@andyqiu/codeforge 0.3.11 → 0.3.13

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,428 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * adr-check.mjs — ADR 体系合规校验(泛化版,由 codeforge adr-init 下发)
4
+ *
5
+ * 与 CodeForge 自仓库 scripts/adr-check.mjs 的区别:
6
+ * - 增加 ADR_ROOT / ADR_DIR 环境变量参数化,便于在不同项目结构中复用
7
+ * - 默认 ROOT 仍按 `path.resolve(__dirname, "..")` 解析(脚本放 scripts/ 子目录时指向项目根)
8
+ * - PR 模板硬性必需 → 改为 warn(不强制目标项目用 .github/pull_request_template.md)
9
+ * - 不含 CodeForge 专属业务逻辑
10
+ *
11
+ * 校验项:
12
+ * A. ADR 文件结构(docs/adr/)
13
+ * - 文件命名:纯 slug,小写字母开头,kebab-case,不允许数字开头
14
+ * - slug 唯一性(文件名 stem 全局唯一)
15
+ * - frontmatter 必填:title / status / date / deciders / code-refs
16
+ * - status 合法(Proposed / Accepted / Superseded / Deprecated / Rejected)
17
+ * - Superseded 必须有 superseded-by 指向
18
+ * - supersedes 必须双向闭环(用 slug stem 互查)
19
+ *
20
+ * B. 三向引用
21
+ * - ADR 的 code-refs 列出的文件必须存在([TBD] / "—" / N/A 跳过;支持 glob)
22
+ * - ADR 的 prd-refs 章节必须能在 docs/PRD.md grep 到(warn,若 PRD 不存在则跳过)
23
+ * - PRD 中 `<!-- ADR: <slug[, slug...]> -->` 注释引用的 ADR 必须真存在
24
+ *
25
+ * C. 触发式历史补全
26
+ * - 检查 git diff 改动文件
27
+ * - 对每个 plugins/*.ts、workflows/*.yaml、tools/*.ts、agents/*.md、commands/*.md、lib/*.ts:
28
+ * * 该文件必须能 grep 到 `ADR:<slug>` 或 `ADR-NNNN` 注释,或某 ADR 的 code-refs 包含它
29
+ * - 迁移期起点:.adr-migration-start 文件读取(不存在则今天)
30
+ * - 环境变量 ADR_STRICT=1 强制升级为错误(CI 用)
31
+ * - 环境变量 ADR_DIFF_MODE=last-commit 用 push 前模式(默认 staged)
32
+ *
33
+ * D. PR 模板存在性(warn-only,下发版不强制)
34
+ *
35
+ * E. README 索引同步(调 adr-index-sync.mjs --check)
36
+ *
37
+ * 环境变量:
38
+ * ADR_ROOT 项目根目录绝对/相对路径;默认 path.resolve(__dirname, "..")
39
+ * ADR_DIR ADR 目录;默认 <ROOT>/docs/adr
40
+ * ADR_STRICT "1" 时把触发式补全 warn 升级为 error
41
+ * ADR_DIFF_MODE "last-commit" | "staged"(默认 staged)
42
+ *
43
+ * 退出码:
44
+ * 0 = 全过 / 仅警告
45
+ * 1 = 有错误
46
+ */
47
+
48
+ // 注意:fs.globSync 在 Node 22+ 才稳定(Node 20 没有),用 namespace import + 运行时能力检测
49
+ import * as nodeFs from "node:fs"
50
+ import * as path from "node:path"
51
+ import { fileURLToPath } from "node:url"
52
+ import { execSync } from "node:child_process"
53
+
54
+ const { existsSync, promises: fs } = nodeFs
55
+ const globSync = typeof nodeFs.globSync === "function" ? nodeFs.globSync : null
56
+
57
+ // ── 参数化 ROOT / ADR_DIR ─────────────────────────────────
58
+ // 默认:脚本位于 <ROOT>/scripts/ 或 <ROOT>/.githooks/,向上一层即项目根
59
+ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
60
+ const ROOT = process.env.ADR_ROOT ? path.resolve(process.env.ADR_ROOT) : DEFAULT_ROOT
61
+ const ADR_DIR = process.env.ADR_DIR ? path.resolve(process.env.ADR_DIR) : path.join(ROOT, "docs", "adr")
62
+ const MIGRATION_FILE = path.join(ROOT, ".adr-migration-start")
63
+ const STRICT = process.env.ADR_STRICT === "1"
64
+ const MIGRATION_DAYS = 7
65
+
66
+ // ── ADR 文件命名 regex ──
67
+ // 纯 slug:小写字母开头、kebab-case;template.md 与 README.md 排除
68
+ const SLUG_RE = /^[a-z][a-z0-9-]*\.md$/
69
+ const isTemplate = (f) => f === "template.md"
70
+ const isReadme = (f) => f === "README.md"
71
+ const isAdrFile = (f) => !isTemplate(f) && !isReadme(f) && SLUG_RE.test(f)
72
+
73
+ let failed = 0
74
+ let warned = 0
75
+ let passed = 0
76
+
77
+ const ok = (msg) => { passed++; console.log(` ✅ ${msg}`) }
78
+ const warn = (msg) => { warned++; console.log(` ⚠️ ${msg}`) }
79
+ const bad = (msg) => { failed++; console.log(` ❌ ${msg}`) }
80
+ const section = (t) => console.log(`\n=== ${t} ===`)
81
+
82
+ // ─── 工具:解析 ADR frontmatter ──────────────────────────
83
+ async function parseADR(filepath) {
84
+ const raw = await fs.readFile(filepath, "utf8")
85
+ const m = raw.match(/^---\n([\s\S]*?)\n---/)
86
+ if (!m) return null
87
+ const fm = {}
88
+ const lines = m[1].split("\n")
89
+ let curKey = null
90
+ let curArr = null
91
+ for (const line of lines) {
92
+ const kv = line.match(/^([a-z][\w-]*):\s*(.*)$/)
93
+ if (kv) {
94
+ curKey = kv[1]
95
+ const val = kv[2].trim()
96
+ if (/^\[.*\]$/.test(val)) {
97
+ fm[curKey] = val.slice(1, -1).split(",")
98
+ .map(s => s.trim().replace(/^["']|["']$/g, ""))
99
+ .filter(Boolean)
100
+ curArr = null
101
+ } else if (val === "" || val === "[]") {
102
+ fm[curKey] = []
103
+ curArr = fm[curKey]
104
+ } else {
105
+ fm[curKey] = val.replace(/^["']|["']$/g, "")
106
+ curArr = null
107
+ }
108
+ } else if (curArr && line.startsWith(" - ")) {
109
+ curArr.push(line.slice(4).trim().replace(/^["']|["']$/g, ""))
110
+ }
111
+ }
112
+ return { fm, raw }
113
+ }
114
+
115
+ const stemOf = (f) => f.replace(/\.md$/, "")
116
+
117
+ // ─── A. ADR 文件结构校验 ────────────────────────────────
118
+
119
+ section("A. ADR 文件结构")
120
+
121
+ const REQUIRED_FIELDS = ["title", "status", "date", "deciders", "code-refs"]
122
+ const VALID_STATUS = ["Proposed", "Accepted", "Superseded", "Deprecated", "Rejected"]
123
+
124
+ if (!existsSync(ADR_DIR)) {
125
+ bad(`${path.relative(ROOT, ADR_DIR)} 不存在`)
126
+ } else {
127
+ const all = (await fs.readdir(ADR_DIR)).sort()
128
+
129
+ // 防回潮:旧 NNNN- 前缀命名一律报错
130
+ for (const f of all) {
131
+ if (/^\d{4}-/.test(f)) {
132
+ bad(`${f}: 检测到旧 NNNN- 前缀命名,请改为纯 slug`)
133
+ }
134
+ }
135
+
136
+ const files = all.filter(isAdrFile)
137
+ const adrs = []
138
+ for (const f of files) {
139
+ const parsed = await parseADR(path.join(ADR_DIR, f))
140
+ if (!parsed) { bad(`${f}: frontmatter 解析失败`); continue }
141
+ adrs.push({ file: f, stem: stemOf(f), ...parsed })
142
+ }
143
+
144
+ // slug 唯一性(stem 即 key)
145
+ const slugMap = new Map()
146
+ for (const a of adrs) {
147
+ if (!slugMap.has(a.stem)) slugMap.set(a.stem, [])
148
+ slugMap.get(a.stem).push(a.file)
149
+ }
150
+ let dupFound = false
151
+ for (const [stem, files] of slugMap) {
152
+ if (files.length > 1) {
153
+ bad(`ADR slug 重名:"${stem}" 出现在多个文件:${files.join(", ")}`)
154
+ dupFound = true
155
+ }
156
+ }
157
+ if (!dupFound) ok(`ADR 文件 ${adrs.length} 份,slug 唯一`)
158
+
159
+ // byKey map
160
+ const byKey = new Map()
161
+ for (const a of adrs) byKey.set(a.stem, a)
162
+
163
+ // 字段 + status + supersedes 闭环
164
+ const requiredArrayFields = new Set(["deciders", "code-refs"])
165
+ for (const a of adrs) {
166
+ for (const f of REQUIRED_FIELDS) {
167
+ if (!a.fm[f] || (requiredArrayFields.has(f) && (!Array.isArray(a.fm[f]) || a.fm[f].length === 0))) {
168
+ bad(`${a.file}: 缺字段 ${f}`)
169
+ }
170
+ }
171
+ if (!VALID_STATUS.includes(a.fm.status)) {
172
+ bad(`${a.file}: status="${a.fm.status}" 非法`)
173
+ }
174
+ if (a.fm.status === "Superseded") {
175
+ const sb = Array.isArray(a.fm["superseded-by"]) ? a.fm["superseded-by"] : []
176
+ if (sb.length === 0) {
177
+ bad(`${a.file}: status=Superseded 但 superseded-by 为空`)
178
+ } else {
179
+ for (const sbSlug of sb) {
180
+ const tgtSb = byKey.get(String(sbSlug).trim())
181
+ if (!tgtSb) bad(`${a.file}: superseded-by "${sbSlug}" 不存在`)
182
+ }
183
+ }
184
+ }
185
+ const supers = Array.isArray(a.fm.supersedes) ? a.fm.supersedes : []
186
+ for (const target of supers) {
187
+ const tgtStr = String(target).trim()
188
+ const tgt = byKey.get(tgtStr)
189
+ if (!tgt) { bad(`${a.file}: supersedes ${target} 不存在`); continue }
190
+ const tgtSb = Array.isArray(tgt.fm["superseded-by"]) ? tgt.fm["superseded-by"] : []
191
+ const closed = tgtSb.map(String).map((s) => s.trim()).includes(a.stem)
192
+ if (!closed) {
193
+ bad(`${a.file} → ${tgt.file}: supersedes 未双向闭环(${tgt.file} 缺 superseded-by: ${a.stem})`)
194
+ }
195
+ }
196
+ }
197
+ ok(`字段 / status / supersedes 闭环检查完成`)
198
+ }
199
+
200
+ // ─── B. 三向引用 ─────────────────────────────────────────
201
+
202
+ section("B. 三向引用(ADR → Code)")
203
+
204
+ if (existsSync(ADR_DIR)) {
205
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
206
+ for (const f of files) {
207
+ const parsed = await parseADR(path.join(ADR_DIR, f))
208
+ if (!parsed) continue
209
+ const refs = Array.isArray(parsed.fm["code-refs"]) ? parsed.fm["code-refs"] : []
210
+ for (const ref of refs) {
211
+ if (!ref || ref === "TBD" || ref === "—" || ref === "N/A") continue
212
+ const filepath = ref.split(/[,(#]/)[0].trim()
213
+ if (/[*?\[\{]/.test(filepath)) {
214
+ if (!globSync) {
215
+ warn(`${f}: 当前 Node 不支持 fs.globSync,跳过 code-refs glob "${filepath}" 存在性检查`)
216
+ continue
217
+ }
218
+ try {
219
+ const matches = globSync(filepath, { cwd: ROOT })
220
+ if (!matches || matches.length === 0) {
221
+ warn(`${f}: code-refs glob "${filepath}" 无任何匹配(追溯型 ADR 可忽略)`)
222
+ }
223
+ } catch (err) {
224
+ warn(`${f}: code-refs glob "${filepath}" 解析失败:${err.message}`)
225
+ }
226
+ continue
227
+ }
228
+ const abs = path.join(ROOT, filepath)
229
+ if (!existsSync(abs)) {
230
+ warn(`${f}: code-refs "${filepath}" 不存在(追溯型 ADR 可忽略)`)
231
+ }
232
+ }
233
+ }
234
+ ok(`code-refs 文件存在性检查完成`)
235
+ }
236
+
237
+ // ─── B'. PRD 反向引用双向校验 ──────────────────────────
238
+
239
+ section("B'. PRD ↔ ADR 双向引用")
240
+
241
+ const PRD_PATH = path.join(ROOT, "docs", "PRD.md")
242
+ if (!existsSync(PRD_PATH)) {
243
+ warn(`docs/PRD.md 不存在,跳过 B' 段(下发版可选)`)
244
+ } else {
245
+ const prdContent = await fs.readFile(PRD_PATH, "utf8")
246
+
247
+ if (existsSync(ADR_DIR)) {
248
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
249
+ let prdRefChecked = 0
250
+ let prdRefMissing = 0
251
+ for (const f of files) {
252
+ const parsed = await parseADR(path.join(ADR_DIR, f))
253
+ if (!parsed) continue
254
+ const refs = Array.isArray(parsed.fm["prd-refs"]) ? parsed.fm["prd-refs"] : []
255
+ for (const ref of refs) {
256
+ if (!ref || ref === "TBD" || ref === "—") continue
257
+ const m = String(ref).match(/§\s*(\d+(?:\.\d+)*)/)
258
+ if (!m) continue
259
+ prdRefChecked++
260
+ const sectionId = m[1]
261
+ const sectionRegex = new RegExp(`^##+\\s+${sectionId.replace(".", "\\.")}\\b`, "m")
262
+ if (!sectionRegex.test(prdContent)) {
263
+ warn(`${f}: prd-refs "${ref}" 在 PRD.md 找不到对应章节 §${sectionId}`)
264
+ prdRefMissing++
265
+ }
266
+ }
267
+ }
268
+ ok(`PRD 章节存在性:${prdRefChecked} 个 prd-refs 检查,${prdRefMissing} 个找不到`)
269
+ }
270
+
271
+ // 反向:PRD 中 <!-- ADR: <slug> --> 注释引用的 ADR 必须真存在
272
+ const adrCommentRe = /<!--\s*ADR:\s*([^>]+?)\s*-->/g
273
+ let prdAdrCommentCount = 0
274
+ let prdAdrInvalid = 0
275
+ const knownAdrKeys = new Set()
276
+ if (existsSync(ADR_DIR)) {
277
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
278
+ for (const f of files) knownAdrKeys.add(stemOf(f))
279
+ }
280
+ let cm
281
+ while ((cm = adrCommentRe.exec(prdContent)) !== null) {
282
+ const ids = cm[1].split(",").map((x) => x.trim()).filter(Boolean)
283
+ for (const rawId of ids) {
284
+ prdAdrCommentCount++
285
+ if (!knownAdrKeys.has(rawId)) {
286
+ bad(`docs/PRD.md: <!-- ADR: ${rawId} --> 注释引用的 ADR 不存在(位置 char ${cm.index})`)
287
+ prdAdrInvalid++
288
+ }
289
+ }
290
+ }
291
+ if (prdAdrCommentCount === 0) {
292
+ warn(`docs/PRD.md 中没有任何 <!-- ADR: <slug> --> 注释(建议至少给关键章节加反向引用)`)
293
+ } else {
294
+ ok(`PRD 反向注释:${prdAdrCommentCount} 个 ADR 引用,${prdAdrInvalid} 个无效`)
295
+ }
296
+ }
297
+
298
+ // ─── C. 触发式历史补全 ──────────────────────────────────
299
+
300
+ section("C. 触发式补全(git diff 模式)")
301
+
302
+ let migrationStart
303
+ if (existsSync(MIGRATION_FILE)) {
304
+ migrationStart = new Date(await fs.readFile(MIGRATION_FILE, "utf8"))
305
+ } else {
306
+ migrationStart = new Date()
307
+ await fs.writeFile(MIGRATION_FILE, migrationStart.toISOString())
308
+ console.log(` ℹ️ 初始化迁移期起点:${migrationStart.toISOString()}`)
309
+ }
310
+ const daysSince = Math.floor((Date.now() - migrationStart.getTime()) / 86400000)
311
+ const grace = daysSince < MIGRATION_DAYS && !STRICT
312
+ console.log(` 迁移期已过 ${daysSince} / ${MIGRATION_DAYS} 天,模式:${grace ? "warn" : "error"}(STRICT=${STRICT})`)
313
+
314
+ let diffFiles = []
315
+ try {
316
+ const diffMode = process.env.ADR_DIFF_MODE === "last-commit" ? "last-commit" : "staged"
317
+ const diffCmd = diffMode === "last-commit"
318
+ ? "git diff --name-status HEAD~1 HEAD"
319
+ : "git diff --cached --name-status"
320
+ console.log(` ℹ️ diff 模式:${diffMode}(${diffCmd})`)
321
+ const out = execSync(diffCmd, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] })
322
+ diffFiles = out.toString()
323
+ .split("\n")
324
+ .map((x) => x.trim())
325
+ .filter(Boolean)
326
+ .map((line) => {
327
+ const parts = line.split("\t")
328
+ const status = parts[0]
329
+ const file = parts[parts.length - 1]
330
+ return { status, file }
331
+ })
332
+ .filter(({ status }) => !status.startsWith("D"))
333
+ .map(({ file }) => file)
334
+ } catch {
335
+ console.log(` ℹ️ 无 git diff(首次提交或非 git 仓库),跳过 C 校验`)
336
+ diffFiles = []
337
+ }
338
+
339
+ const allCodeRefs = new Set()
340
+ if (existsSync(ADR_DIR)) {
341
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
342
+ for (const f of files) {
343
+ const parsed = await parseADR(path.join(ADR_DIR, f))
344
+ if (!parsed) continue
345
+ const refs = Array.isArray(parsed.fm["code-refs"]) ? parsed.fm["code-refs"] : []
346
+ for (const r of refs) {
347
+ const fp = String(r).split(/[,(#]/)[0].trim().replace(/\\/g, "/")
348
+ if (!fp || fp === "TBD" || fp === "—" || fp === "N/A") continue
349
+ if (/[*?\[\{]/.test(fp)) {
350
+ if (globSync) {
351
+ try {
352
+ for (const match of globSync(fp, { cwd: ROOT })) {
353
+ allCodeRefs.add(String(match).replace(/\\/g, "/"))
354
+ }
355
+ } catch (err) {
356
+ warn(`${f}: code-refs glob "${fp}" 展开失败:${err.message}`)
357
+ allCodeRefs.add(fp)
358
+ }
359
+ } else {
360
+ warn(`${f}: 当前 Node 不支持 fs.globSync,跳过 code-refs glob "${fp}" 展开`)
361
+ allCodeRefs.add(fp)
362
+ }
363
+ } else {
364
+ allCodeRefs.add(fp)
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ const TARGET_PATTERNS = [/^plugins\/.+\.ts$/, /^workflows\/.+\.yaml$/, /^tools\/.+\.ts$/, /^agents\/.+\.md$/, /^commands\/.+\.md$/, /^lib\/.+\.ts$/]
371
+ const ADR_COMMENT_RE = /ADR:[a-z][a-z0-9-]+|ADR-\d{4}/
372
+ let trigChecked = 0
373
+ for (const file of diffFiles) {
374
+ const norm = file.replace(/\\/g, "/")
375
+ if (!TARGET_PATTERNS.some((p) => p.test(norm))) continue
376
+ trigChecked++
377
+ let hasComment = false
378
+ try {
379
+ const content = await fs.readFile(path.join(ROOT, file), "utf8")
380
+ if (ADR_COMMENT_RE.test(content)) hasComment = true
381
+ } catch { /* 文件可能被删 */ }
382
+ const inRefs = allCodeRefs.has(norm)
383
+ if (!hasComment && !inRefs) {
384
+ const msg = `${file}: 改动了但找不到 ADR 引用(既无注释 ADR:<slug>/ADR-NNNN 也无 ADR code-refs)`
385
+ grace ? warn(msg) : bad(msg)
386
+ }
387
+ }
388
+ ok(`触发式补全检查完成(扫描 ${trigChecked} 个改动文件)`)
389
+
390
+ // ─── D. PR 模板(下发版 warn-only) ─────────────────────
391
+
392
+ section("D. PR 模板")
393
+ const PR_TEMPLATE = path.join(ROOT, ".github", "pull_request_template.md")
394
+ if (!existsSync(PR_TEMPLATE)) {
395
+ warn(`.github/pull_request_template.md 不存在(下发版不强制;建议复制 assets/adr-init/.github/pull_request_template.md)`)
396
+ } else {
397
+ const content = await fs.readFile(PR_TEMPLATE, "utf8")
398
+ if (!/ADR/.test(content)) warn(`PR 模板未含 "ADR" 关键字(建议加 ADR checklist)`)
399
+ else ok(`PR 模板存在且含 ADR checklist`)
400
+ }
401
+
402
+ // ─── E. README 索引同步 ──────────────────────────────────
403
+
404
+ section("E. README 索引同步")
405
+ const indexSyncScript = path.join(ROOT, "scripts", "adr-index-sync.mjs")
406
+ if (!existsSync(indexSyncScript)) {
407
+ warn(`scripts/adr-index-sync.mjs 不存在,跳过索引同步检查`)
408
+ } else {
409
+ try {
410
+ execSync(`node "${indexSyncScript}" --check`, {
411
+ cwd: ROOT,
412
+ stdio: "pipe",
413
+ env: { ...process.env, ADR_ROOT: ROOT, ADR_DIR },
414
+ })
415
+ ok(`README 索引与 ADR 文件同步`)
416
+ } catch {
417
+ warn(`README 索引未同步 — 跑 \`node scripts/adr-index-sync.mjs\` 修复`)
418
+ }
419
+ }
420
+
421
+ // ─── 总结 ───────────────────────────────────────────────
422
+
423
+ console.log(`\n=== 总结 ===`)
424
+ console.log(` ✅ 通过:${passed}`)
425
+ console.log(` ⚠️ 警告:${warned}`)
426
+ console.log(` ❌ 失败:${failed}`)
427
+
428
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * adr-index-sync.mjs — 自动同步 docs/adr/README.md 索引表(泛化版,由 codeforge adr-init 下发)
4
+ *
5
+ * 与 CodeForge 自仓库 scripts/adr-index-sync.mjs 的区别:
6
+ * - LEGACY_ID_MAP 默认为空(不展示「曾用编号」列)
7
+ * - README marker 改为 HTML 注释锚点:`<!-- adr-index:start --> / <!-- adr-index:end -->`
8
+ * - ROOT 支持 ADR_ROOT 环境变量参数化
9
+ *
10
+ * 用法:
11
+ * node scripts/adr-index-sync.mjs # 默认:写回 README.md
12
+ * node scripts/adr-index-sync.mjs --check # 仅检查,不写;不同步退出码 1
13
+ *
14
+ * 环境变量:
15
+ * ADR_ROOT 项目根目录绝对/相对路径;默认 path.resolve(__dirname, "..")
16
+ * ADR_DIR ADR 目录;默认 <ROOT>/docs/adr
17
+ *
18
+ * 工作原理:
19
+ * 1. 扫 docs/adr/*.md(纯 slug 格式)取 frontmatter
20
+ * 2. 按 date 升序排序(同 date 按 id 二级排序)生成索引行
21
+ * 3. 在 README.md 找 marker `<!-- adr-index:start --> ... <!-- adr-index:end -->` 之间整段替换
22
+ * 4. --check 模式不写文件,diff 不为空则 exit 1
23
+ */
24
+
25
+ import { existsSync, promises as fs } from "node:fs"
26
+ import * as path from "node:path"
27
+ import { fileURLToPath } from "node:url"
28
+
29
+ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
30
+ const ROOT = process.env.ADR_ROOT ? path.resolve(process.env.ADR_ROOT) : DEFAULT_ROOT
31
+ const ADR_DIR = process.env.ADR_DIR ? path.resolve(process.env.ADR_DIR) : path.join(ROOT, "docs", "adr")
32
+ const README = path.join(ADR_DIR, "README.md")
33
+ const CHECK_ONLY = process.argv.includes("--check")
34
+
35
+ const SLUG_RE = /^[a-z][a-z0-9-]*\.md$/
36
+ const isTemplate = (f) => f === "template.md"
37
+ const isReadme = (f) => f === "README.md"
38
+ const isAdrFile = (f) => !isTemplate(f) && !isReadme(f) && SLUG_RE.test(f)
39
+
40
+ // 模板行:固定 prepend 在首行;template.md 单独列出避免被 isAdrFile 排除
41
+ const TEMPLATE_LINE = "| template | — | Template(模板) | — | — |"
42
+
43
+ // 下发版默认为空映射;如目标项目从旧 NNNN- 编号体系迁移,可在此显式补充
44
+ const LEGACY_ID_MAP = new Map()
45
+
46
+ async function parseADR(filepath) {
47
+ const raw = await fs.readFile(filepath, "utf8")
48
+ const m = raw.match(/^---\n([\s\S]*?)\n---/)
49
+ if (!m) return null
50
+ const fm = {}
51
+ let curArr = null
52
+ for (const line of m[1].split("\n")) {
53
+ const kv = line.match(/^([a-z][\w-]*):\s*(.*)$/)
54
+ if (kv) {
55
+ const v = kv[2].trim()
56
+ if (/^\[.*\]$/.test(v)) {
57
+ fm[kv[1]] = v.slice(1, -1).split(",")
58
+ .map(s => s.trim().replace(/^["']|["']$/g, ""))
59
+ .filter(Boolean)
60
+ curArr = null
61
+ } else if (v === "" || v === "[]") {
62
+ fm[kv[1]] = []
63
+ curArr = fm[kv[1]]
64
+ } else {
65
+ fm[kv[1]] = v.replace(/^["']|["']$/g, "")
66
+ curArr = null
67
+ }
68
+ } else if (curArr && line.startsWith(" - ")) {
69
+ curArr.push(line.slice(4).trim().replace(/^["']|["']$/g, ""))
70
+ }
71
+ }
72
+ return fm
73
+ }
74
+
75
+ async function buildIndex() {
76
+ const files = (await fs.readdir(ADR_DIR)).filter(isAdrFile)
77
+ const rows = []
78
+ for (const f of files) {
79
+ const fm = await parseADR(path.join(ADR_DIR, f))
80
+ if (!fm) continue
81
+ const id = f.replace(/\.md$/, "")
82
+ const date = String(fm.date || "").trim() || "—"
83
+ const title = String(fm.title || "").replace(/^["']|["']$/g, "")
84
+ const status = fm.status || "?"
85
+ const sb = Array.isArray(fm["superseded-by"]) ? fm["superseded-by"].filter(Boolean) : []
86
+ const sup = Array.isArray(fm.supersedes) ? fm.supersedes.filter(Boolean) : []
87
+ let rel = "—"
88
+ if (sb.length) rel = `by ${sb.map((x) => String(x).trim()).join(", ")}`
89
+ else if (sup.length) rel = `Supersedes ${sup.map((x) => String(x).trim()).join(", ")}`
90
+ const statusCell = status === "Superseded" ? `**${status}**` : status
91
+ const titleCell = `[${title}](./${f})`
92
+ // LEGACY_ID_MAP 留作扩展:如目标项目有旧编号要展示,可在源码 const 处补充并改表头
93
+ rows.push({
94
+ id,
95
+ date,
96
+ title,
97
+ status,
98
+ sb,
99
+ sup,
100
+ file: f,
101
+ line: `| ${id} | ${date} | ${titleCell} | ${statusCell} | ${rel} |`,
102
+ })
103
+ }
104
+ rows.sort((a, b) => a.date.localeCompare(b.date) || a.id.localeCompare(b.id))
105
+ return [TEMPLATE_LINE, ...rows.map((r) => r.line)]
106
+ }
107
+
108
+ async function main() {
109
+ if (!existsSync(README)) {
110
+ console.error(`✗ 未找到 ${README}`)
111
+ process.exit(1)
112
+ }
113
+ const old = await fs.readFile(README, "utf8")
114
+ const rows = await buildIndex()
115
+ const tableHeader = "| ID | Date | 标题 | 状态 | 替换关系 |\n|---|---|---|---|---|"
116
+ const tableBlock = `${tableHeader}\n${rows.join("\n")}`
117
+
118
+ // marker:HTML 注释锚点
119
+ const markerRe = /(<!--\s*adr-index:start\s*-->)[\s\S]*?(<!--\s*adr-index:end\s*-->)/
120
+ const m = old.match(markerRe)
121
+ if (!m) {
122
+ console.error(`✗ README 找不到 marker "<!-- adr-index:start --> ... <!-- adr-index:end -->"`)
123
+ console.error(` 请确认 ${path.relative(ROOT, README)} 含一对 HTML 注释锚点。`)
124
+ process.exit(1)
125
+ }
126
+
127
+ const newContent = old.replace(markerRe, `$1\n${tableBlock}\n$2`)
128
+
129
+ if (CHECK_ONLY) {
130
+ if (newContent === old) {
131
+ console.log(`✓ README 索引与 ADR 文件同步(${rows.length} 行)`)
132
+ process.exit(0)
133
+ } else {
134
+ console.error(`✗ README 索引与 ADR 文件不同步`)
135
+ console.error(` 跑 \`node scripts/adr-index-sync.mjs\` 自动修复`)
136
+ process.exit(1)
137
+ }
138
+ }
139
+
140
+ if (newContent === old) {
141
+ console.log(`✓ README 索引已是最新(${rows.length} 行),未改动`)
142
+ } else {
143
+ await fs.writeFile(README, newContent, "utf8")
144
+ console.log(`✓ README 索引已更新:${rows.length} 行`)
145
+ }
146
+ }
147
+
148
+ main().catch((err) => {
149
+ console.error(err)
150
+ process.exit(1)
151
+ })