sillyspec 3.26.7 → 3.26.8

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,333 @@
1
+ /**
2
+ * docs-check — 文档行号引用校验核心(change: 2026-08-15-docs-check-productize)
3
+ *
4
+ * 从 dogfood 私有测试 test/doc-ref-check.test.mjs 抽离产品化(design D-001~D-008):
5
+ * 层1 存在性(所有引用必查):文件存在 + 行号 ≥1 且 ≤ 总行数(范围引用查 end)
6
+ * 层2 关键词断言(keywordAssert 可配,缺省开):引用所在文档行内的反引号代码符号 token,
7
+ * 断言源文件 [start-2, end+5] 窗口(实现期口径,非对称——文档行号常指向块起始行
8
+ * 而 token 在体首几行)内含任一 token。多候选宽容:裸文件名多命中时任一候选全过即通过。
9
+ *
10
+ * 纯函数(collectDocRefs/looksLikeCodeSymbol/validateRefLines/extractExpectedTokensFromLine)
11
+ * 无 fs 依赖可单测;resolveCandidates/walkGlob/runDocsCheck 是 IO 面。
12
+ * 只读校验(不修改任何被校验文件);纯 Node 内置模块零依赖(D-008:glob 手写 walker,
13
+ * 仅「目录递归」「目录单层」「字面路径」三形态);Windows 路径归一化;兼容 CRLF/LF。
14
+ */
15
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import jsYaml from 'js-yaml'
18
+
19
+ /** 提取 file.js:line / file.js:start-end 引用(.js/.mjs,反引号包裹与裸文本均命中,全文扫描 D-006) */
20
+ export const REF_RE = /([A-Za-z0-9_.\-\/]+\.(?:js|mjs)):(\d+)(?:-(\d+))?/g
21
+
22
+ /**
23
+ * 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
24
+ * @param {string} projectRoot 源码仓根(local.yaml 在 <root>/.sillyspec/local.yaml)
25
+ * @returns {{ paths?: string[], skip: string[], keywordAssert: boolean }}
26
+ */
27
+ export function readDocsCheckConfig(projectRoot) {
28
+ const fallback = { paths: null, skip: [], keywordAssert: true }
29
+ try {
30
+ const p = join(projectRoot, '.sillyspec', 'local.yaml')
31
+ if (!existsSync(p)) return fallback
32
+ const doc = jsYaml.load(readFileSync(p, 'utf8'))
33
+ if (!doc || typeof doc !== 'object') return fallback
34
+ const dc = doc['docs-check']
35
+ if (!dc || typeof dc !== 'object') return fallback
36
+ return {
37
+ paths: Array.isArray(dc.paths) ? dc.paths.filter(s => typeof s === 'string') : null,
38
+ skip: Array.isArray(dc.skip) ? dc.skip.filter(s => typeof s === 'string') : [],
39
+ keywordAssert: typeof dc.keywordAssert === 'boolean' ? dc.keywordAssert : true,
40
+ }
41
+ } catch {
42
+ return fallback
43
+ }
44
+ }
45
+
46
+ /** 配置错误(glob 形态不支持等)→ CLI exit 2 */
47
+ export class DocsCheckConfigError extends Error {}
48
+
49
+ /**
50
+ * 纯函数:从 markdown 全文提取全部 file:line 引用。
51
+ * @param {string} md 文档全文
52
+ * @returns {Array<{ ref, file, start, end, docLine }>} docLine 为 1-based 文档行号
53
+ */
54
+ export function collectDocRefs(md) {
55
+ if (!md || typeof md !== 'string') return []
56
+ const refs = []
57
+ const re = new RegExp(REF_RE.source, 'g')
58
+ let m
59
+ while ((m = re.exec(md)) !== null) {
60
+ refs.push({
61
+ ref: m[0],
62
+ file: m[1],
63
+ start: parseInt(m[2], 10),
64
+ end: m[3] !== undefined ? parseInt(m[3], 10) : parseInt(m[2], 10),
65
+ docLine: md.slice(0, m.index).split(/\r?\n/).length,
66
+ })
67
+ }
68
+ return refs
69
+ }
70
+
71
+ /**
72
+ * 纯函数:判定 token 是否「像代码符号」:首字符字母/_/$,且含大写字母/下划线/点/$ 之一。
73
+ * 纯小写英文单词(local/platform/abort)→ false(自然语言,跳过断言防误报)。
74
+ */
75
+ export function looksLikeCodeSymbol(token) {
76
+ if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(token)) return false
77
+ return /[A-Z_$.]/.test(token)
78
+ }
79
+
80
+ /**
81
+ * 纯函数:层1 行号边界校验。
82
+ * @param {number} totalLines 源文件总行数
83
+ * @param {number} start 1-based 起始行
84
+ * @param {number} end 1-based 结束行(单行引用 = start)
85
+ * @returns {{ ok: boolean, reasons: string[] }}
86
+ */
87
+ export function validateRefLines(totalLines, start, end) {
88
+ const reasons = []
89
+ if (start < 1 || start > totalLines) reasons.push(`行号超界(start=${start} > 总行数 ${totalLines})`)
90
+ if (end < start || end > totalLines) reasons.push(`范围 end=${end} 超界(总行数 ${totalLines})`)
91
+ return { ok: reasons.length === 0, reasons }
92
+ }
93
+
94
+ /**
95
+ * 纯函数:层2 token 提取——引用所在的文档行内所有反引号 token 中的代码符号。
96
+ * token 归一:①剥函数括号(getDispatchMode());②点分名拆段(syncMod.checkApproval → 两段)。
97
+ * 空数组 = 纯位置引用,跳过层2。
98
+ * @param {string} line 引用所在的文档行全文
99
+ * @returns {string[]} token 列表
100
+ */
101
+ export function extractExpectedTokensFromLine(line) {
102
+ if (!line || typeof line !== 'string') return []
103
+ const tokens = new Set()
104
+ for (const mm of line.matchAll(/`([^`\n]{1,60})`/g)) {
105
+ const t = mm[1].split('(')[0].trim()
106
+ if (!looksLikeCodeSymbol(t)) continue
107
+ tokens.add(t)
108
+ if (t.includes('.')) {
109
+ for (const seg of t.split('.')) {
110
+ if (looksLikeCodeSymbol(seg)) tokens.add(seg)
111
+ }
112
+ }
113
+ }
114
+ return [...tokens]
115
+ }
116
+
117
+ /** 递归收集 dir 下与 baseName 同名的文件(相对 dir 的 POSIX 路径数组;排除 node_modules/.git) */
118
+ function findInTree(dir, baseName, rel = '') {
119
+ const out = []
120
+ let entries
121
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
122
+ for (const e of entries) {
123
+ if (e.name === 'node_modules' || e.name === '.git') continue
124
+ const relPath = rel ? `${rel}/${e.name}` : e.name
125
+ if (e.isDirectory()) out.push(...findInTree(join(dir, e.name), baseName, relPath))
126
+ else if (e.name === baseName) out.push(relPath)
127
+ }
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * 解析引用文件 → 候选绝对路径数组。文档写法三种混用(首跑实证):
133
+ * ①仓库根相对(src/sync.js / docs/...)→ 直拼;②src/ 内部相对(dispatch/probe.js)→
134
+ * src/ 前缀重试;③裸中缀(backends/sillyhub-mcp.js)→ src/ 树内按路径后缀匹配。
135
+ * 空数组 = 不存在。
136
+ */
137
+ export function resolveCandidates(projectRoot, refFile) {
138
+ if (refFile.includes('/')) {
139
+ const direct = join(projectRoot, refFile)
140
+ if (existsSync(direct)) return [direct]
141
+ const inSrc = join(projectRoot, 'src', refFile)
142
+ if (existsSync(inSrc)) return [inSrc]
143
+ const slash = refFile.lastIndexOf('/')
144
+ const baseName = slash === -1 ? refFile : refFile.slice(slash + 1)
145
+ return findInTree(join(projectRoot, 'src'), baseName)
146
+ .filter((rel) => ('src/' + rel).endsWith('/' + refFile) || 'src/' + rel === 'src/' + refFile)
147
+ .map((rel) => join(projectRoot, 'src', rel))
148
+ }
149
+ return findInTree(join(projectRoot, 'src'), refFile).map((rel) => join(projectRoot, 'src', rel))
150
+ }
151
+
152
+ /** 读文件行数组(CRLF/LF 归一:split(/\r?\n/),层2 只做子串查找不污染原文) */
153
+ function readLines(absPath) {
154
+ try { return readFileSync(absPath, 'utf8').split(/\r?\n/) } catch { return null }
155
+ }
156
+
157
+ /** 简单通配匹配(skip 排除用,仅 * 单段) */
158
+ function matchSimple(text, pattern) {
159
+ const parts = pattern.split('*')
160
+ let idx = 0
161
+ for (let i = 0; i < parts.length; i++) {
162
+ if (parts[i] === '') continue
163
+ const found = text.indexOf(parts[i], idx)
164
+ if (found === -1) return false
165
+ if (i === 0 && found !== 0) return false
166
+ idx = found + parts[i].length
167
+ }
168
+ return true
169
+ }
170
+
171
+ /**
172
+ * glob walker(D-008:手写不引依赖)。仅支持三形态:
173
+ * - `dir/「递归」*.ext` 递归收集(maxDepth 12 兜底防符号链接环)
174
+ * - `dir/*.ext` 单层
175
+ * - 字面路径直传(存在才返回)
176
+ * 其他形态(?、{a,b}、多层 * 混合等)抛 DocsCheckConfigError → CLI exit 2。
177
+ * @param {string} projectRoot 锚点(平台模式也锚源码仓根,design §3.3)
178
+ * @param {string} pattern glob 模式
179
+ * @param {string[]} skip 排除 glob(前缀或单 * 简单匹配)
180
+ * @returns {string[]} 命中文件的 POSIX 相对路径
181
+ */
182
+ export function walkGlob(projectRoot, pattern, skip = []) {
183
+ const norm = (p) => p.replace(/\\/g, '/')
184
+ const isSkipped = (rel) => skip.some((s) => {
185
+ const sn = norm(s).replace(/\/$/, '')
186
+ return rel === sn || rel.startsWith(sn + '/') ||
187
+ (sn.includes('*') && matchSimple(rel, sn))
188
+ })
189
+ const matchExt = (name, ext) => name.endsWith(ext)
190
+ // 形态 1:**/*.ext
191
+ let m = norm(pattern).match(/^(.+?)\/\*\*\/\*(\.[A-Za-z0-9]+)$/)
192
+ if (m) {
193
+ const [, baseDir, ext] = m
194
+ const out = []
195
+ const rec = (dir, rel, depth) => {
196
+ if (depth > 12) return
197
+ let entries
198
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
199
+ for (const e of entries) {
200
+ if (e.name === 'node_modules' || e.name === '.git') continue
201
+ const relPath = rel ? `${rel}/${e.name}` : e.name
202
+ if (isSkipped(relPath)) continue
203
+ if (e.isDirectory()) rec(join(dir, e.name), relPath, depth + 1)
204
+ else if (matchExt(e.name, ext)) out.push(relPath)
205
+ }
206
+ }
207
+ rec(join(projectRoot, baseDir), baseDir, 0)
208
+ return out
209
+ }
210
+ // 形态 2:dir/*.ext(单层)
211
+ m = norm(pattern).match(/^(.+?)\/\*(\.[A-Za-z0-9]+)$/)
212
+ if (m) {
213
+ const [, baseDir, ext] = m
214
+ const out = []
215
+ let entries
216
+ try { entries = readdirSync(join(projectRoot, baseDir), { withFileTypes: true }) } catch { return out }
217
+ for (const e of entries) {
218
+ if (!e.isFile()) continue
219
+ const relPath = `${baseDir}/${e.name}`
220
+ if (isSkipped(relPath)) continue
221
+ if (matchExt(e.name, ext)) out.push(relPath)
222
+ }
223
+ return out
224
+ }
225
+ // 形态 3:字面路径(不含任何通配元字符才直传;含 ?、{ }、[ ] 等不支持形态落 error)
226
+ if (!/[*?{}\[\]]/.test(pattern)) {
227
+ return existsSync(join(projectRoot, pattern)) ? [norm(pattern)] : []
228
+ }
229
+ throw new DocsCheckConfigError(`不支持的 glob 形态:${pattern}(当前仅支持 dir/**/*.ext、dir/*.ext、字面路径)`)
230
+ }
231
+
232
+ /** 候选路径 → 相对 projectRoot 显示(失败信息用;Windows 反斜杠归一) */
233
+ function relDisplay(absPath, projectRoot) {
234
+ return absPath
235
+ .replace(/\\/g, '/')
236
+ .replace(projectRoot.replace(/\\/g, '/') + '/', '')
237
+ }
238
+
239
+ /**
240
+ * IO 入口:跑一次完整校验。
241
+ * @param {{ projectRoot: string, docs?: string[], paths?: string[], skip?: string[], keywordAssert?: boolean }} opts
242
+ * projectRoot 源码仓根(候选解析与 glob 的锚点,平台模式也传源码仓根)
243
+ * docs 显式文档相对路径列表(优先于 paths glob 展开结果)
244
+ * @returns {{ ok: boolean, total: number, invalid: Array<{doc, docLine, ref, reason}>, warnings: string[], kwChecked: number }}
245
+ * @throws {DocsCheckConfigError} glob 形态不支持
246
+ */
247
+ export function runDocsCheck(opts) {
248
+ // paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
249
+ // 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
250
+ const { projectRoot, docs = null, paths: rawPaths = ['docs/**/*.md'], skip = [], keywordAssert = true } = opts || {}
251
+ const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : ['docs/**/*.md']
252
+ const warnings = []
253
+ if (keywordAssert === false) warnings.push('关键词断言已关闭(keywordAssert=false),仅做存在性校验')
254
+ const docFiles = docs && docs.length > 0
255
+ ? docs
256
+ : [...new Set(paths.flatMap((p) => walkGlob(projectRoot, p, skip)))].sort()
257
+
258
+ const invalid = []
259
+ let total = 0
260
+ let kwChecked = 0
261
+
262
+ for (const docRel of docFiles) {
263
+ const docAbs = join(projectRoot, docRel)
264
+ if (!existsSync(docAbs)) {
265
+ invalid.push({ doc: docRel, docLine: 0, ref: '', reason: '文档不存在' })
266
+ continue
267
+ }
268
+ const md = readFileSync(docAbs, 'utf8')
269
+ const refs = collectDocRefs(md)
270
+ const mdLines = md.split(/\r?\n/)
271
+
272
+ for (const r of refs) {
273
+ total++
274
+ const candidates = resolveCandidates(projectRoot, r.file)
275
+ if (candidates.length === 0) {
276
+ invalid.push({
277
+ doc: docRel, docLine: r.docLine, ref: r.ref,
278
+ reason: '文件不存在(含 / 按仓库根解析;裸文件名在 src/ 递归)',
279
+ })
280
+ continue
281
+ }
282
+ // 多候选宽容:逐候选跑层1+层2,任一全过即通过
283
+ const tokens = keywordAssert
284
+ ? extractExpectedTokensFromLine(mdLines[r.docLine - 1] || '')
285
+ : []
286
+ const candidateFails = []
287
+ let passedAny = false
288
+ for (const candAbs of candidates) {
289
+ const lines = readLines(candAbs)
290
+ if (lines === null) { candidateFails.push(`${relDisplay(candAbs, projectRoot)}: 读取失败`); continue }
291
+ const v = validateRefLines(lines.length, r.start, r.end)
292
+ // 层2:token 任一在 [start-2, end+5] 窗口命中即过
293
+ let kwOk = true
294
+ if (tokens.length > 0) {
295
+ const from = Math.max(0, r.start - 2)
296
+ const to = Math.min(lines.length, r.end + 5)
297
+ const window = lines.slice(from, to).join('\n')
298
+ kwOk = tokens.some((t) => window.includes(t))
299
+ }
300
+ if (v.ok && kwOk) { passedAny = true; break }
301
+ const reasons = [...v.reasons]
302
+ if (!kwOk) reasons.push(`关键词缺失:期望任一「${tokens.join(' / ')}」在 [start-2, end+5] 窗口内`)
303
+ candidateFails.push(`${relDisplay(candAbs, projectRoot)}: ${reasons.join(';')}`)
304
+ }
305
+ if (tokens.length > 0) kwChecked++
306
+ if (!passedAny) {
307
+ invalid.push({
308
+ doc: docRel, docLine: r.docLine, ref: r.ref,
309
+ reason: candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0],
310
+ // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
311
+ suggest: suggestLines(candidates, tokens),
312
+ })
313
+ }
314
+ }
315
+ }
316
+
317
+ return { ok: invalid.length === 0, total, invalid, warnings, kwChecked }
318
+ }
319
+
320
+ /**
321
+ * 建议行号计算(--suggest):对失效引用,在候选文件里找 token 全量命中行。
322
+ * 无 token(纯行号断言)时返回空数组——没有符号线索无法定位,只能人工看。
323
+ */
324
+ function suggestLines(candidates, tokens) {
325
+ if (tokens.length === 0) return []
326
+ try {
327
+ const lines = readLines(candidates[0])
328
+ if (lines === null) return []
329
+ const out = []
330
+ lines.forEach((l, i) => { if (tokens.some((t) => l.includes(t))) out.push(i + 1) })
331
+ return out.slice(0, 8)
332
+ } catch { return [] }
333
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * docs-debt — 模块文档欠账事实计算(change: 2026-08-15-docs-debt-inject,债单第六节)
3
+ *
4
+ * 原则:"CLI 算事实注入"——git 事实算出本变更触及模块的文档欠账,注入 Wave prompt 的是
5
+ * 结论不是要求(advisory 不阻断,无债零输出,D-005)。
6
+ *
7
+ * 归属三级(D-003):module.paths || module.core_files(读端口径同 prompt.js loadModuleContextIndex)
8
+ * → 模块卡 doc 内容中的文件路径字面量(v1 map 兼容)→ 未匹配归 unmapped。
9
+ * behind 口径(D-004):每模块两次 git log -1 --format="%h %ct" 取时间戳判方向,
10
+ * behind = rev-list --count docCommit..srcCommit;untracked 卡 behind=null 显式"卡片从未提交";
11
+ * git 失败/超时 5s → 该模块 behind=null + 降级注记,不抛。
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
14
+ import { join, relative } from 'node:path'
15
+ import { safeGit } from './git-helper.js'
16
+ import { parseModuleMapSimple } from './modules.js'
17
+ import { runDocsCheck } from './docs-check.js'
18
+
19
+ /** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
20
+ const GIT_TIMEOUT_MS = 5000
21
+
22
+ /**
23
+ * 纯函数:把 changedFiles 归属到模块。
24
+ * @param {string[]} changedFiles 变更文件(POSIX 相对路径)
25
+ * @param {object} moduleIndex parseModuleMapSimple 返回值({ modules: { id: { paths?, core_files?, doc? } } })
26
+ * @param {{ cardsDir: string }} opts cardsDir = <specBase>/docs/<project>/modules
27
+ * @returns {{ byModule: Map<string, { doc: string, files: string[] }>, unmapped: string[] }}
28
+ */
29
+ export function matchFilesToModules(changedFiles, moduleIndex, opts = {}) {
30
+ const byModule = new Map()
31
+ const unmapped = []
32
+ // parseModuleMapSimple 返回扁平模块对象({ id: {paths?, core_files?, doc?} },无 .modules 包层)
33
+ const modules = moduleIndex && !moduleIndex.modules ? moduleIndex : (moduleIndex?.modules || {})
34
+ const cardsDir = opts.cardsDir
35
+
36
+ // 模块 → 候选路径集合(一级:paths || core_files;同时预载卡片内容供二级)
37
+ const modulePaths = new Map() // moduleId → string[]
38
+ const cardContents = new Map() // moduleId → string
39
+ for (const [id, m] of Object.entries(modules)) {
40
+ const ps = [...(Array.isArray(m.paths) ? m.paths : []), ...(Array.isArray(m.core_files) ? m.core_files : [])]
41
+ modulePaths.set(id, ps)
42
+ if (cardsDir && m.doc) {
43
+ const cardPath = join(cardsDir, m.doc.replace(/^modules\//, ''))
44
+ if (existsSync(cardPath)) {
45
+ try { cardContents.set(id, readFileSync(cardPath, 'utf8')) } catch { /* 降级二级 */ }
46
+ }
47
+ }
48
+ }
49
+
50
+ for (const f of changedFiles) {
51
+ let matched = null
52
+ // 一级:paths/core_files 前缀或字面量命中
53
+ for (const [id, ps] of modulePaths) {
54
+ if (ps.some(p => p === f || f.startsWith(p.replace(/\/$/, '') + '/') || p.startsWith(f.split('/')[0] + '/') && p === f)) {
55
+ matched = id; break
56
+ }
57
+ }
58
+ // 二级:卡片 doc 内容中的文件路径字面量(精确子串)
59
+ if (!matched) {
60
+ for (const [id, content] of cardContents) {
61
+ if (content && content.includes(f)) { matched = id; break }
62
+ }
63
+ }
64
+ // 二级b:裸文件名匹配——卡片引用习惯是裸名(`execute.js` 而非 `src/stages/execute.js`,
65
+ // 本仓 stages.md 实证),全路径 includes 对这类卡永远落空 → 归属 unmapped(假"无归属")。
66
+ // 规则:基名在卡内存在"独立出现"(两侧均非路径/标识符字符),防 a.js 误配 xa.js.txt 等。
67
+ // 全路径命中(上面循环)优先,裸名仅在未命中时兜底。
68
+ if (!matched) {
69
+ const base = f.slice(f.lastIndexOf('/') + 1)
70
+ if (base && !base.startsWith('.')) {
71
+ outer: for (const [id, content] of cardContents) {
72
+ if (!content) continue
73
+ let pos = content.indexOf(base)
74
+ while (pos !== -1) {
75
+ const before = pos > 0 ? content[pos - 1] : ''
76
+ const after = content[pos + base.length] || ''
77
+ const beforeOk = before === '' || !/[A-Za-z0-9_\/.\-]/.test(before)
78
+ const afterOk = after === '' || !/[A-Za-z0-9_\-./]/.test(after)
79
+ if (beforeOk && afterOk) { matched = id; break outer }
80
+ pos = content.indexOf(base, pos + 1)
81
+ }
82
+ }
83
+ }
84
+ }
85
+ if (!matched) { unmapped.push(f); continue }
86
+ const entry = byModule.get(matched) || { doc: modules[matched].doc || '', files: [] }
87
+ entry.files.push(f)
88
+ byModule.set(matched, entry)
89
+ }
90
+ return { byModule, unmapped }
91
+ }
92
+
93
+ /** 单模块欠账:双 commit 时间戳判方向 + rev-list 计数。全失败 → behind null + 注记。
94
+ * docGitPath:模块卡的 git 相对路径(specBase 相对 projectRoot 换算——specBase 在项目内时
95
+ * 是 .sillyspec/docs/<p>/modules/<id>.md;平台模式 specBase 在仓外时卡片不在 git 内 →
96
+ * docLog 恒失败 → 走"从未提交"形态,如实呈现)。 */
97
+ function moduleDebt(projectRoot, moduleId, entry, docGitPath, docGitRoot) {
98
+ const notes = []
99
+ const srcPaths = entry.files
100
+ const srcLog = safeGit(projectRoot, ['log', '-1', '--format=%h %ct', '--', ...srcPaths], { timeout: GIT_TIMEOUT_MS })
101
+ let behind = null
102
+ let srcCommit = null
103
+ let docCommit = null
104
+ let neverCommitted = false
105
+ if (!srcLog.error && srcLog.value && srcLog.value.trim()) {
106
+ const [h, ct] = srcLog.value.trim().split(' ')
107
+ srcCommit = { h, ct: parseInt(ct, 10) }
108
+ } else {
109
+ notes.push('源码 commit 历史不可得(新文件或 git 失败)')
110
+ }
111
+ if (docGitPath) {
112
+ // 卡片历史在卡片所在 git 仓查(docGitRoot;linked worktree 场景 = 主仓根),
113
+ // 源码历史在 projectRoot 查——两侧 commit 同属一个仓库历史可 rev-list 对账
114
+ const docGitCwd = docGitRoot || projectRoot
115
+ const docLog = safeGit(docGitCwd, ['log', '-1', '--format=%h %ct', '--', docGitPath], { timeout: GIT_TIMEOUT_MS })
116
+ if (!docLog.error && docLog.value && docLog.value.trim()) {
117
+ const [h, ct] = docLog.value.trim().split(' ')
118
+ docCommit = { h, ct: parseInt(ct, 10) }
119
+ } else {
120
+ neverCommitted = true
121
+ }
122
+ }
123
+ if (srcCommit && !docCommit) {
124
+ // 有源码历史但卡片从未提交 → 欠账最重形态
125
+ return { module: moduleId, ...entry, srcCommit: srcCommit.h, docCommit: null, behind: null, neverCommitted: true, notes }
126
+ }
127
+ if (srcCommit && docCommit) {
128
+ // ct 相等也进对账(同一秒内的提交——测试/快速序列场景),由 rev-list count 分晓
129
+ if (srcCommit.ct >= docCommit.ct) {
130
+ const cnt = safeGit(docGitRoot || projectRoot, ['rev-list', '--count', `${docCommit.h}..${srcCommit.h}`], { timeout: GIT_TIMEOUT_MS })
131
+ behind = cnt.error ? null : parseInt(cnt.value, 10)
132
+ if (behind === null) notes.push('rev-list 失败降级')
133
+ } else {
134
+ return { module: moduleId, ...entry, srcCommit: srcCommit.h, docCommit: docCommit.h, behind: 0, neverCommitted: false, notes }
135
+ }
136
+ }
137
+ return { module: moduleId, ...entry, srcCommit: srcCommit?.h || null, docCommit: docCommit?.h || null, behind, neverCommitted, notes }
138
+ }
139
+
140
+ /**
141
+ * IO 入口:算模块文档欠账事实。
142
+ * @param {{ projectRoot: string, specBase: string, projectName: string, changedFiles: string[] }} opts
143
+ * projectRoot:git 锚(worktree 根或 cwd);specBase/projectName:module-map 与卡片定位
144
+ * @returns {{ ok: boolean, facts: string, entries: object[], unmapped: string[] }}
145
+ * ok=false = 归属数据缺失(map 不存在/解析空)→ facts 为单行说明
146
+ * facts 空 = 无债(调用方零输出)
147
+ */
148
+ export function computeDocsDebt(opts) {
149
+ const { projectRoot, specBase, projectName, changedFiles = [] } = opts || {}
150
+ const cardsDir = specBase && projectName ? join(specBase, 'docs', projectName, 'modules') : null
151
+ const mapPath = cardsDir ? join(cardsDir, '_module-map.yaml') : null
152
+ if (!mapPath || !existsSync(mapPath)) {
153
+ return { ok: false, facts: '[docs-debt] 无 _module-map.yaml,模块归属数据缺失(跑 sillyspec modules rebuild 生成)', entries: [], unmapped: changedFiles }
154
+ }
155
+ let moduleIndex
156
+ try {
157
+ moduleIndex = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
158
+ } catch (e) {
159
+ return { ok: false, facts: `[docs-debt] module-map 解析失败(${e.message}),跳过欠账计算`, entries: [], unmapped: changedFiles }
160
+ }
161
+ if (!moduleIndex || Object.keys(moduleIndex.modules ? moduleIndex.modules : moduleIndex).length === 0) {
162
+ return { ok: false, facts: '[docs-debt] module-map 解析为空(0 模块),归属数据缺失', entries: [], unmapped: changedFiles }
163
+ }
164
+ const { byModule, unmapped } = matchFilesToModules(changedFiles, moduleIndex, { cardsDir })
165
+ if (byModule.size === 0) {
166
+ return { ok: true, facts: '', entries: [], unmapped }
167
+ }
168
+ const entries = []
169
+ // doc 的 git 锚定(execute 审查 FAIL-1 修复):卡片查询按 specBase 所在 git 仓根算相对路径,
170
+ // 不按 projectRoot——dogfood execute 场景 projectRoot=worktree 而 specBase=主仓/.sillyspec,
171
+ // linked worktree 共享 git 历史,git -C <worktree> log -- <主仓相对路径> 查得到卡片真实历史;
172
+ // 若按 projectRoot relative 判 null 会把"卡片在主仓 git 内"误报为"从未提交"(假事实)。
173
+ // 平台模式 specRoot 在仓外(git -C specBase 失败/无 git)→ docGitPath=null 走"从未提交"如实呈现。
174
+ let docGitRoot = null
175
+ let docPathPrefix = null
176
+ try {
177
+ const specTop = safeGit(specBase, ['rev-parse', '--show-toplevel'], { timeout: GIT_TIMEOUT_MS })
178
+ if (!specTop.error && specTop.value) {
179
+ docGitRoot = specTop.value.trim()
180
+ const rel = relative(docGitRoot, cardsDir)
181
+ if (rel && !rel.startsWith('..')) docPathPrefix = rel.replace(/\\/g, '/')
182
+ }
183
+ } catch { /* 保持 null */ }
184
+ for (const [id, entry] of byModule) {
185
+ const docGitPath = docPathPrefix && entry.doc ? `${docPathPrefix}/${entry.doc.replace(/^modules\//, '')}` : null
186
+ entries.push(moduleDebt(projectRoot, id, entry, docGitPath, docGitPath ? docGitRoot : null))
187
+ }
188
+ // 只报欠账(behind>0 / neverCommitted / behind=null 降级注记);behind=0 不出(无债零输出)
189
+ const debtEntries = entries.filter(e => e.neverCommitted || (e.behind !== null && e.behind > 0) || (e.behind === null && !e.neverCommitted))
190
+ if (debtEntries.length === 0) return { ok: true, facts: '', entries, unmapped }
191
+ const lines = ['[docs-debt] 本变更已触及以下模块(CLI 算,diff × _module-map.yaml,累计):']
192
+ for (const e of debtEntries) {
193
+ if (e.neverCommitted) {
194
+ lines.push(` - ${e.module}:模块卡 ${e.doc} 从未提交(欠账最重)`)
195
+ } else if (e.behind === null) {
196
+ lines.push(` - ${e.module}:欠账数不可得(${(e.notes || []).join(';') || 'git 降级'})`)
197
+ } else {
198
+ lines.push(` - ${e.module}:源码 ${e.behind} commit 未同步卡(卡停 ${e.docCommit},源码到 ${e.srcCommit})`)
199
+ }
200
+ // O-2(docs-signals-o12):内联卡片失效引用 + 建议行号——agent 从"知道欠账"到"知道改哪行"。
201
+ // 守卫 docGitPath && docGitRoot(D-004 同锚;平台模式仓外跳过);每模块上限 3 条(D-002);
202
+ // 异常整块降级跳过(advisory 链不因增强而脆)。
203
+ const inline = inlineCardInvalidRefs(e, docGitRoot, docPathPrefix)
204
+ if (inline) lines.push(inline)
205
+ lines.push(` 涉及文件:${e.files.join(', ')}`)
206
+ }
207
+ return { ok: true, facts: lines.join('\n'), entries, unmapped }
208
+ }
209
+
210
+ /** O-2:欠账模块卡片的失效引用内联行(docs check 层1 单文档)。无失效/不可跑 → null。 */
211
+ function inlineCardInvalidRefs(entry, docGitRoot, docPathPrefix) {
212
+ if (!docGitRoot || !docPathPrefix || !entry.doc) return null
213
+ try {
214
+ const docGitPath = `${docPathPrefix}/${entry.doc.replace(/^modules\//, '')}`
215
+ const check = runDocsCheck({ projectRoot: docGitRoot, docs: [docGitPath] })
216
+ if (check.ok || !Array.isArray(check.invalid) || check.invalid.length === 0) return null
217
+ const parts = check.invalid.slice(0, 3).map((inv) => {
218
+ const sug = Array.isArray(inv.suggest) && inv.suggest.length > 0 ? `→建议 L${inv.suggest.join('/L')}` : ''
219
+ return `${inv.ref || inv.doc}${sug}`
220
+ })
221
+ const more = check.invalid.length > 3 ? ` · 等 ${check.invalid.length} 处` : ''
222
+ return ` 卡内失效引用 ${check.invalid.length} 处:${parts.join(' · ')}${more}`
223
+ } catch {
224
+ return null
225
+ }
226
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * docs gate:docs check 的 ratchet 门(欠账只许减少不许增加)。
3
+ *
4
+ * 动机(doc-consistency-debt 第七节裁决):behind 计数是代理信号不能当阈值(源码活跃
5
+ * 不代表卡错,误报会让所有人学会忽略报警);docs check 失效数是直接信号(每条都是
6
+ * 具体的错),ratchet 语义 = 失效数 ≤ 基线即过、超基线拦——不管历史存量,只拦增量。
7
+ *
8
+ * 基线文件:.sillyspec/docs-check-baseline(纯数字一行,可手工改)。首次无基线时
9
+ * 必须显式 --init-baseline 生成(不悄悄合法化存量欠账——与 quick fail-closed 调性
10
+ * 一致);--init-baseline 幂等,重跑以当前实测数覆盖。
11
+ *
12
+ * exit code:0 过(≤基线)/ 1 拦(>基线)/ 2 配置或 IO 错误(含无基线)。
13
+ * 纯判定逻辑(evaluateRatchet)与 IO 面(runDocsGate)分离,前者可单测。
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError } from './docs-check.js';
18
+
19
+ /** 基线文件名(相对 specBase/.sillyspec 根;specBase 由调用方传入完整路径锚) */
20
+ export const BASELINE_FILENAME = 'docs-check-baseline';
21
+
22
+ /** 展示用基线路径(文案用,POSIX 风格;join 防 specBase 带不带尾分隔符两种形态) */
23
+ function baselineDisplay(specBase) {
24
+ return [specBase.replace(/[\\/]+$/, ''), BASELINE_FILENAME].join('/').replace(/\\/g, '/');
25
+ }
26
+
27
+ /**
28
+ * 纯判定:ratchet 语义。
29
+ * @param {{ current: number, baseline: number }} r
30
+ * @returns {{ ok: boolean, delta: number, message: string }}
31
+ * delta = current - baseline;负值(清偿了)提示可下调基线但不强制。
32
+ */
33
+ export function evaluateRatchet({ current, baseline }) {
34
+ const delta = current - baseline;
35
+ if (delta <= 0) {
36
+ const hint = delta < 0
37
+ ? `✅ docs gate: ${current} 处失效 ≤ 基线 ${baseline}(清偿了 ${-delta} 处,可跑 --init-baseline 下调基线锁住成果)`
38
+ : `✅ docs gate: ${current} 处失效 = 基线 ${baseline},放行`;
39
+ return { ok: true, delta, message: hint };
40
+ }
41
+ return {
42
+ ok: false, delta,
43
+ message: `❌ docs gate: ${current} 处失效 > 基线 ${baseline}(新增 ${delta} 处),拦截。修掉新增引用或显式 --init-baseline 重置基线(需你确认存量合法)`,
44
+ };
45
+ }
46
+
47
+ /** 读基线:不存在返回 null(调用方决定 init 或报错);内容非数字行返回 NaN(按损坏报错)。 */
48
+ export function readBaseline(specBase) {
49
+ const p = join(specBase, BASELINE_FILENAME);
50
+ if (!existsSync(p)) return null;
51
+ const raw = readFileSync(p, 'utf8').trim();
52
+ const n = parseInt(raw, 10);
53
+ return Number.isInteger(n) && n >= 0 ? n : NaN;
54
+ }
55
+
56
+ /** 写基线(--init-baseline):以实测当前失效数覆盖。 */
57
+ export function writeBaseline(specBase, value) {
58
+ writeFileSync(join(specBase, BASELINE_FILENAME), `${value}\n`, 'utf8');
59
+ }
60
+
61
+ /**
62
+ * IO 入口:跑一次 gate。
63
+ * @param {{ projectRoot: string, specBase: string, initBaseline?: boolean }} opts
64
+ * projectRoot 源码仓根(docs check 锚);specBase .sillyspec 根(基线文件所在)
65
+ * @param {object} checkOpts 透传 runDocsCheck(paths/skip/keywordAssert 覆盖;缺省读 local.yaml)
66
+ * @returns {Promise<{ exitCode: 0|1|2, ok: boolean, current: number, baseline: number|null,
67
+ * delta: number|null, message: string, inited: boolean }>}
68
+ */
69
+ export async function runDocsGate(opts = {}, checkOpts = {}) {
70
+ const { projectRoot, specBase, initBaseline = false } = opts;
71
+ let result;
72
+ try {
73
+ const cfg = readDocsCheckConfig(projectRoot);
74
+ result = runDocsCheck({
75
+ projectRoot,
76
+ paths: checkOpts.paths || cfg.paths,
77
+ skip: checkOpts.skip || cfg.skip,
78
+ keywordAssert: checkOpts.keywordAssert ?? cfg.keywordAssert,
79
+ });
80
+ } catch (e) {
81
+ if (e instanceof DocsCheckConfigError) {
82
+ return { exitCode: 2, ok: false, current: null, baseline: null, delta: null, message: `docs gate 配置错误:${e.message}`, inited: false };
83
+ }
84
+ throw e;
85
+ }
86
+ const current = result.invalid.length;
87
+
88
+ if (initBaseline) {
89
+ writeBaseline(specBase, current);
90
+ return { exitCode: 0, ok: true, current, baseline: current, delta: 0, message: `📌 docs gate: 基线已初始化为当前实测 ${current} 处失效(${baselineDisplay(specBase)})`, inited: true };
91
+ }
92
+
93
+ const baseline = readBaseline(specBase);
94
+ if (baseline === null) {
95
+ return {
96
+ exitCode: 2, ok: false, current, baseline: null, delta: null,
97
+ message: `❌ docs gate: 无基线文件(${baselineDisplay(specBase)})。首次使用先跑 sillyspec docs gate --init-baseline(以当前实测数立基线,存量既往不咎只拦增量)`,
98
+ inited: false,
99
+ };
100
+ }
101
+ if (Number.isNaN(baseline)) {
102
+ return { exitCode: 2, ok: false, current, baseline: null, delta: null, message: `❌ docs gate: 基线文件损坏(非非负整数),手工修正或 --init-baseline 重置`, inited: false };
103
+ }
104
+ const v = evaluateRatchet({ current, baseline });
105
+ return { exitCode: v.ok ? 0 : 1, ok: v.ok, current, baseline, delta: v.delta, message: v.message, inited: false };
106
+ }