@andyqiu/codeforge 0.3.10 → 0.3.12

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,105 @@
1
+ # Architecture Decision Records (ADR)
2
+
3
+ 本目录记录项目所有架构与设计决策。每个决策 = 一份独立 ADR 文件,**永不删除、永不编辑历史**。
4
+
5
+ ## 为什么需要 ADR
6
+
7
+ 代码告诉你 **What**(做了什么),ADR 告诉你 **Why**(当时为什么这么决定)。
8
+ 半年后看到一段奇怪的代码想重构?先翻 ADR——也许当年就是因为某个权衡才这么写的,重构会撞回老坑。
9
+
10
+ ## 编号规则
11
+
12
+ - **新 ADR**:文件名 `<kebab-slug>.md`(例:`add-redis-cache.md`、`split-auth-service.md`),无序号、无日期前缀;时间靠 frontmatter `date` 字段,索引按 date 排序
13
+ - **slug 唯一性**:文件名 stem(去 `.md`)在 `docs/adr/` 下不允许重名,`scripts/adr-check.mjs` 强校验
14
+ - **不允许新 ADR 用 `NNNN-` 前缀**(防回潮),`adr-check.mjs` 会报错
15
+ - `template.md` 是模板,不计入正式编号;frontmatter 不再有 `adr` 字段
16
+
17
+ ## 状态机
18
+
19
+ ```
20
+ Proposed ──accept──▶ Accepted ──supersede──▶ Superseded
21
+ │ │
22
+ └──reject──▶ Rejected └──deprecate──▶ Deprecated
23
+ ```
24
+
25
+ | 状态 | 含义 | 何时用 |
26
+ |---|---|---|
27
+ | `Proposed` | 草拟中,未实施 | 出方案时 |
28
+ | `Accepted` | 已实施,当前生效 | 代码落地后 |
29
+ | `Superseded` | 被新 ADR 替换 | 必须填 `superseded-by: <slug>` |
30
+ | `Deprecated` | 废弃但未替换(特性下线) | 单纯不再生效 |
31
+ | `Rejected` | 提案被否,未实施 | 保留作历史参考 |
32
+
33
+ **铁律**:`Status` 字段可改,其他内容**永不编辑**。要修正/补充 → 写新 ADR。
34
+
35
+ ## 三向引用约定
36
+
37
+ 每个 ADR 必须双向链接到 PRD(如有)和 Code:
38
+
39
+ | 方向 | 怎么写 |
40
+ |---|---|
41
+ | ADR → PRD | frontmatter `prd-refs: ["§6.2"]`(如项目无 PRD 可填 `["TBD"]`) |
42
+ | ADR → Code | frontmatter `code-refs: ["lib/foo.ts"]` |
43
+ | PRD → ADR | PRD 章节末尾加注释 `<!-- ADR: <slug> -->` |
44
+ | Code → ADR | 文件头注释 `// ADR:<slug>: 简短说明` |
45
+
46
+ ## 工作流(每次新决策走这 3 步)
47
+
48
+ ```
49
+ 1. 出方案时
50
+ → cp template.md docs/adr/<kebab-slug>.md (slug 全局唯一即可,无需查最大编号)
51
+ → 填 Context / Options / Decision
52
+ → status: Proposed
53
+
54
+ 2. 实现时
55
+ → 代码注释加 // ADR:<slug>
56
+ → 更新 ADR frontmatter 的 code-refs
57
+
58
+ 3. 落地完成时
59
+ → ADR 改 status: Accepted
60
+ → 如果替换旧 ADR:老 ADR 加 superseded-by: <slug>(填新 ADR 的 slug)
61
+ ```
62
+
63
+ ## 强制约束
64
+
65
+ - **scripts/adr-check.mjs**:校验三向引用 + status 闭环 + slug 唯一性 + 改动文件必须有对应 ADR
66
+ - **.githooks/pre-commit**:commit 前自动跑 `adr-check.mjs --diff-mode=staged`
67
+ - **.githooks/pre-push**:push 前自动跑 `adr-check.mjs --diff-mode=last-commit`(兜底)
68
+ - **PR 模板**(可选):`.github/pull_request_template.md` 必填 ADR checklist(新建 / 更新 / 无需,三选一)
69
+
70
+ ## 历史补全规则(触发式)
71
+
72
+ 不要求一次性补完历史,而是**触发式补全**:
73
+
74
+ - 改到哪块代码 → 必须先补该代码对应的 ADR 才能改
75
+ - `adr-check.mjs` 看 git diff,对未改文件只警告,对改动文件强校验
76
+ - 这样永远不会出现"想改 X 但 X 没决策记录"的窘境
77
+
78
+ ## 启用 git hooks(首次 clone 后跑一次)
79
+
80
+ ````
81
+ git config core.hooksPath .githooks
82
+ ````
83
+
84
+ npm 项目可在 `package.json` 加 `"prepare": "git config core.hooksPath .githooks"`,`npm install` 时自动生效。
85
+
86
+ 也可以重跑 `codeforge adr-init --write-prepare` 让 codeforge 帮你合并 prepare 链路(写前会自动 backup `package.json`)。
87
+
88
+ ## 索引
89
+
90
+ > 表格由 `scripts/adr-index-sync.mjs` 自动同步:按 `frontmatter.date` 升序排序(同 date 按 ID 二级排)。请勿手工编辑 marker 之间的内容。
91
+ >
92
+ > 重生成命令:`node scripts/adr-index-sync.mjs`
93
+
94
+ <!-- adr-index:start -->
95
+ | ID | Date | 标题 | 状态 | 替换关系 |
96
+ |---|---|---|---|---|
97
+ | template | — | Template(模板) | — | — |
98
+ <!-- adr-index:end -->
99
+
100
+ ## 参考
101
+
102
+ - [adr.github.io](https://adr.github.io/) — ADR 社区主站
103
+ - [MADR](https://adr.github.io/madr/) — Markdown ADR 模板(本目录采用)
104
+ - [Michael Nygard 原始论文](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
105
+ - AWS Prescriptive Guidance — [ADR process](https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html)
@@ -0,0 +1,83 @@
1
+ ---
2
+ # ── ADR Frontmatter(必填字段)──
3
+ # 注:新 ADR 不再用 `adr: NNNN` 数字编号字段;文件名本身就是唯一 slug 标识。
4
+ # 所有 ADR 均为纯 slug 命名(`kebab-case-slug.md`),无数字前缀。
5
+ title: "<决策的简短标题,10 个字以内>"
6
+ status: Proposed
7
+ date: 2026-01-01 # YYYY-MM-DD,确定决策的日期
8
+ deciders:
9
+ - <Your Name>
10
+ # 关系字段(可选,按需填写)
11
+ supersedes: [] # 例:[some-old-slug](用 slug,不能用数字)
12
+ superseded-by: [] # 由后续 ADR 回填,本人通常不填
13
+ # 三向引用(必填,至少各 1 条;新决策无对应实现时填 [TBD])
14
+ prd-refs:
15
+ - "TBD" # 例:"§6.2 review 闭环"
16
+ code-refs:
17
+ - "TBD" # 例:"lib/auto-feedback.ts"
18
+ tests:
19
+ - "TBD" # 例:"tests/auto-feedback.test.ts"
20
+ tags:
21
+ - architecture # 自由分类:architecture / workflow / convention / tooling 等
22
+ ---
23
+
24
+ # ADR: <决策标题>
25
+
26
+ ## Context(背景与问题)
27
+
28
+ <2-4 段说明:当时遇到什么问题?为什么需要决策?相关约束是什么?
29
+ 不要写"我们决定 X"——那是 Decision 段的事。这里只描述"问题空间"。>
30
+
31
+ ## Options Considered(对比的备选方案)
32
+
33
+ 至少列 2 个候选方案,每个要写优劣。**只有一个候选** = 没真正决策。
34
+
35
+ ### Option 1: <方案 A 名称>
36
+ - 优点:...
37
+ - 缺点:...
38
+
39
+ ### Option 2: <方案 B 名称>(✅ 已选)
40
+ - 优点:...
41
+ - 缺点:...
42
+
43
+ ### Option 3: <方案 C 名称>
44
+ - 优点:...
45
+ - 缺点:...
46
+ - 不选原因:...
47
+
48
+ ## Decision(最终决策)
49
+
50
+ **采用 Option N**,关键理由(≤ 3 条):
51
+
52
+ 1. ...
53
+ 2. ...
54
+ 3. ...
55
+
56
+ ## Consequences(代价与影响)
57
+
58
+ ### 正面
59
+ - ...
60
+
61
+ ### 负面 / 代价
62
+ - ...
63
+
64
+ ### 后续行动
65
+ - 需要更新:...
66
+ - 留待后续 ADR 处理:...(可写"无")
67
+
68
+ ## References
69
+
70
+ - 相关讨论 / Issue / PR:
71
+ - 业界资料:
72
+ - 灵感来源:
73
+
74
+ ---
75
+ <!-- 复制本模板时:
76
+ 1. 复制本文件 → `docs/adr/<kebab-slug>.md`
77
+ - slug = 决策内容的 kebab-case 描述(例:`add-redis-cache.md`、`split-auth-service.md`)
78
+ - **无需查最大编号**:新 ADR 不再用 NNNN- 前缀
79
+ - slug 需在整个 docs/adr/ 下唯一(adr-check.mjs 会校验)
80
+ 2. 把 title 改为正式标题
81
+ 3. 把 date 改为今天
82
+ 4. 删除本注释块
83
+ -->
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env sh
2
+ # Generated by codeforge adr-init — 提交前 ADR 体系合规校验
3
+ #
4
+ # 安装方式:
5
+ # git config core.hooksPath .githooks
6
+ # 或 npm 项目可在 package.json scripts.prepare 加 "git config core.hooksPath .githooks"
7
+ #
8
+ # 紧急绕过(不推荐):
9
+ # git commit --no-verify
10
+
11
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
12
+ cd "$ROOT" || exit 0
13
+
14
+ if ! command -v node >/dev/null 2>&1; then
15
+ echo "⚠️ 未找到 node,跳过 ADR 校验(请安装 Node.js >= 20)"
16
+ exit 0
17
+ fi
18
+
19
+ # 1. ADR 文件改动 → 同步 README 索引
20
+ STAGED_ADR=$(git diff --cached --name-only --diff-filter=ACMR \
21
+ | grep -E '^docs/adr/.*\.md$' \
22
+ | grep -v '^docs/adr/README\.md$' || true)
23
+
24
+ if [ -n "$STAGED_ADR" ] && [ -f "$ROOT/scripts/adr-index-sync.mjs" ]; then
25
+ echo "📚 检测到 ADR 改动,自动同步 README 索引..."
26
+ if node "$ROOT/scripts/adr-index-sync.mjs"; then
27
+ if ! git diff --quiet docs/adr/README.md; then
28
+ git add docs/adr/README.md
29
+ echo " ✓ docs/adr/README.md 已自动同步并加入 commit"
30
+ else
31
+ echo " ✓ README 索引已是最新(无需改动)"
32
+ fi
33
+ else
34
+ echo "❌ adr-index-sync 失败,请手动跑 node scripts/adr-index-sync.mjs 后重试"
35
+ exit 1
36
+ fi
37
+ fi
38
+
39
+ # 2. ADR 合规校验(staged 模式)
40
+ if [ -f "$ROOT/scripts/adr-check.mjs" ]; then
41
+ echo "🔍 ADR 合规校验中..."
42
+ ADR_STRICT=1 ADR_DIFF_MODE=staged node "$ROOT/scripts/adr-check.mjs" || {
43
+ echo "❌ ADR 校验失败 — commit 已阻断;紧急绕过 git commit --no-verify(不推荐)"
44
+ exit 1
45
+ }
46
+ fi
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env sh
2
+ # Generated by codeforge adr-init — push 前 ADR 体系合规校验(last-commit 模式)
3
+ #
4
+ # 兜底 pre-commit 漏网(如有人 --no-verify commit);扫最后一个 commit 的改动。
5
+ #
6
+ # 紧急绕过(不推荐):
7
+ # git push --no-verify
8
+
9
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
10
+ cd "$ROOT" || exit 0
11
+
12
+ if ! command -v node >/dev/null 2>&1; then
13
+ echo "⚠️ 未找到 node,跳过 ADR 校验(请安装 Node.js >= 20)"
14
+ exit 0
15
+ fi
16
+
17
+ if [ -f scripts/adr-check.mjs ]; then
18
+ echo "🔍 ADR 合规校验中(push 前 last-commit 模式)..."
19
+ ADR_STRICT=1 ADR_DIFF_MODE=last-commit node scripts/adr-check.mjs || {
20
+ echo "❌ ADR 校验失败 — push 已阻断;紧急绕过 git push --no-verify(不推荐)"
21
+ exit 1
22
+ }
23
+ fi
@@ -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)