@andyqiu/teamkit 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/adr-check.mjs — ADR 体系合规校验(teamkit ADR 体系)
4
+ *
5
+ * 简化版(teamkit 是新项目,纯 slug 命名,无 NNNN 历史编号)。
6
+ *
7
+ * 校验项:
8
+ * A. ADR 文件结构(docs/adr/)
9
+ * - 文件命名:纯 slug,小写字母开头,kebab-case,不允许数字开头
10
+ * - slug 唯一性(文件名 stem 全局唯一)
11
+ * - frontmatter 必填:title / status / date / author
12
+ * - status 合法(Proposed / Draft / Accepted / Superseded / Deprecated / Rejected)
13
+ * - Superseded 必须有 superseded-by 指向
14
+ * - supersedes 必须双向闭环(用 slug stem 互查)
15
+ *
16
+ * B. 三向引用
17
+ * - ADR 的 code-refs 列出的文件必须存在([TBD] / "—" / N/A 跳过;支持 glob)
18
+ * - teamkit 无 PRD 章节校验(teamkit 模板无 prd-refs 字段,跳过)
19
+ *
20
+ * C. 触发式历史补全(机制 #5)
21
+ * - 检查 git diff 改动文件
22
+ * - 对每个 plugins/*.ts、workflows/*.yaml、tools/*.ts、agents/*.md、commands/*.md、lib/*.ts:
23
+ * * 该文件必须能 grep 到 `ADR:<slug>` 或 `ADR-NNNN` 注释,或某 ADR 的 code-refs 包含它
24
+ * * 没有 → 警告(迁移期 < 7 天)/ 错误(迁移期 >= 7 天)
25
+ * - 迁移期起点:.adr-migration-start 文件读取(不存在则今天)
26
+ * - 环境变量 ADR_STRICT=1 强制升级为错误(CI 用)
27
+ *
28
+ * D. PR 模板存在性
29
+ * - .github/pull_request_template.md 必须存在且含 "ADR" 关键字
30
+ * - teamkit Phase 0:.github/pull_request_template.md 尚未创建,warn 跳过不阻塞
31
+ *
32
+ * E. README 索引同步(调 adr-index-sync.mjs --check)
33
+ *
34
+ * 退出码:
35
+ * 0 = 全过 / 仅警告
36
+ * 1 = 有错误
37
+ *
38
+ * 作者:Andy
39
+ */
40
+
41
+ // 注意:fs.globSync 在 Node 22+ 才稳定(Node 20 没有),用 namespace import + 运行时能力检测,
42
+ // 避免 import 阶段在 Node 20 直接 ReferenceError。
43
+ import * as nodeFs from "node:fs"
44
+ import * as path from "node:path"
45
+ import { fileURLToPath } from "node:url"
46
+ import { execSync } from "node:child_process"
47
+
48
+ const { existsSync, promises: fs } = nodeFs
49
+ const globSync = typeof nodeFs.globSync === "function" ? nodeFs.globSync : null
50
+
51
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
52
+ const ADR_DIR = path.join(ROOT, "docs", "adr")
53
+ const MIGRATION_FILE = path.join(ROOT, ".adr-migration-start")
54
+ const STRICT = process.env.ADR_STRICT === "1"
55
+ const MIGRATION_DAYS = 7
56
+
57
+ // ── ADR 文件命名 regex ──
58
+ // 纯 slug:小写字母开头、kebab-case;template.md 与 README.md 排除
59
+ const SLUG_RE = /^[a-z][a-z0-9-]*\.md$/
60
+ const isTemplate = (f) => f === "template.md"
61
+ const isReadme = (f) => f === "README.md"
62
+ const isAdrFile = (f) => !isTemplate(f) && !isReadme(f) && SLUG_RE.test(f)
63
+
64
+ let failed = 0
65
+ let warned = 0
66
+ let passed = 0
67
+
68
+ const ok = (msg) => { passed++; console.log(` ✅ ${msg}`) }
69
+ const warn = (msg) => { warned++; console.log(` ⚠️ ${msg}`) }
70
+ const bad = (msg) => { failed++; console.log(` ❌ ${msg}`) }
71
+ const section = (t) => console.log(`\n=== ${t} ===`)
72
+
73
+ // ─── 工具:解析 ADR frontmatter ──────────────────────────
74
+ async function parseADR(filepath) {
75
+ const raw = await fs.readFile(filepath, "utf8")
76
+ const m = raw.match(/^---\n([\s\S]*?)\n---/)
77
+ if (!m) return null
78
+ const fm = {}
79
+ const lines = m[1].split("\n")
80
+ let curKey = null
81
+ let curArr = null
82
+ for (const line of lines) {
83
+ const kv = line.match(/^([a-z][\w-]*):\s*(.*)$/)
84
+ if (kv) {
85
+ curKey = kv[1]
86
+ const val = kv[2].trim()
87
+ if (/^\[.*\]$/.test(val)) {
88
+ // inline array: [acp-protocol] 或 [slug-a, slug-b]
89
+ fm[curKey] = val.slice(1, -1).split(",")
90
+ .map(s => s.trim().replace(/^["']|["']$/g, ""))
91
+ .filter(Boolean)
92
+ curArr = null
93
+ } else if (val === "" || val === "[]") {
94
+ fm[curKey] = []
95
+ curArr = fm[curKey]
96
+ } else {
97
+ fm[curKey] = val.replace(/^["']|["']$/g, "")
98
+ curArr = null
99
+ }
100
+ } else if (curArr && line.startsWith(" - ")) {
101
+ curArr.push(line.slice(4).trim().replace(/^["']|["']$/g, ""))
102
+ }
103
+ }
104
+ return { fm, raw }
105
+ }
106
+
107
+ // 取文件的 stem(去 .md 后缀)
108
+ const stemOf = (f) => f.replace(/\.md$/, "")
109
+
110
+ // ─── A. ADR 文件结构校验 ────────────────────────────────
111
+
112
+ section("A. ADR 文件结构")
113
+
114
+ // teamkit 使用 author 字段(不是 deciders)
115
+ const REQUIRED_FIELDS = ["title", "status", "date", "author"]
116
+ const VALID_STATUS = ["Proposed", "Draft", "Accepted", "Superseded", "Deprecated", "Rejected"]
117
+
118
+ if (!existsSync(ADR_DIR)) {
119
+ bad(`docs/adr/ 不存在`)
120
+ } else {
121
+ const all = (await fs.readdir(ADR_DIR)).sort()
122
+
123
+ // 防回潮:旧 NNNN- 前缀命名一律报错
124
+ for (const f of all) {
125
+ if (/^\d{4}-/.test(f)) {
126
+ bad(`${f}: 检测到旧 NNNN- 前缀命名,teamkit 使用纯 slug 命名`)
127
+ }
128
+ }
129
+
130
+ const files = all.filter(isAdrFile)
131
+ const adrs = []
132
+ for (const f of files) {
133
+ const parsed = await parseADR(path.join(ADR_DIR, f))
134
+ if (!parsed) { bad(`${f}: frontmatter 解析失败`); continue }
135
+ adrs.push({ file: f, stem: stemOf(f), ...parsed })
136
+ }
137
+
138
+ // slug 唯一性(stem 即 key,文件名层面 fs 已保证不重,但二次防御)
139
+ const slugMap = new Map()
140
+ for (const a of adrs) {
141
+ if (!slugMap.has(a.stem)) slugMap.set(a.stem, [])
142
+ slugMap.get(a.stem).push(a.file)
143
+ }
144
+ let dupFound = false
145
+ for (const [stem, files] of slugMap) {
146
+ if (files.length > 1) {
147
+ bad(`ADR slug 重名:"${stem}" 出现在多个文件:${files.join(", ")}`)
148
+ dupFound = true
149
+ }
150
+ }
151
+ if (!dupFound) ok(`ADR 文件 ${adrs.length} 份,slug 唯一`)
152
+
153
+ // byKey map:以 stem 唯一索引
154
+ const byKey = new Map()
155
+ for (const a of adrs) byKey.set(a.stem, a)
156
+
157
+ // 字段 + status + supersedes 闭环
158
+ for (const a of adrs) {
159
+ for (const f of REQUIRED_FIELDS) {
160
+ if (!a.fm[f] || (Array.isArray(a.fm[f]) && a.fm[f].length === 0 && f === "author")) {
161
+ bad(`${a.file}: 缺字段 ${f}`)
162
+ }
163
+ }
164
+ if (!VALID_STATUS.includes(a.fm.status)) {
165
+ bad(`${a.file}: status="${a.fm.status}" 非法`)
166
+ }
167
+ // superseded-by slug 存在性校验
168
+ if (a.fm.status === "Superseded") {
169
+ const sb = Array.isArray(a.fm["superseded-by"]) ? a.fm["superseded-by"] : []
170
+ if (sb.length === 0) {
171
+ bad(`${a.file}: status=Superseded 但 superseded-by 为空`)
172
+ } else {
173
+ for (const sbSlug of sb) {
174
+ const tgtSb = byKey.get(String(sbSlug).trim())
175
+ if (!tgtSb) bad(`${a.file}: superseded-by "${sbSlug}" 不存在`)
176
+ }
177
+ }
178
+ }
179
+ // 双向闭环(supersedes / superseded-by 均用 slug stem)
180
+ const supers = Array.isArray(a.fm.supersedes) ? a.fm.supersedes : []
181
+ for (const target of supers) {
182
+ const tgtStr = String(target).trim()
183
+ const tgt = byKey.get(tgtStr)
184
+ if (!tgt) { bad(`${a.file}: supersedes ${target} 不存在`); continue }
185
+ const tgtSb = Array.isArray(tgt.fm["superseded-by"]) ? tgt.fm["superseded-by"] : []
186
+ const closed = tgtSb.map(String).map((s) => s.trim()).includes(a.stem)
187
+ if (!closed) {
188
+ bad(`${a.file} → ${tgt.file}: supersedes 未双向闭环(${tgt.file} 缺 superseded-by: ${a.stem})`)
189
+ }
190
+ }
191
+ }
192
+ ok(`字段 / status / supersedes 闭环检查完成`)
193
+ }
194
+
195
+ // ─── B. 三向引用 ─────────────────────────────────────────
196
+
197
+ section("B. 三向引用(ADR → Code)")
198
+
199
+ if (existsSync(ADR_DIR)) {
200
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
201
+ for (const f of files) {
202
+ const parsed = await parseADR(path.join(ADR_DIR, f))
203
+ if (!parsed) continue
204
+ const refs = Array.isArray(parsed.fm["code-refs"]) ? parsed.fm["code-refs"] : []
205
+ for (const ref of refs) {
206
+ // 跳过 N/A 占位
207
+ if (!ref || ref === "TBD" || ref === "—" || ref === "N/A") continue
208
+ // 按 (, # 切割,取第一个文件路径(支持 inline # 注释)
209
+ const filepath = ref.split(/[,(#]/)[0].trim()
210
+ // glob 模式(含 * ? [ {)→ 展开后检查至少有一个匹配
211
+ if (/[*?\[\{]/.test(filepath)) {
212
+ if (!globSync) {
213
+ warn(`${f}: 当前 Node 不支持 fs.globSync,跳过 code-refs glob "${filepath}" 存在性检查`)
214
+ continue
215
+ }
216
+ try {
217
+ const matches = globSync(filepath, { cwd: ROOT })
218
+ if (!matches || matches.length === 0) {
219
+ warn(`${f}: code-refs glob "${filepath}" 无任何匹配(追溯型 ADR 可忽略)`)
220
+ }
221
+ } catch (err) {
222
+ warn(`${f}: code-refs glob "${filepath}" 解析失败:${err.message}`)
223
+ }
224
+ continue
225
+ }
226
+ const abs = path.join(ROOT, filepath)
227
+ if (!existsSync(abs)) {
228
+ warn(`${f}: code-refs "${filepath}" 不存在(Phase 0:代码尚未实现,可忽略)`)
229
+ }
230
+ }
231
+ }
232
+ ok(`code-refs 文件存在性检查完成`)
233
+ }
234
+
235
+ // ─── B'. PRD 引用(teamkit 无 PRD 章节号校验,跳过)──────
236
+
237
+ section("B'. PRD ↔ ADR 双向引用")
238
+ warn(`teamkit 模板无 prd-refs 字段,跳过 PRD 章节校验(不阻塞)`)
239
+
240
+
241
+ // ─── C. 触发式历史补全 ──────────────────────────────────
242
+
243
+ section("C. 触发式补全(git diff 模式)")
244
+
245
+ // 迁移期起点
246
+ let migrationStart
247
+ if (existsSync(MIGRATION_FILE)) {
248
+ migrationStart = new Date(await fs.readFile(MIGRATION_FILE, "utf8"))
249
+ } else {
250
+ migrationStart = new Date()
251
+ await fs.writeFile(MIGRATION_FILE, migrationStart.toISOString())
252
+ console.log(` ℹ️ 初始化迁移期起点:${migrationStart.toISOString()}`)
253
+ }
254
+ const daysSince = Math.floor((Date.now() - migrationStart.getTime()) / 86400000)
255
+ const grace = daysSince < MIGRATION_DAYS && !STRICT
256
+ console.log(` 迁移期已过 ${daysSince} / ${MIGRATION_DAYS} 天,模式:${grace ? "warn" : "error"}(STRICT=${STRICT})`)
257
+
258
+ // 收 git diff
259
+ let diffFiles = []
260
+ try {
261
+ // 双 diff 模式:staged(pre-commit)或 last-commit(CI)
262
+ const diffMode = process.env.ADR_DIFF_MODE === "last-commit" ? "last-commit" : "staged"
263
+ const diffCmd = diffMode === "last-commit"
264
+ ? "git diff --name-status HEAD~1 HEAD"
265
+ : "git diff --cached --name-status"
266
+ console.log(` ℹ️ diff 模式:${diffMode}(${diffCmd})`)
267
+ const out = execSync(diffCmd, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] })
268
+ diffFiles = out.toString()
269
+ .split("\n")
270
+ .map((x) => x.trim())
271
+ .filter(Boolean)
272
+ .map((line) => {
273
+ const parts = line.split("\t")
274
+ const status = parts[0]
275
+ const file = parts[parts.length - 1]
276
+ return { status, file }
277
+ })
278
+ .filter(({ status }) => !status.startsWith("D"))
279
+ .map(({ file }) => file)
280
+ } catch {
281
+ console.log(` ℹ️ 无 git diff(首次提交或非 git 仓库),跳过 C 校验`)
282
+ diffFiles = []
283
+ }
284
+
285
+ // 收集所有 ADR 的 code-refs(含 glob 展开)
286
+ const allCodeRefs = new Set()
287
+ if (existsSync(ADR_DIR)) {
288
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
289
+ for (const f of files) {
290
+ const parsed = await parseADR(path.join(ADR_DIR, f))
291
+ if (!parsed) continue
292
+ const refs = Array.isArray(parsed.fm["code-refs"]) ? parsed.fm["code-refs"] : []
293
+ for (const r of refs) {
294
+ const fp = String(r).split(/[,(#]/)[0].trim().replace(/\\/g, "/")
295
+ if (!fp || fp === "TBD" || fp === "—" || fp === "N/A") continue
296
+ if (/[*?\[\{]/.test(fp)) {
297
+ if (globSync) {
298
+ try {
299
+ for (const match of globSync(fp, { cwd: ROOT })) {
300
+ allCodeRefs.add(String(match).replace(/\\/g, "/"))
301
+ }
302
+ } catch (err) {
303
+ warn(`${f}: code-refs glob "${fp}" 展开失败:${err.message}`)
304
+ allCodeRefs.add(fp)
305
+ }
306
+ } else {
307
+ warn(`${f}: 当前 Node 不支持 fs.globSync,跳过 code-refs glob "${fp}" 展开`)
308
+ allCodeRefs.add(fp)
309
+ }
310
+ } else {
311
+ allCodeRefs.add(fp)
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ const TARGET_PATTERNS = [/^plugins\/.+\.ts$/, /^workflows\/.+\.yaml$/, /^tools\/.+\.ts$/, /^agents\/.+\.md$/, /^commands\/.+\.md$/, /^lib\/.+\.ts$/]
318
+ // 注释格式:新格式 `ADR:<slug>`(冒号 + kebab-case)或旧格式 `ADR-NNNN`(四位数字)
319
+ const ADR_COMMENT_RE = /ADR:[a-z][a-z0-9-]+|ADR-\d{4}/
320
+ let trigChecked = 0
321
+ for (const file of diffFiles) {
322
+ const norm = file.replace(/\\/g, "/")
323
+ if (!TARGET_PATTERNS.some((p) => p.test(norm))) continue
324
+ trigChecked++
325
+ let hasComment = false
326
+ try {
327
+ const content = await fs.readFile(path.join(ROOT, file), "utf8")
328
+ if (ADR_COMMENT_RE.test(content)) hasComment = true
329
+ } catch { /* 文件可能被删 */ }
330
+ const inRefs = allCodeRefs.has(norm)
331
+ if (!hasComment && !inRefs) {
332
+ const msg = `${file}: 改动了但找不到 ADR 引用(既无注释 ADR:<slug>/ADR-NNNN 也无 ADR code-refs)`
333
+ grace ? warn(msg) : bad(msg)
334
+ }
335
+ }
336
+ ok(`触发式补全检查完成(扫描 ${trigChecked} 个改动文件)`)
337
+
338
+ // ─── D. PR 模板 ──────────────────────────────────────────
339
+
340
+ section("D. PR 模板")
341
+ const PR_TEMPLATE = path.join(ROOT, ".github", "pull_request_template.md")
342
+ if (!existsSync(PR_TEMPLATE)) {
343
+ warn(`.github/pull_request_template.md 不存在(teamkit Phase 0:尚未创建,不阻塞)`)
344
+ } else {
345
+ const content = await fs.readFile(PR_TEMPLATE, "utf8")
346
+ if (!/ADR/.test(content)) bad(`PR 模板未含 "ADR" 关键字`)
347
+ else ok(`PR 模板存在且含 ADR checklist`)
348
+ }
349
+
350
+ // ─── E. README 索引同步 ──────────────────────────────────
351
+
352
+ section("E. README 索引同步")
353
+ try {
354
+ execSync("node scripts/adr-index-sync.mjs --check", { cwd: ROOT, stdio: "pipe" })
355
+ ok(`README 索引与 ADR 文件同步`)
356
+ } catch {
357
+ warn(`README 索引未同步 — 跑 \`npm run adr:sync\` 修复`)
358
+ }
359
+
360
+
361
+ // ─── 总结 ───────────────────────────────────────────────
362
+
363
+ console.log(`\n=== 总结 ===`)
364
+ console.log(` ✅ 通过:${passed}`)
365
+ console.log(` ⚠️ 警告:${warned}`)
366
+ console.log(` ❌ 失败:${failed}`)
367
+
368
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/adr-index-sync.mjs — 自动同步 docs/adr/README.md 索引表(teamkit ADR 体系)
4
+ *
5
+ * teamkit 是新项目,纯 slug 命名,无 NNNN 历史编号。
6
+ *
7
+ * 用法:
8
+ * node scripts/adr-index-sync.mjs # 默认:写回 README.md
9
+ * node scripts/adr-index-sync.mjs --check # 仅检查,不写;不同步退出码 1
10
+ *
11
+ * 工作原理:
12
+ * 1. 扫 docs/adr/*.md(纯 slug 格式)取 frontmatter
13
+ * 2. 按 date 升序排序(同 date 按 id 二级排序)生成索引行
14
+ * 3. 在 README.md 找 "## 当前 ADR 列表" marker → 表头 → 替换到下一段
15
+ * 4. --check 模式不写文件,diff 不为空则 exit 1
16
+ *
17
+ * 作者:Andy
18
+ */
19
+
20
+ import { existsSync, promises as fs } from "node:fs"
21
+ import * as path from "node:path"
22
+ import { fileURLToPath } from "node:url"
23
+
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
25
+ const ADR_DIR = path.join(ROOT, "docs", "adr")
26
+ const README = path.join(ADR_DIR, "README.md")
27
+ const CHECK_ONLY = process.argv.includes("--check")
28
+
29
+ // 与 adr-check.mjs 同步的命名 regex:纯 slug
30
+ const SLUG_RE = /^[a-z][a-z0-9-]*\.md$/
31
+ const isTemplate = (f) => f === "template.md"
32
+ const isReadme = (f) => f === "README.md"
33
+ const isAdrFile = (f) => !isTemplate(f) && !isReadme(f) && SLUG_RE.test(f)
34
+
35
+ // 内联简化版 frontmatter parser(保持脚本独立,不 import adr-check.mjs)
36
+ async function parseADR(filepath) {
37
+ const raw = await fs.readFile(filepath, "utf8")
38
+ const m = raw.match(/^---\n([\s\S]*?)\n---/)
39
+ if (!m) return null
40
+ const fm = {}
41
+ let curArr = null
42
+ for (const line of m[1].split("\n")) {
43
+ const kv = line.match(/^([a-z][\w-]*):\s*(.*)$/)
44
+ if (kv) {
45
+ const v = kv[2].trim()
46
+ if (/^\[.*\]$/.test(v)) {
47
+ // inline array: [acp-protocol] 或 [slug-a, slug-b]
48
+ fm[kv[1]] = v.slice(1, -1).split(",")
49
+ .map(s => s.trim().replace(/^["']|["']$/g, ""))
50
+ .filter(Boolean)
51
+ curArr = null
52
+ } else if (v === "" || v === "[]") {
53
+ fm[kv[1]] = []
54
+ curArr = fm[kv[1]]
55
+ } else {
56
+ fm[kv[1]] = v.replace(/^["']|["']$/g, "")
57
+ curArr = null
58
+ }
59
+ } else if (curArr && line.startsWith(" - ")) {
60
+ curArr.push(line.slice(4).trim().replace(/^["']|["']$/g, ""))
61
+ }
62
+ }
63
+ return fm
64
+ }
65
+
66
+ async function buildIndex() {
67
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
68
+ const rows = []
69
+ for (const f of files) {
70
+ const fm = await parseADR(path.join(ADR_DIR, f))
71
+ if (!fm) continue
72
+ // ID = 文件 stem(纯 slug)
73
+ const id = f.replace(/\.md$/, "")
74
+ const date = String(fm.date || "").trim() || "—"
75
+ const title = String(fm.title || "").replace(/^["']|["']$/g, "")
76
+ const status = fm.status || "?"
77
+ const sb = Array.isArray(fm["superseded-by"]) ? fm["superseded-by"].filter(Boolean) : []
78
+ const sup = Array.isArray(fm.supersedes) ? fm.supersedes.filter(Boolean) : []
79
+ let rel = "—"
80
+ if (sb.length) rel = `by ${sb.map((x) => String(x).trim()).join(", ")}`
81
+ else if (sup.length) rel = `Supersedes ${sup.map((x) => String(x).trim()).join(", ")}`
82
+ const statusCell = status === "Superseded" ? `**${status}**` : status
83
+ const titleCell = `[${title}](./${f})`
84
+ rows.push({ id, date, title, status, sb, sup, file: f, line: `| ${id} | ${date} | ${titleCell} | ${statusCell} | ${rel} |` })
85
+ }
86
+ // 排序:按 date 升序,同 date 按 id 二级
87
+ rows.sort((a, b) => a.date.localeCompare(b.date) || a.id.localeCompare(b.id))
88
+ return rows.map((r) => r.line)
89
+ }
90
+
91
+ async function main() {
92
+ if (!existsSync(README)) {
93
+ console.error(`✗ 未找到 ${README}`)
94
+ process.exit(1)
95
+ }
96
+ const old = await fs.readFile(README, "utf8")
97
+ const rows = await buildIndex()
98
+ const tableHeader = "| Slug | Date | 标题 | 状态 | Superseded-by |\n|---|---|---|---|---|"
99
+ const tableBlock = `${tableHeader}\n${rows.join("\n")}`
100
+
101
+ // marker:## 当前 ADR 列表 + 空行 + 表格(到下一段 ## 或文档末尾)
102
+ const markerRe = /(## 当前 ADR 列表\n\n)(\|[\s\S]*?)(?=\n\n##|\n\n<!--|\n---\n|$)/
103
+ const m = old.match(markerRe)
104
+ if (!m) {
105
+ console.error(`✗ README 找不到 "## 当前 ADR 列表" 段或表格 marker`)
106
+ process.exit(1)
107
+ }
108
+
109
+ const newContent = old.replace(markerRe, `$1${tableBlock}\n`)
110
+
111
+ if (CHECK_ONLY) {
112
+ if (newContent === old) {
113
+ console.log(`✓ README 索引与 ADR 文件同步(${rows.length} 行)`)
114
+ process.exit(0)
115
+ } else {
116
+ console.error(`✗ README 索引与 ADR 文件不同步`)
117
+ console.error(` 跑 \`node scripts/adr-index-sync.mjs\` 自动修复`)
118
+ process.exit(1)
119
+ }
120
+ }
121
+
122
+ if (newContent === old) {
123
+ console.log(`✓ README 索引已是最新(${rows.length} 行),未改动`)
124
+ } else {
125
+ await fs.writeFile(README, newContent, "utf8")
126
+ console.log(`✓ README 索引已更新:${rows.length} 行`)
127
+ }
128
+ }
129
+
130
+ main().catch((err) => {
131
+ console.error(err)
132
+ process.exit(1)
133
+ })