@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/agents/teamkit-git.md +42 -0
- package/bin/.gitkeep +0 -0
- package/bin/teamkit-doctor.mjs +320 -0
- package/bin/teamkit-upgrade.mjs +93 -0
- package/bin/teamkit.mjs +115 -0
- package/context-templates/.gitkeep +0 -0
- package/context-templates/git-instructions.md +69 -0
- package/context-templates/kh-instructions.md +109 -0
- package/dist/index.cjs +1773 -0
- package/install.sh +302 -0
- package/package.json +45 -0
- package/scripts/adr-check.mjs +368 -0
- package/scripts/adr-index-sync.mjs +133 -0
- package/scripts/capability-detect.mjs +231 -0
- package/scripts/merge-agents-md.mjs +201 -0
- package/scripts/publish.sh +184 -0
|
@@ -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
|
+
})
|