sillyspec 3.26.7 → 3.26.9

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.
Files changed (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -2
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
  3. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  4. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  5. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
  8. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  10. package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
  11. package/.husky/pre-push +9 -2
  12. package/README.md +5 -9
  13. package/bin/sillyspec.js +1 -1
  14. package/package.json +2 -2
  15. package/packages/dashboard/server/index.js +14 -8
  16. package/src/change-risk-profile.js +5 -207
  17. package/src/config-schema.js +310 -287
  18. package/src/constants.js +97 -106
  19. package/src/db-engine.js +1 -1
  20. package/src/docs-check.js +362 -0
  21. package/src/docs-debt.js +226 -0
  22. package/src/docs-gate.js +106 -0
  23. package/src/doctor-diagnostics.js +23 -2
  24. package/src/git-helper.js +2 -2
  25. package/src/hooks/worktree-guard.js +1 -1
  26. package/src/index.js +1963 -1787
  27. package/src/init.js +95 -17
  28. package/src/machine-interface.js +7 -4
  29. package/src/modules.js +490 -486
  30. package/src/progress/stage-machine.js +3 -0
  31. package/src/progress.js +37 -13
  32. package/src/quicklog.js +182 -1
  33. package/src/run/command.js +154 -26
  34. package/src/run/complete-handlers.js +1254 -1128
  35. package/src/run/complete.js +9 -33
  36. package/src/run/gates.js +85 -1
  37. package/src/run/prompt.js +82 -1
  38. package/src/run/quick-audit.js +23 -0
  39. package/src/run/shared.js +273 -5
  40. package/src/run/stage.js +17 -1
  41. package/src/scan-diff.js +388 -0
  42. package/src/scan-staleness.js +95 -0
  43. package/src/stage-contract-spec.js +16 -1
  44. package/src/stage-contract.js +8 -5
  45. package/src/stage-review.js +1 -1
  46. package/src/stages/brainstorm-auto.js +0 -1
  47. package/src/stages/brainstorm.js +3 -3
  48. package/src/stages/execute.js +35 -11
  49. package/src/stages/plan-postcheck.js +93 -36
  50. package/src/stages/plan.js +22 -7
  51. package/src/stages/quick.js +1 -1
  52. package/src/stages/scan.js +2 -2
  53. package/src/stages/verify.js +11 -12
  54. package/src/sync.js +39 -17
  55. package/src/task-review.js +14 -5
  56. package/src/workflow.js +8 -185
  57. package/src/worktree-apply.js +128 -3
  58. package/src/worktree.js +2 -2
  59. package/templates/claude-instruction.md +1 -1
  60. package/templates/prompts/taskcard-rules.md +1 -0
  61. package/templates/prompts/verify-probes.md +2 -2
  62. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
package/src/constants.js CHANGED
@@ -1,106 +1,97 @@
1
- /**
2
- * SillySpec 平台状态枚举
3
- *
4
- * 所有平台产物(manifest、pointer、postcheck、workflow-runs)共享这些枚举。
5
- * SillyHub 侧直接使用常量值,不需要猜字符串。
6
- */
7
-
8
- // ── scan 阶段状态 ──
9
- export const SCAN_STATUS = Object.freeze({
10
- PENDING: 'pending', // scan 未开始
11
- IN_PROGRESS: 'in_progress', // scan 进行中
12
- SUCCESS: 'success', // scan 成功(所有检查通过)
13
- COMPLETED_WITH_WARNINGS: 'completed_with_warnings', // scan 成功但有警告
14
- FAILED_POST_CHECK: 'failed_post_check', // scan 失败(post-check 不通过)
15
- })
16
-
17
- // ── 平台指针状态 ──
18
- export const POINTER_STATUS = Object.freeze({
19
- ACTIVE: 'active', // 指针活跃,任务进行中
20
- SCAN_COMPLETED: 'scan_completed', // scan 已完成
21
- STALE: 'stale', // 指针过时(完成超过 24h,建议清理)
22
- CORRUPTED: 'corrupted', // 指针损坏(缺少必要字段)
23
- })
24
-
25
- // ── workflow 检查状态 ──
26
- export const WORKFLOW_STATUS = Object.freeze({
27
- PASS: 'pass',
28
- FAIL: 'fail',
29
- SKIPPED: 'skipped',
30
- })
31
-
32
- // ── postcheck 检查严重级别 ──
33
- export const CHECK_SEVERITY = Object.freeze({
34
- FAILED: 'failed',
35
- WARNING: 'warning',
36
- PASSED: 'passed',
37
- })
38
-
39
- // ── stage 步骤状态 ──
40
- export const STEP_STATUS = Object.freeze({
41
- PENDING: 'pending',
42
- IN_PROGRESS: 'in-progress',
43
- COMPLETED: 'completed',
44
- SKIPPED: 'skipped',
45
- })
46
-
47
- // ── stage 阶段状态 ──
48
- export const STAGE_STATUS = Object.freeze({
49
- PENDING: 'pending',
50
- IN_PROGRESS: 'in_progress',
51
- COMPLETED: 'completed',
52
- FAILED_POST_CHECK: 'failed_post_check',
53
- })
54
-
55
- /**
56
- * 判断指针是否过时(完成超过 24h)
57
- */
58
- export function isPointerStale(pointer) {
59
- if (!pointer.completedAt) return false
60
- const completed = new Date(pointer.completedAt)
61
- const age = Date.now() - completed.getTime()
62
- return age > 24 * 60 * 60 * 1000
63
- }
64
-
65
- /**
66
- * 判断指针是否损坏(缺少必要字段)
67
- */
68
- export function isPointerCorrupted(pointer) {
69
- return !pointer || !pointer.specRoot || !pointer.savedAt
70
- }
71
-
72
- // ── scan 必需文档清单(stage-contract validator 与 scan-postcheck 共用单一真相源)──
73
- // 曾分两处硬编码同一份清单,加新文档需同步改两处,易分叉。现收敛到此处一处。
74
- export const SCAN_REQUIRED_DOCS = Object.freeze([
75
- 'ARCHITECTURE.md',
76
- 'CONVENTIONS.md',
77
- 'STRUCTURE.md',
78
- 'INTEGRATIONS.md',
79
- 'TESTING.md',
80
- 'CONCERNS.md',
81
- 'PROJECT.md',
82
- ])
83
-
84
- // ── scan quick profile 必需文档子集(4 份核心文档)──
85
- // quick 档(--quick 显式 或 小项目自动判定)只生成这 4 份;scan-postcheck 按 profile 取清单:
86
- // mode==='quick' → SCAN_REQUIRED_DOCS_QUICK,否则 → SCAN_REQUIRED_DOCS(7 份)。
87
- // 由 scan-postcheck.js + complete-handlers.js 透传的 scanProfile.mode 决定取哪份。
88
- export const SCAN_REQUIRED_DOCS_QUICK = Object.freeze([
89
- 'PROJECT.md',
90
- 'ARCHITECTURE.md',
91
- 'CONVENTIONS.md',
92
- 'STRUCTURE.md',
93
- ])
94
-
95
- // ── 辅助 stage 清单(stage-contract 转移规则 与 stages/index 注册表 共用单一真相源)──
96
- // 曾在 stage-contract.js 与 stages/index.js 逐字重复定义,新增/调整辅助 stage 需同步改两处
97
- // 易分叉。现收敛一处。(注意:主流程序 STAGE_ORDER/mainFlowStages/flowStages 语义不同、
98
- // 各自用途正确,不在本清单范围。)
99
- export const AUXILIARY_STAGES = Object.freeze([
100
- 'scan',
101
- 'quick',
102
- 'explore',
103
- 'archive',
104
- 'status',
105
- 'doctor',
106
- ])
1
+ /**
2
+ * SillySpec 平台状态枚举
3
+ *
4
+ * 所有平台产物(manifest、pointer、postcheck、workflow-runs)共享这些枚举。
5
+ * SillyHub 侧直接使用常量值,不需要猜字符串。
6
+ */
7
+
8
+ // ── scan 阶段状态 ──
9
+ export const SCAN_STATUS = Object.freeze({
10
+ PENDING: 'pending', // scan 未开始
11
+ IN_PROGRESS: 'in_progress', // scan 进行中
12
+ SUCCESS: 'success', // scan 成功(所有检查通过)
13
+ COMPLETED_WITH_WARNINGS: 'completed_with_warnings', // scan 成功但有警告
14
+ FAILED_POST_CHECK: 'failed_post_check', // scan 失败(post-check 不通过)
15
+ })
16
+
17
+ // ── 平台指针状态 ──
18
+ export const POINTER_STATUS = Object.freeze({
19
+ ACTIVE: 'active', // 指针活跃,任务进行中
20
+ SCAN_COMPLETED: 'scan_completed', // scan 已完成
21
+ STALE: 'stale', // 指针过时(完成超过 24h,建议清理)
22
+ CORRUPTED: 'corrupted', // 指针损坏(缺少必要字段)
23
+ })
24
+
25
+ // ── workflow 检查状态 ──
26
+ export const WORKFLOW_STATUS = Object.freeze({
27
+ PASS: 'pass',
28
+ FAIL: 'fail',
29
+ SKIPPED: 'skipped',
30
+ })
31
+
32
+ // ── postcheck 检查严重级别 ──
33
+ export const CHECK_SEVERITY = Object.freeze({
34
+ FAILED: 'failed',
35
+ WARNING: 'warning',
36
+ PASSED: 'passed',
37
+ })
38
+
39
+ /**
40
+ * 判断指针是否过时(完成超过 24h)
41
+ */
42
+ export function isPointerStale(pointer) {
43
+ if (!pointer.completedAt) return false
44
+ const completed = new Date(pointer.completedAt)
45
+ const age = Date.now() - completed.getTime()
46
+ return age > 24 * 60 * 60 * 1000
47
+ }
48
+
49
+ /**
50
+ * 判断指针是否损坏(缺少必要字段)
51
+ */
52
+ export function isPointerCorrupted(pointer) {
53
+ return !pointer || !pointer.specRoot || !pointer.savedAt
54
+ }
55
+
56
+ // ── scan 必需文档清单(stage-contract validator 与 scan-postcheck 共用单一真相源)──
57
+ // 曾分两处硬编码同一份清单,加新文档需同步改两处,易分叉。现收敛到此处一处。
58
+ export const SCAN_REQUIRED_DOCS = Object.freeze([
59
+ 'ARCHITECTURE.md',
60
+ 'CONVENTIONS.md',
61
+ 'STRUCTURE.md',
62
+ 'INTEGRATIONS.md',
63
+ 'TESTING.md',
64
+ 'CONCERNS.md',
65
+ 'PROJECT.md',
66
+ ])
67
+
68
+ // ── scan quick profile 必需文档子集(4 份核心文档)──
69
+ // quick 档(--quick 显式 或 小项目自动判定)只生成这 4 份;scan-postcheck 按 profile 取清单:
70
+ // mode==='quick' → SCAN_REQUIRED_DOCS_QUICK,否则 → SCAN_REQUIRED_DOCS(7 份)。
71
+ // 由 scan-postcheck.js + complete-handlers.js 透传的 scanProfile.mode 决定取哪份。
72
+ export const SCAN_REQUIRED_DOCS_QUICK = Object.freeze([
73
+ 'PROJECT.md',
74
+ 'ARCHITECTURE.md',
75
+ 'CONVENTIONS.md',
76
+ 'STRUCTURE.md',
77
+ ])
78
+
79
+ // ── 辅助 stage 清单(stage-contract 转移规则 与 stages/index 注册表 共用单一真相源)──
80
+ // 曾在 stage-contract.js 与 stages/index.js 逐字重复定义,新增/调整辅助 stage 需同步改两处
81
+ // 易分叉。现收敛一处。(注意:主流程序 STAGE_ORDER/mainFlowStages/flowStages 语义不同、
82
+ // 各自用途正确,不在本清单范围。)
83
+ export const AUXILIARY_STAGES = Object.freeze([
84
+ 'scan',
85
+ 'quick',
86
+ 'explore',
87
+ 'archive',
88
+ 'status',
89
+ 'doctor',
90
+ ])
91
+
92
+ // ── 只读辅助 stage 清单(查询型辅助阶段,可读短路不触碰进度库)──
93
+ // 供 command.js 判断 status/doctor 等查询型辅助阶段执行只读短路(FR-04 / D-005@v2)。
94
+ export const READONLY_AUXILIARY_STAGES = Object.freeze([
95
+ 'status',
96
+ 'doctor',
97
+ ])
package/src/db-engine.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 封装 node:sqlite 内置模块 + 消解 better-sqlite3 → node:sqlite 三缺口:
3
3
  // pragma → exec / transaction → 手写 SAVEPOINT / pluck → Object.values 取首列
4
4
  // 供 db.js 与 doctor-diagnostics 共用(方案 B 单一换引擎点,D-002)。
5
- // node:sqlite 在 node v22.11.0+ 无需 --experimental-sqlite flag(仍发 ExperimentalWarning)。
5
+ // node:sqlite 在 node v22.13.0+ 无需 --experimental-sqlite flag(仍发 ExperimentalWarning)。
6
6
  // Author: sillyspec execute W1 task-02
7
7
  // Created: 2026-08-11
8
8
  import { DatabaseSync } from 'node:sqlite';
@@ -0,0 +1,362 @@
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, statSync } 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
+ const REF_RE = /([A-Za-z0-9_.\-\/]+\.(?:js|mjs)):(\d+)(?:-(\d+))?/g
21
+
22
+ /** 缺省扫描范围:docs/ + .sillyspec/docs/(scan/modules 产物同是文档,失效即该暴露;2026-08-16 用户裁决改缺省,见 doc-consistency-debt.md §八) */
23
+ const DEFAULT_DOC_PATHS = ['docs/**/*.md', '.sillyspec/docs/**/*.md']
24
+
25
+ /**
26
+ * 读 local.yaml 的 docs-check 段(best-effort,绝不抛;缺文件/无段 → 全缺省)。
27
+ * @param {string} projectRoot 源码仓根(local.yaml 在 <root>/.sillyspec/local.yaml)
28
+ * @returns {{ paths?: string[], skip: string[], keywordAssert: boolean }}
29
+ */
30
+ export function readDocsCheckConfig(projectRoot) {
31
+ const fallback = { paths: null, skip: [], keywordAssert: true }
32
+ try {
33
+ const p = join(projectRoot, '.sillyspec', 'local.yaml')
34
+ if (!existsSync(p)) return fallback
35
+ const doc = jsYaml.load(readFileSync(p, 'utf8'))
36
+ if (!doc || typeof doc !== 'object') return fallback
37
+ const dc = doc['docs-check']
38
+ if (!dc || typeof dc !== 'object') return fallback
39
+ return {
40
+ paths: Array.isArray(dc.paths) ? dc.paths.filter(s => typeof s === 'string') : null,
41
+ skip: Array.isArray(dc.skip) ? dc.skip.filter(s => typeof s === 'string') : [],
42
+ keywordAssert: typeof dc.keywordAssert === 'boolean' ? dc.keywordAssert : true,
43
+ }
44
+ } catch {
45
+ return fallback
46
+ }
47
+ }
48
+
49
+ /** 配置错误(glob 形态不支持等)→ CLI exit 2 */
50
+ export class DocsCheckConfigError extends Error {}
51
+
52
+ /**
53
+ * 纯函数:从 markdown 全文提取全部 file:line 引用。
54
+ * @param {string} md 文档全文
55
+ * @returns {Array<{ ref, file, start, end, docLine }>} docLine 为 1-based 文档行号
56
+ */
57
+ export function collectDocRefs(md) {
58
+ if (!md || typeof md !== 'string') return []
59
+ const refs = []
60
+ const re = new RegExp(REF_RE.source, 'g')
61
+ let m
62
+ while ((m = re.exec(md)) !== null) {
63
+ refs.push({
64
+ ref: m[0],
65
+ file: m[1],
66
+ start: parseInt(m[2], 10),
67
+ end: m[3] !== undefined ? parseInt(m[3], 10) : parseInt(m[2], 10),
68
+ docLine: md.slice(0, m.index).split(/\r?\n/).length,
69
+ })
70
+ }
71
+ return refs
72
+ }
73
+
74
+ /**
75
+ * 纯函数:判定 token 是否「像代码符号」:首字符字母/_/$,且含大写字母/下划线/点/$ 之一。
76
+ * 纯小写英文单词(local/platform/abort)→ false(自然语言,跳过断言防误报)。
77
+ */
78
+ export function looksLikeCodeSymbol(token) {
79
+ if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(token)) return false
80
+ return /[A-Z_$.]/.test(token)
81
+ }
82
+
83
+ /**
84
+ * 纯函数:层1 行号边界校验。
85
+ * @param {number} totalLines 源文件总行数
86
+ * @param {number} start 1-based 起始行
87
+ * @param {number} end 1-based 结束行(单行引用 = start)
88
+ * @returns {{ ok: boolean, reasons: string[] }}
89
+ */
90
+ export function validateRefLines(totalLines, start, end) {
91
+ const reasons = []
92
+ if (start < 1 || start > totalLines) reasons.push(`行号超界(start=${start} > 总行数 ${totalLines})`)
93
+ if (end < start || end > totalLines) reasons.push(`范围 end=${end} 超界(总行数 ${totalLines})`)
94
+ return { ok: reasons.length === 0, reasons }
95
+ }
96
+
97
+ /**
98
+ * 纯函数:层2 token 提取——引用所在的文档行内所有反引号 token 中的代码符号。
99
+ * token 归一:①剥函数括号(getDispatchMode());②点分名拆段(syncMod.checkApproval → 两段)。
100
+ * 空数组 = 纯位置引用,跳过层2。
101
+ * @param {string} line 引用所在的文档行全文
102
+ * @returns {string[]} token 列表
103
+ */
104
+ export function extractExpectedTokensFromLine(line) {
105
+ if (!line || typeof line !== 'string') return []
106
+ const tokens = new Set()
107
+ for (const mm of line.matchAll(/`([^`\n]{1,60})`/g)) {
108
+ const t = mm[1].split('(')[0].trim()
109
+ if (!looksLikeCodeSymbol(t)) continue
110
+ tokens.add(t)
111
+ if (t.includes('.')) {
112
+ for (const seg of t.split('.')) {
113
+ if (looksLikeCodeSymbol(seg)) tokens.add(seg)
114
+ }
115
+ }
116
+ }
117
+ return [...tokens]
118
+ }
119
+
120
+ /** 递归收集 dir 下与 baseName 同名的文件(相对 dir 的 POSIX 路径数组;排除 node_modules/.git) */
121
+ function findInTree(dir, baseName, rel = '') {
122
+ const out = []
123
+ let entries
124
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
125
+ for (const e of entries) {
126
+ if (e.name === 'node_modules' || e.name === '.git') continue
127
+ const relPath = rel ? `${rel}/${e.name}` : e.name
128
+ if (e.isDirectory()) out.push(...findInTree(join(dir, e.name), baseName, relPath))
129
+ else if (e.name === baseName) out.push(relPath)
130
+ }
131
+ return out
132
+ }
133
+
134
+ /**
135
+ * 解析引用文件 → 候选绝对路径数组。文档写法三种混用(首跑实证):
136
+ * ①仓库根相对(src/sync.js / docs/...)→ 直拼;②src/ 内部相对(dispatch/probe.js)→
137
+ * src/ 前缀重试;③裸中缀(backends/sillyhub-mcp.js)→ src/ 树内按路径后缀匹配。
138
+ * 空数组 = 不存在。
139
+ */
140
+ export function resolveCandidates(projectRoot, refFile) {
141
+ if (refFile.includes('/')) {
142
+ const direct = join(projectRoot, refFile)
143
+ if (existsSync(direct)) return [direct]
144
+ const inSrc = join(projectRoot, 'src', refFile)
145
+ if (existsSync(inSrc)) return [inSrc]
146
+ const slash = refFile.lastIndexOf('/')
147
+ const baseName = slash === -1 ? refFile : refFile.slice(slash + 1)
148
+ return findInTree(join(projectRoot, 'src'), baseName)
149
+ .filter((rel) => ('src/' + rel).endsWith('/' + refFile) || 'src/' + rel === 'src/' + refFile)
150
+ .map((rel) => join(projectRoot, 'src', rel))
151
+ }
152
+ return findInTree(join(projectRoot, 'src'), refFile).map((rel) => join(projectRoot, 'src', rel))
153
+ }
154
+
155
+ /** 读文件行数组(CRLF/LF 归一:split(/\r?\n/),层2 只做子串查找不污染原文) */
156
+ function readLines(absPath) {
157
+ try { return readFileSync(absPath, 'utf8').split(/\r?\n/) } catch { return null }
158
+ }
159
+
160
+ /** 简单通配匹配(skip 排除用,仅 * 单段) */
161
+ function matchSimple(text, pattern) {
162
+ const parts = pattern.split('*')
163
+ let idx = 0
164
+ for (let i = 0; i < parts.length; i++) {
165
+ if (parts[i] === '') continue
166
+ const found = text.indexOf(parts[i], idx)
167
+ if (found === -1) return false
168
+ if (i === 0 && found !== 0) return false
169
+ idx = found + parts[i].length
170
+ }
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * glob walker(D-008:手写不引依赖)。仅支持三形态:
176
+ * - `dir/「递归」*.ext` 递归收集(maxDepth 12 兜底防符号链接环)
177
+ * - `dir/*.ext` 单层
178
+ * - 字面路径直传(存在才返回)
179
+ * 其他形态(?、{a,b}、多层 * 混合等)抛 DocsCheckConfigError → CLI exit 2。
180
+ * @param {string} projectRoot 锚点(平台模式也锚源码仓根,design §3.3)
181
+ * @param {string} pattern glob 模式
182
+ * @param {string[]} skip 排除 glob(前缀或单 * 简单匹配)
183
+ * @returns {string[]} 命中文件的 POSIX 相对路径
184
+ */
185
+ export function walkGlob(projectRoot, pattern, skip = []) {
186
+ const norm = (p) => p.replace(/\\/g, '/')
187
+ const isSkipped = (rel) => skip.some((s) => {
188
+ const sn = norm(s).replace(/\/$/, '')
189
+ return rel === sn || rel.startsWith(sn + '/') ||
190
+ (sn.includes('*') && matchSimple(rel, sn))
191
+ })
192
+ const matchExt = (name, ext) => name.endsWith(ext)
193
+ // 形态 0:**/*.ext(根级递归,无 base 前缀——B11b:原被形态 2 误解析为字面目录 `**` 静默 0 命中)
194
+ let m0 = norm(pattern).match(/^\*\*\/\*(\.[A-Za-z0-9]+)$/)
195
+ if (m0) {
196
+ const [, ext] = m0
197
+ const out = []
198
+ const rec = (dir, rel, depth) => {
199
+ if (depth > 12) return
200
+ let entries
201
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
202
+ for (const e of entries) {
203
+ if (e.name === 'node_modules' || e.name === '.git') continue
204
+ const relPath = rel ? `${rel}/${e.name}` : e.name
205
+ if (isSkipped(relPath)) continue
206
+ if (e.isDirectory()) rec(join(dir, e.name), relPath, depth + 1)
207
+ else if (matchExt(e.name, ext)) out.push(relPath)
208
+ }
209
+ }
210
+ rec(projectRoot, '', 0)
211
+ return out
212
+ }
213
+ // 形态 1:**/*.ext
214
+ let m = norm(pattern).match(/^(.+?)\/\*\*\/\*(\.[A-Za-z0-9]+)$/)
215
+ if (m) {
216
+ const [, baseDir, ext] = m
217
+ const out = []
218
+ const rec = (dir, rel, depth) => {
219
+ if (depth > 12) return
220
+ let entries
221
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
222
+ for (const e of entries) {
223
+ if (e.name === 'node_modules' || e.name === '.git') continue
224
+ const relPath = rel ? `${rel}/${e.name}` : e.name
225
+ if (isSkipped(relPath)) continue
226
+ if (e.isDirectory()) rec(join(dir, e.name), relPath, depth + 1)
227
+ else if (matchExt(e.name, ext)) out.push(relPath)
228
+ }
229
+ }
230
+ rec(join(projectRoot, baseDir), baseDir, 0)
231
+ return out
232
+ }
233
+ // 形态 2:dir/*.ext(单层)
234
+ m = norm(pattern).match(/^(.+?)\/\*(\.[A-Za-z0-9]+)$/)
235
+ if (m) {
236
+ const [, baseDir, ext] = m
237
+ const out = []
238
+ let entries
239
+ try { entries = readdirSync(join(projectRoot, baseDir), { withFileTypes: true }) } catch { return out }
240
+ for (const e of entries) {
241
+ if (!e.isFile()) continue
242
+ const relPath = `${baseDir}/${e.name}`
243
+ if (isSkipped(relPath)) continue
244
+ if (matchExt(e.name, ext)) out.push(relPath)
245
+ }
246
+ return out
247
+ }
248
+ // 形态 3:字面路径(不含任何通配元字符才直传;含 ?、{ }、[ ] 等不支持形态落 error)
249
+ if (!/[*?{}\[\]]/.test(pattern)) {
250
+ const abs = join(projectRoot, pattern)
251
+ if (!existsSync(abs)) return []
252
+ // B11b:目录字面量抛配置错误(原直传 readFileSync 撞 EISDIR 裸崩 exit 1;契约应 exit 2)
253
+ if (statSync(abs).isDirectory()) {
254
+ throw new DocsCheckConfigError(`路径是目录不是文档:${pattern}(文档路径应为 .md 文件或 glob 形态 **/*.ext / dir/**/*.ext / dir/*.ext)`)
255
+ }
256
+ return [norm(pattern)]
257
+ }
258
+ throw new DocsCheckConfigError(`不支持的 glob 形态:${pattern}(当前仅支持 dir/**/*.ext、dir/*.ext、字面路径)`)
259
+ }
260
+
261
+ /** 候选路径 → 相对 projectRoot 显示(失败信息用;Windows 反斜杠归一) */
262
+ function relDisplay(absPath, projectRoot) {
263
+ return absPath
264
+ .replace(/\\/g, '/')
265
+ .replace(projectRoot.replace(/\\/g, '/') + '/', '')
266
+ }
267
+
268
+ /**
269
+ * IO 入口:跑一次完整校验。
270
+ * @param {{ projectRoot: string, docs?: string[], paths?: string[], skip?: string[], keywordAssert?: boolean }} opts
271
+ * projectRoot 源码仓根(候选解析与 glob 的锚点,平台模式也传源码仓根)
272
+ * docs 显式文档相对路径列表(优先于 paths glob 展开结果)
273
+ * @returns {{ ok: boolean, total: number, invalid: Array<{doc, docLine, ref, reason}>, warnings: string[], kwChecked: number }}
274
+ * @throws {DocsCheckConfigError} glob 形态不支持
275
+ */
276
+ export function runDocsCheck(opts) {
277
+ // paths/docs 显式传 null(readDocsCheckConfig 无 local.yaml 段时的回退值)须落回缺省 glob,
278
+ // 解构默认值只挡 undefined 不挡 null——docs-check 无配置裸跑曾因此 null.flatMap 崩溃。
279
+ const { projectRoot, docs = null, paths: rawPaths = DEFAULT_DOC_PATHS, skip = [], keywordAssert = true } = opts || {}
280
+ const paths = Array.isArray(rawPaths) && rawPaths.length > 0 ? rawPaths : DEFAULT_DOC_PATHS
281
+ const warnings = []
282
+ if (keywordAssert === false) warnings.push('关键词断言已关闭(keywordAssert=false),仅做存在性校验')
283
+ const docFiles = docs && docs.length > 0
284
+ ? docs
285
+ : [...new Set(paths.flatMap((p) => walkGlob(projectRoot, p, skip)))].sort()
286
+
287
+ const invalid = []
288
+ let total = 0
289
+ let kwChecked = 0
290
+
291
+ for (const docRel of docFiles) {
292
+ const docAbs = join(projectRoot, docRel)
293
+ if (!existsSync(docAbs)) {
294
+ invalid.push({ doc: docRel, docLine: 0, ref: '', reason: '文档不存在' })
295
+ continue
296
+ }
297
+ const md = readFileSync(docAbs, 'utf8')
298
+ const refs = collectDocRefs(md)
299
+ const mdLines = md.split(/\r?\n/)
300
+
301
+ for (const r of refs) {
302
+ total++
303
+ const candidates = resolveCandidates(projectRoot, r.file)
304
+ if (candidates.length === 0) {
305
+ invalid.push({
306
+ doc: docRel, docLine: r.docLine, ref: r.ref,
307
+ reason: '文件不存在(含 / 按仓库根解析;裸文件名在 src/ 递归)',
308
+ })
309
+ continue
310
+ }
311
+ // 多候选宽容:逐候选跑层1+层2,任一全过即通过
312
+ const tokens = keywordAssert
313
+ ? extractExpectedTokensFromLine(mdLines[r.docLine - 1] || '')
314
+ : []
315
+ const candidateFails = []
316
+ let passedAny = false
317
+ for (const candAbs of candidates) {
318
+ const lines = readLines(candAbs)
319
+ if (lines === null) { candidateFails.push(`${relDisplay(candAbs, projectRoot)}: 读取失败`); continue }
320
+ const v = validateRefLines(lines.length, r.start, r.end)
321
+ // 层2:token 任一在 [start-2, end+5] 窗口命中即过
322
+ let kwOk = true
323
+ if (tokens.length > 0) {
324
+ const from = Math.max(0, r.start - 2)
325
+ const to = Math.min(lines.length, r.end + 5)
326
+ const window = lines.slice(from, to).join('\n')
327
+ kwOk = tokens.some((t) => window.includes(t))
328
+ }
329
+ if (v.ok && kwOk) { passedAny = true; break }
330
+ const reasons = [...v.reasons]
331
+ if (!kwOk) reasons.push(`关键词缺失:期望任一「${tokens.join(' / ')}」在 [start-2, end+5] 窗口内`)
332
+ candidateFails.push(`${relDisplay(candAbs, projectRoot)}: ${reasons.join(';')}`)
333
+ }
334
+ if (tokens.length > 0) kwChecked++
335
+ if (!passedAny) {
336
+ invalid.push({
337
+ doc: docRel, docLine: r.docLine, ref: r.ref,
338
+ reason: candidateFails.length > 1 ? `多候选全失败 → ${candidateFails.join(' | ')}` : candidateFails[0],
339
+ // 建议行号(--suggest):token 在首个候选文件的全量命中行,供人工确认改锚——不自动改文件
340
+ suggest: suggestLines(candidates, tokens),
341
+ })
342
+ }
343
+ }
344
+ }
345
+
346
+ return { ok: invalid.length === 0, total, invalid, warnings, kwChecked }
347
+ }
348
+
349
+ /**
350
+ * 建议行号计算(--suggest):对失效引用,在候选文件里找 token 全量命中行。
351
+ * 无 token(纯行号断言)时返回空数组——没有符号线索无法定位,只能人工看。
352
+ */
353
+ function suggestLines(candidates, tokens) {
354
+ if (tokens.length === 0) return []
355
+ try {
356
+ const lines = readLines(candidates[0])
357
+ if (lines === null) return []
358
+ const out = []
359
+ lines.forEach((l, i) => { if (tokens.some((t) => l.includes(t))) out.push(i + 1) })
360
+ return out.slice(0, 8)
361
+ } catch { return [] }
362
+ }