sillyspec 3.25.9 → 3.25.11

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 (71) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +176 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
@@ -1,862 +1,933 @@
1
- /**
2
- * Verify Postcheck — CLI 客观测试执行与自报告对账
3
- *
4
- * verify 阶段历史上完全依赖 agent 自报告(跑没跑测试、结果如何全写在
5
- * verify-result.md 里),可被文案绕过。此模块让 CLI 在 verify 完成时
6
- * 亲自执行 local.yaml 配置的测试命令,与 verify-result.md 的结论对账:
7
- * 自报告 PASS 但实测失败 → 阻断 verify 完成。
8
- *
9
- * 未配置 commands.test(或标记 unavailable)时降级为 warning 不阻断,
10
- * 兼容无测试项目。
11
- *
12
- * test_strategy 支持(D-002@v1):
13
- * - full(默认):整跑 commands.test(brownfield 行为不变)
14
- * - module:按 local.yaml modules 映射,仅跑 git diff 命中的模块子集
15
- * 测试,避免 monorepo 全量测试超 gate timeout。
16
- */
17
-
18
- import { execSync } from 'child_process'
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
- import { join } from 'path'
21
- import { verifyApiParity } from './contract-matrix.js'
22
- import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
23
-
24
- // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
25
- const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
26
- const OUTPUT_TAIL_CHARS = 4000
27
-
28
- /**
29
- * 从 local.yaml 文本提取 commands.test。
30
- * 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
31
- * 支持带引号与不带引号两种写法;'unavailable' 视为未配置。
32
- */
33
- export function extractTestCommand(yamlText) {
34
- if (!yamlText) return null
35
- const doubleQuoted = yamlText.match(/^\s*test:\s*"([^"]+)"\s*(?:#.*)?$/m)
36
- const singleQuoted = yamlText.match(/^\s*test:\s*'([^']+)'\s*(?:#.*)?$/m)
37
- const quoted = doubleQuoted || singleQuoted
38
- if (quoted && quoted[1]) {
39
- return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
40
- }
41
- const bare = yamlText.match(/^\s*test:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
42
- if (bare && bare[1]) {
43
- const cmd = bare[1].trim()
44
- return cmd.toLowerCase() === 'unavailable' ? null : cmd
45
- }
46
- return null
47
- }
48
-
49
- /**
50
- * 从 local.yaml 文本提取顶层 test_strategy。
51
- * 轻量正则(与 extractTestCommand 同风格,不引 yaml 依赖)。
52
- *
53
- * @param {string} yamlText
54
- * @returns {'full'|'module'|null} - 解析到的策略;缺省/无法解析返回 null(调用方按 full 处理)
55
- */
56
- export function extractTestStrategy(yamlText) {
57
- if (!yamlText) return null
58
- const m = yamlText.match(/^\s*test_strategy:\s*([A-Za-z_]+)\s*(?:#.*)?$/m)
59
- if (!m || !m[1]) return null
60
- const v = m[1].trim().toLowerCase()
61
- if (v === 'module') return 'module'
62
- if (v === 'full') return 'full'
63
- return null
64
- }
65
-
66
- /**
67
- * 计算全量 fallback 的原因文本(供 printVerifyTestCheck / facet 给 agent/daemon 明示)。
68
- *
69
- * 返回 null 表示不需要 hint:显式 test_strategy:full(用户有意跑全量),
70
- * 或 test_strategy:module 已成功命中模块子集(该情况不会走到全量路径)。
71
- * 其余走全量路径的情况都返回原因字符串,让结果可正确解读——
72
- * 否则 agent/daemon 会把"全量 commands.test"当成"按变更范围测的",误把
73
- * 未变更模块的预存错误归因到本次变更(见 3.24 verify 坑1)。
74
- *
75
- * 语义对齐 runVerifyTestCheck 的分支:
76
- * - strategy==='full' → null
77
- * - strategy==='module' 但无有效 modules 块 → hint
78
- * - strategy==='module' 有块但 git 不可用(hitCount=-1)→ hint
79
- * - strategy==='module' 有块但 0 命中(hitCount=0)→ hint
80
- * - strategy==='module' 有块且命中(hitCount>0)→ null(走子集)
81
- * - strategy===null(缺省)→ hint(默认全量)
82
- *
83
- * @param {object} ctx
84
- * @param {'full'|'module'|null} ctx.strategy
85
- * @param {boolean} ctx.modulesPresent - extractModules 是否返回有效映射
86
- * @param {number} ctx.hitCount - git diff 命中的模块数;-1 表示 git 不可用/非仓库
87
- * @returns {string|null}
88
- */
89
- export function computeFullFallbackReason({ strategy, modulesPresent, hitCount }) {
90
- if (strategy === 'full') return null
91
- if (strategy === 'module') {
92
- if (!modulesPresent) {
93
- return 'test_strategy: module 但 local.yaml 未配置有效的 modules: 块(需 inline flow: name: { path, test }),回退全量'
94
- }
95
- if (hitCount < 0) {
96
- return 'test_strategy: module 但 git 不可用/非 git 仓库,无法判定命中模块,回退全量'
97
- }
98
- if (hitCount === 0) {
99
- return 'test_strategy: module 但本次 git diff 未命中任何已配置 modules,回退全量'
100
- }
101
- return null
102
- }
103
- // strategy === null(缺省 → 默认全量)
104
- return 'local.yaml 未配置 test_strategy(默认全量 commands.test,未按变更范围收窄)'
105
- }
106
-
107
- /**
108
- * 从 local.yaml 文本解析 modules 映射块。
109
- * 设计约定(见 change 2026-07-10-tooling-followups design.md #2 方案 A):
110
- *
111
- * modules:
112
- * backend: { path: "backend/", test: "cd backend && uv run pytest" }
113
- * frontend: { path: "frontend/", test: "cd frontend && pnpm test" }
114
- *
115
- * 每个模块一行 inline flow mapping,含 path 与 test 两个键。
116
- * 轻量行扫描(与 modules.js parseModuleMapSimple 同风格,不引 yaml 依赖)。
117
- *
118
- * @param {string} yamlText
119
- * @returns {Record<string, {path:string, test:string}>|null}
120
- * - 找不到 modules 块 → null(调用方 fallback commands.test)
121
- * - 找到块但无有效条目 → null
122
- */
123
- export function extractModules(yamlText) {
124
- if (!yamlText) return null
125
- const lines = yamlText.split('\n')
126
-
127
- // 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
128
- let startIdx = -1
129
- for (let i = 0; i < lines.length; i++) {
130
- const m = lines[i].match(/^modules:\s*(?:#.*)?$/)
131
- if (m) { startIdx = i; break }
132
- }
133
- if (startIdx === -1) return null
134
-
135
- const modules = {}
136
- for (let i = startIdx + 1; i < lines.length; i++) {
137
- const line = lines[i]
138
- // 子条目:以 2 空格缩进 + key + ':' 开头
139
- const entry = line.match(/^ ([A-Za-z0-9_.\-]+):\s*(.*)$/)
140
- if (!entry) {
141
- // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
142
- if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
143
- continue
144
- }
145
- const name = entry[1]
146
- const rest = entry[2].trim()
147
- if (rest === '' || rest.startsWith('#')) continue // 子块展开式(本实现只支持 inline flow)
148
- // 解析 inline flow mapping: { path: "...", test: "..." }
149
- const pathVal = parseFlowValue(rest, 'path')
150
- const testVal = parseFlowValue(rest, 'test')
151
- if (pathVal && testVal) {
152
- modules[name] = { path: pathVal, test: testVal }
153
- }
154
- }
155
-
156
- return Object.keys(modules).length > 0 ? modules : null
157
- }
158
-
159
- /**
160
- * 从 inline flow mapping 文本(如 `{ path: "backend/", test: "cd ..." }`)
161
- * 提取指定键的值。支持双引号/单引号/bare 值。
162
- */
163
- function parseFlowValue(flowText, key) {
164
- // 键可能带引号也可能不带:path: "x" 或 "path": "x"
165
- const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]+?))\s*(?=[,}]|$)`)
166
- const m = flowText.match(re)
167
- if (!m) return null
168
- let v = m[1].trim()
169
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
170
- v = v.slice(1, -1)
171
- }
172
- return v.length > 0 ? v : null
173
- }
174
-
175
- /**
176
- * 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
177
- * 文件路径以 module.path 为前缀(含子目录)即视为命中。
178
- *
179
- * @param {string[]} changedFiles - git diff 产出的相对路径列表
180
- * @param {Record<string, {path:string, test:string}>} modules - extractModules 解析结果
181
- * @returns {Array<{name:string, path:string, test:string}>} - 命中的模块(按 modules 声明顺序去重)
182
- */
183
- export function pickHitModules(changedFiles, modules) {
184
- if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
185
- if (!modules || typeof modules !== 'object') return []
186
-
187
- const files = changedFiles.map(f => String(f).replace(/\\/g, '/'))
188
- const hits = []
189
- for (const [name, mod] of Object.entries(modules)) {
190
- if (!mod || !mod.path) continue
191
- const modPath = String(mod.path).replace(/\\/g, '/')
192
- const prefix = modPath.endsWith('/') ? modPath : modPath + '/'
193
- // path 本身被改 或 path/ 下任意文件被改
194
- const hit = files.some(f => f === modPath || f.startsWith(prefix))
195
- if (hit) hits.push({ name, path: modPath, test: mod.test })
196
- }
197
- return hits
198
- }
199
-
200
- /**
201
- * 聚合多个模块测试结果为单一 status。
202
- * - 全 passed → 'passed'
203
- * - 任一 failed → 'failed'
204
- * - 空 → null(调用方按 fallback 处理)
205
- *
206
- * @param {Array<{status:'passed'|'failed'}>} results
207
- * @returns {'passed'|'failed'|null}
208
- */
209
- export function aggregateStatus(results) {
210
- if (!Array.isArray(results) || results.length === 0) return null
211
- if (results.every(r => r && r.status === 'passed')) return 'passed'
212
- return 'failed'
213
- }
214
-
215
- /**
216
- * 从 local.yaml 提取 known_failures 声明(预存失败豁免清单,坑 verify-worktree-... 修复方向 2)。
217
- * 支持块式与流式两种写法:
218
- * known_failures:
219
- * - "tests/test_ppm.py::test_legacy"
220
- * - app/modules/plan/test_old
221
- * 或 known_failures: [a, b]
222
- * 每条作为子串(大小写不敏感)匹配测试输出中的失败行。
223
- *
224
- * @param {string} yamlText
225
- * @returns {string[]} 模式列表;无声明返回 []
226
- */
227
- export function extractKnownFailures(yamlText) {
228
- if (!yamlText) return []
229
- const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
230
- if (inline) {
231
- return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
232
- }
233
- const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
234
- if (block) {
235
- return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
236
- .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
237
- .filter(Boolean)
238
- }
239
- return []
240
- }
241
-
242
- // 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
243
- // 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
244
- // "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
245
- const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
246
- // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
247
- const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|failed\s+in\s+\d)/i
248
-
249
- /**
250
- * 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
251
- * @param {string} output
252
- * @param {string[]} knownFailures 子串模式列表(大小写不敏感)
253
- * @returns {{ failureLines: string[], exempted: string[], remaining: string[] }}
254
- */
255
- export function partitionFailures(output, knownFailures) {
256
- const lines = String(output || '').split(/\r?\n/)
257
- const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
258
- const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
259
- const exempted = []
260
- const remaining = []
261
- for (const l of failureLines) {
262
- const ll = l.toLowerCase()
263
- if (pats.some(p => ll.includes(p))) exempted.push(l)
264
- else remaining.push(l)
265
- }
266
- return { failureLines, exempted, remaining }
267
- }
268
-
269
- /**
270
- * 结合 known_failures 判定单次测试运行的状态(fail-safe)。
271
- * - exitCode 0 → passed
272
- * - exitCode≠0 且无 known_failures → failed(原行为)
273
- * - exitCode≠0 且有 known_failures:
274
- * 检测到失败行且全部命中豁免 → passed(披露:请人工复核清单是否过宽)
275
- * 有未豁免失败行 / 检测不到失败行(保守)→ failed
276
- * fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
277
- *
278
- * @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
279
- */
280
- export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
281
- if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
282
- if (!knownFailures || knownFailures.length === 0) {
283
- return { status: 'failed', reason: baseReason, exemptedCount: 0 }
284
- }
285
- const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
286
- if (failureLines.length > 0 && remaining.length === 0) {
287
- return {
288
- status: 'passed',
289
- reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
290
- exemptedCount: exempted.length,
291
- }
292
- }
293
- // 剩余未豁免失败行:列出具体行 + 预存债指引(坑 verify-known-failures-stale-list)。
294
- // known_failures 清单常滞后于新预存测试 → 同源预存债漏入清单,agent 跑完才发现要手动补。
295
- // 这里点出具体未命中行,让 agent 一眼分辨"补清单 vs 修代码",不替它判断(确定性:列已算出的 remaining)。
296
- let detail
297
- if (remaining.length > 0) {
298
- const sample = remaining.slice(0, 5).map(l => {
299
- const t = String(l).trim()
300
- return t.length > 120 ? t.slice(0, 120) + '…' : t
301
- })
302
- const more = remaining.length > 5 ? `\n …(其余 ${remaining.length - 5} 行见上方测试输出)` : ''
303
- detail = `${remaining.length} 个失败行未命中 known_failures 清单:\n - ${sample.join('\n - ')}${more}\n → 若是预存债(变更前就失败),加入 local.yaml 的 known_failures 清单;若是本次变更引入的真实失败,请修复`
304
- } else {
305
- detail = '失败输出未检测到可豁免的失败行(保守判 fail)'
306
- }
307
- return {
308
- status: 'failed',
309
- reason: baseReason ? `${baseReason}(${detail})` : detail,
310
- exemptedCount: exempted.length,
311
- }
312
- }
313
-
314
- /**
315
- * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
316
- * - module + 命中模块 → 'module-subset'
317
- * - module + 0 命中 → 'module-zero-hit-skip'(不静默回退注定超时/预存失败的全量)
318
- * - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
319
- * @returns {'module-subset'|'module-zero-hit-skip'|'full'}
320
- */
321
- export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
322
- if (strategy === 'module' && modulesPresent) {
323
- if (hitCount > 0) return 'module-subset'
324
- if (hitCount === 0) return 'module-zero-hit-skip'
325
- return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
326
- }
327
- return 'full'
328
- }
329
-
330
- /**
331
- * 跑单个模块的 test 命令(串行调用方逐个调用)。
332
- * @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
333
- */
334
- function runOneModule(name, testCommand, cwd, knownFailures = []) {
335
- const startedAt = Date.now()
336
- let exitCode = 0
337
- let output = ''
338
- let reason = null
339
- try {
340
- output = execSync(testCommand, {
341
- cwd,
342
- encoding: 'utf8',
343
- timeout: TEST_TIMEOUT_MS,
344
- maxBuffer: 32 * 1024 * 1024,
345
- stdio: ['ignore', 'pipe', 'pipe'],
346
- })
347
- } catch (e) {
348
- exitCode = typeof e.status === 'number' ? e.status : 1
349
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
350
- reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
351
- ? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
352
- : `模块 ${name} 测试退出码 ${exitCode}`
353
- }
354
- const durationMs = Date.now() - startedAt
355
- const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
356
- const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
357
- return {
358
- name,
359
- status: judged.status,
360
- command: testCommand,
361
- exitCode,
362
- durationMs,
363
- outputTail,
364
- reason: judged.reason,
365
- exemptedCount: judged.exemptedCount,
366
- }
367
- }
368
-
369
- /**
370
- * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
371
- * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
372
- */
373
- function runGitDiffNameOnly(cwd, refSpec) {
374
- try {
375
- const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
376
- const out = execSync(cmd, {
377
- cwd,
378
- encoding: 'utf8',
379
- timeout: 30 * 1000,
380
- stdio: ['ignore', 'pipe', 'pipe'],
381
- })
382
- return out.split('\n').map(l => l.trim()).filter(Boolean)
383
- } catch {
384
- return null
385
- }
386
- }
387
-
388
- /**
389
- * 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
390
- * 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
391
- */
392
- function runGitDiffNameStatus(cwd, refSpec) {
393
- try {
394
- const cmd = refSpec ? `git diff --name-status ${refSpec}` : 'git diff --name-status'
395
- return execSync(cmd, {
396
- cwd,
397
- encoding: 'utf8',
398
- timeout: 30 * 1000,
399
- stdio: ['ignore', 'pipe', 'pipe'],
400
- })
401
- } catch {
402
- return null
403
- }
404
- }
405
-
406
- /**
407
- * 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
408
- * `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
409
- * 最适合 brownfield(apply 后未 commit / in-place 改动)场景。
410
- * git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
411
- *
412
- * 注意:worktree 隔离模式下主仓只剩 .sillyspec/ 文档改动、代码在 worktree,
413
- * 本函数看不到——runVerifyTestCheck 用 resolveVerifyChangedFiles 统一处理。
414
- */
415
- function gitChangedFiles(cwd) {
416
- const head = runGitDiffNameOnly(cwd, 'HEAD')
417
- if (head !== null) return head
418
- // HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
419
- return runGitDiffNameOnly(cwd, '')
420
- }
421
-
422
- /**
423
- * 解析 verify 对账用的变更文件集(worktree-aware)。
424
- *
425
- * worktree 隔离模式下 execute 的代码改动落在 worktree,主仓工作区只剩
426
- * .sillyspec/ 文档改动。若仍用主仓 `git diff --name-only HEAD`,hitCount=0
427
- * → 回退注定超时且含预存失败的全量 commands.test → verify 完成被阻断
428
- * (坑 verify-worktree-mode-test-reconciliation-fallback-full)。
429
- *
430
- * 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
431
- * 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
432
- * 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
433
- * 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
434
- *
435
- * @param {string} cwd - 项目根目录(主仓)
436
- * @param {string|null} changeName
437
- * @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
438
- */
439
- export function resolveVerifyChangedFiles(cwd, changeName) {
440
- if (changeName) {
441
- const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
442
- if (existsSync(metaPath)) {
443
- let meta = null
444
- try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
445
- if (meta?.baseHash) {
446
- const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
447
- ? meta.worktreePath
448
- : cwd
449
- const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
450
- if (files !== null) return files
451
- // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
452
- }
453
- }
454
- }
455
- return gitChangedFiles(cwd)
456
- }
457
-
458
- /**
459
- * 执行 verify 实测:读取 local.yaml 配置,按 test_strategy 决定全量或模块子集。
460
- *
461
- * @param {object} opts
462
- * @param {string} opts.cwd - 项目根目录(测试执行目录)
463
- * @param {string} opts.specBase - .sillyspec(或平台 specRoot)目录
464
- * @param {string|null} [opts.changeName]
465
- * @returns {{
466
- * status: 'passed'|'failed'|'skipped',
467
- * command: string|null,
468
- * exitCode: number|null,
469
- * durationMs: number|null,
470
- * outputTail: string|null,
471
- * reason: string|null,
472
- * resultPath: string|null,
473
- * }}
474
- */
475
- export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
476
- const localYamlPath = join(specBase, 'local.yaml')
477
- const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
478
-
479
- const strategy = extractTestStrategy(yamlText)
480
- const knownFailures = extractKnownFailures(yamlText)
481
-
482
- // —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
483
- // resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
484
- let modulesPresent = false
485
- let hitCount = 0
486
- let hits = []
487
- if (strategy === 'module') {
488
- const modules = extractModules(yamlText)
489
- if (modules) {
490
- modulesPresent = true
491
- const changedFiles = resolveVerifyChangedFiles(cwd, changeName)
492
- if (changedFiles === null) {
493
- hitCount = -1 // git 不可用 / 非仓库
494
- } else {
495
- hits = pickHitModules(changedFiles, modules)
496
- hitCount = hits.length
497
- }
498
- }
499
- }
500
-
501
- const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
502
- if (action === 'module-subset') {
503
- return runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
504
- }
505
- if (action === 'module-zero-hit-skip') {
506
- // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
507
- // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
508
- return {
509
- status: 'skipped',
510
- command: null,
511
- exitCode: null,
512
- durationMs: null,
513
- outputTail: null,
514
- reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。',
515
- resultPath: null,
516
- mode: 'module-zero-hit',
517
- fallbackReason: null,
518
- }
519
- }
520
-
521
- // —— 全量路径(full / module 无块 / module git 不可用)——
522
- // fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
523
- const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
524
- return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
525
- }
526
-
527
- /**
528
- * 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
529
- */
530
- function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
531
- const command = extractTestCommand(yamlText)
532
-
533
- if (!command) {
534
- return {
535
- status: 'skipped',
536
- command: null,
537
- exitCode: null,
538
- durationMs: null,
539
- outputTail: null,
540
- reason: yamlText
541
- ? 'local.yaml 未配置 commands.test(或标记 unavailable)'
542
- : `local.yaml 不存在(${localYamlPath})`,
543
- resultPath: null,
544
- mode: 'full',
545
- fallbackReason,
546
- }
547
- }
548
-
549
- const startedAt = Date.now()
550
- let exitCode = 0
551
- let output = ''
552
- let reason = null
553
- try {
554
- output = execSync(command, {
555
- cwd,
556
- encoding: 'utf8',
557
- timeout: TEST_TIMEOUT_MS,
558
- maxBuffer: 32 * 1024 * 1024,
559
- stdio: ['ignore', 'pipe', 'pipe'],
560
- })
561
- } catch (e) {
562
- exitCode = typeof e.status === 'number' ? e.status : 1
563
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
564
- reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
565
- ? `测试命令超时(>${TEST_TIMEOUT_MS / 1000}s)`
566
- : `测试命令退出码 ${exitCode}`
567
- }
568
- const durationMs = Date.now() - startedAt
569
- const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
570
-
571
- const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
572
- const result = {
573
- status: judged.status,
574
- command,
575
- exitCode,
576
- durationMs,
577
- outputTail,
578
- reason: judged.reason,
579
- resultPath: null,
580
- mode: 'full',
581
- fallbackReason,
582
- exemptedCount: judged.exemptedCount,
583
- }
584
-
585
- writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
586
- return result
587
- }
588
-
589
- /**
590
- * 串行跑命中的模块子集,聚合结果。
591
- * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
592
- */
593
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
594
- const subsetStartedAt = Date.now()
595
- const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
596
- const status = aggregateStatus(perModule)
597
-
598
- const command = `module[${hits.map(h => h.name).join(',')}]`
599
- const exitCode = status === 'passed' ? 0 : 1
600
- const durationMs = Date.now() - subsetStartedAt
601
-
602
- // 合并各模块输出尾部(标注模块名)
603
- const outputTail = perModule
604
- .map(r => `── module ${r.name} (${r.status}) ──\n${r.outputTail || ''}`)
605
- .join('\n')
606
- const reason = status === 'passed'
607
- ? null
608
- : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
609
-
610
- const result = {
611
- status,
612
- command,
613
- exitCode,
614
- durationMs,
615
- outputTail: outputTail.length > OUTPUT_TAIL_CHARS ? '…' + outputTail.slice(-OUTPUT_TAIL_CHARS) : outputTail,
616
- reason,
617
- resultPath: null,
618
- mode: 'module-subset',
619
- fallbackReason: null,
620
- exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
621
- }
622
-
623
- writeRunResult({
624
- specBase,
625
- changeName,
626
- result,
627
- extra: {
628
- modules: perModule.map(r => ({
629
- name: r.name,
630
- command: r.command,
631
- exit_code: r.exitCode,
632
- status: r.status,
633
- duration_ms: r.durationMs,
634
- output_tail: r.outputTail,
635
- reason: r.reason,
636
- })),
637
- },
638
- })
639
- return result
640
- }
641
-
642
- /**
643
- * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
644
- * 多模块时 extra.modules 描述各模块明细。
645
- */
646
- function writeRunResult({ specBase, changeName, result, extra = {} }) {
647
- try {
648
- const ts = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '')
649
- const runDir = join(specBase, '.runtime', 'verify-runs', ts)
650
- mkdirSync(runDir, { recursive: true })
651
- const resultPath = join(runDir, 'test-result.json')
652
- writeFileSync(resultPath, JSON.stringify({
653
- change: changeName,
654
- command: result.command,
655
- exit_code: result.exitCode,
656
- status: result.status,
657
- duration_ms: result.durationMs,
658
- output_tail: result.outputTail,
659
- reason: result.reason,
660
- ran_at: new Date().toISOString(),
661
- ...extra,
662
- }, null, 2) + '\n')
663
- result.resultPath = resultPath
664
- } catch (e) {
665
- console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
666
- }
667
- }
668
-
669
- /** 打印实测结果(人类/agent 可读) */
670
- export function printVerifyTestCheck(result) {
671
- if (result.status === 'skipped') {
672
- console.warn(`\n⚠️ Verify 实测跳过:${result.reason}`)
673
- console.warn(' 建议在 local.yaml 的 commands.test 配置真实测试命令,让 CLI 可客观对账。')
674
- return
675
- }
676
- if (result.status === 'passed') {
677
- if (result.exemptedCount > 0) {
678
- console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
679
- console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
680
- } else {
681
- console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
682
- }
683
- } else {
684
- console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
685
- if (result.outputTail) {
686
- const tail = result.outputTail.split('\n').slice(-20).join('\n')
687
- console.error(' 输出(末尾):')
688
- for (const line of tail.split('\n')) console.error(` | ${line}`)
689
- }
690
- }
691
- // —— 全量 fallback 明示(skipped 已提前 return,此处仅 passed/failed)——
692
- // 让 agent 知道本次跑的是全量 commands.test、非变更范围子集;失败可能含未变更模块
693
- // 的预存错误,需先核对用例归属再归因到本次变更(见 3.24 verify 坑1)。
694
- if (result.mode === 'full' && result.fallbackReason) {
695
- if (result.status === 'failed') {
696
- console.warn(` ⚠️ 本次跑的是 commands.test 全量(${result.fallbackReason})。`)
697
- console.warn(' 失败可能含与本变更无关的预存错误——先核对失败用例是否属于你的变更范围;')
698
- console.warn(' 或在 local.yaml 配置 test_strategy: module + modules: 块以收窄到变更模块。')
699
- } else {
700
- console.warn(` ⚠️ 本次跑的是 commands.test 全量(${result.fallbackReason})。`)
701
- console.warn(' 如耗时过长,可在 local.yaml 配置 test_strategy: module + modules: 块按模块收窄。')
702
- }
703
- }
704
- if (result.resultPath) {
705
- console.log(`📄 实测结果已写入: ${result.resultPath}`)
706
- }
707
- }
708
-
709
- /**
710
- * API parity 对账(advisory):前端调用 vs execute 提取的后端 endpoint artifact。
711
- * 三态:skipped(无 provider artifact → 非全栈项目不打扰)/ warning(missingBackend>0)/ passed。
712
- * 启发式正则、多假阳源 → advisory 不阻断 verify 完成(与 SillySpec 确定性校验定位一致)。
713
- *
714
- * @param {object} opts
715
- * @param {string} opts.cwd - 扫描前端调用的根(主工作区,verify 时代码在此)
716
- * @param {string} opts.specBase
717
- * @param {string|null} [opts.changeName]
718
- * @param {string|null} [opts.runtimeRoot]
719
- * @returns {{ status: 'skipped'|'warning'|'passed', missingBackend: Array, unusedBackend: Array, summary: string, reason: string|null }}
720
- */
721
- export function runVerifyParityCheck({ cwd, specBase, changeName = null, runtimeRoot = null }) {
722
- const r = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
723
- // 无 provider artifact(execute 未提取 / 非后端项目)→ 不打扰
724
- if (r.backendCount === 0) {
725
- return { status: 'skipped', missingBackend: [], unusedBackend: [], summary: r.summary, reason: '无后端契约 artifact(非全栈项目或 execute 未提取端点)' }
726
- }
727
- if (r.missingBackend.length > 0) {
728
- return { status: 'warning', missingBackend: r.missingBackend, unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
729
- }
730
- return { status: 'passed', missingBackend: [], unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
731
- }
732
-
733
- /** 打印 parity 对账结果(advisory,不阻断) */
734
- export function printVerifyParityCheck(result) {
735
- if (result.status === 'skipped') return // 静默,不打扰非全栈项目
736
- if (result.status === 'passed') {
737
- console.log(`\n✅ API parity 对账通过:${result.summary}`)
738
- return
739
- }
740
- // warning
741
- console.warn(`\n⚠️ API parity 对账发现 ${result.missingBackend.length} 个前端调用无对应后端端点(advisory,不阻断归档):`)
742
- for (const m of result.missingBackend.slice(0, 20)) {
743
- console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
744
- }
745
- if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
746
- console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
747
- }
748
-
749
- /**
750
- * 删除探针(advisory):用 git 事实客观对账本次变更删除的文件 vs design.md 声明的操作。
751
- *
752
- * 切斯特顿栅栏护栏——verify 对「agent 静默删除代码」本是盲区:agent 删一段它看不懂的
753
- * 旧代码,只要路径合规、不碰风险关键词,5 探针 + 风险分级 + 测试对账全都不会响。本探针
754
- * 不信任 agent 自报告,用 `git diff --name-status HEAD` 客观提取删除文件,对账 design
755
- * 清单声明的操作(声明「新增/修改」却整文件删除 = 高风险)。warning 不阻断 verify 完成
756
- * (advisory 起步)——「检测到删除」是确定性事实(做),「该不该删」是意图(只报不拦)。
757
- *
758
- * 信号源:apply(git apply --3way)不 commit,verify 时主仓 HEAD 仍是变更前 commit,
759
- * 删除的文件在工作树消失但仍在 HEAD → `git diff --name-status HEAD` 显示 D。
760
- *
761
- * @param {object} opts
762
- * @param {string} opts.cwd - 项目根(主仓,代码在此)
763
- * @param {string} opts.specBase - .sillyspec(或平台 specRoot)
764
- * @param {string|null} [opts.changeName]
765
- * @returns {{
766
- * status: 'skipped'|'warning'|'passed',
767
- * highRisk: Array<{path:string, declaredOp:string, reason:string}>,
768
- * mediumRisk: Array<{path:string, reason:string}>,
769
- * compliant: Array<{path:string, declaredOp:string}>,
770
- * summary: string,
771
- * reason: string|null,
772
- * }}
773
- */
774
- export function runVerifyDeletionCheck({ cwd, specBase, changeName = null }) {
775
- const statusRaw = runGitDiffNameStatus(cwd, 'HEAD')
776
- if (statusRaw === null) {
777
- return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
778
- summary: '', reason: 'git 不可用或非仓库,删除对账跳过' }
779
- }
780
-
781
- // 解析 D(删除);R/C 的旧路径等价删除(防御 committed rename;标准 apply 流程
782
- // rename 表现为纯 D,故本探针不加 -M,D 统一处理)。
783
- const deletions = []
784
- for (const line of statusRaw.split('\n').filter(Boolean)) {
785
- const parts = line.split('\t')
786
- const st = parts[0]
787
- if (!st) continue
788
- if (st.startsWith('D')) {
789
- deletions.push({ path: parts[1], kind: 'D' })
790
- } else if ((st.startsWith('R') || st.startsWith('C')) && parts.length >= 3) {
791
- // rename/copy 旧路径(parts[1])等价删除,新路径 parts[2]
792
- deletions.push({ path: parts[1], kind: 'R-old', renamedTo: parts[2] })
793
- }
794
- }
795
-
796
- // 排除交付物外文件(.sillyspec/ 文档 churn + meta.json),避免污染删除信号。
797
- // 内联 filterDeliverableFiles 逻辑(一行,不跨模块耦合)。
798
- const deliverable = deletions.filter(d =>
799
- !d.path.startsWith('.sillyspec/') && d.path !== 'meta.json')
800
-
801
- if (deliverable.length === 0) {
802
- return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
803
- summary: '', reason: '本次变更无文件删除(或改动已被 commit,主仓 HEAD 已推进,删除对账无锚点)' }
804
- }
805
-
806
- // 读 design 声明(operation)。无清单章节 → [],所有删除归「未声明」。
807
- const designPath = changeName
808
- ? join(specBase, 'changes', changeName, 'design.md') : null
809
- const designEntries = designPath && existsSync(designPath)
810
- ? parseFileChangeListDetailed(designPath) : []
811
-
812
- const highRisk = []
813
- const mediumRisk = []
814
- const compliant = []
815
- for (const d of deliverable) {
816
- const hit = designEntries.find(e => pathMatches(d.path, e.path))
817
- if (!hit) {
818
- mediumRisk.push({ path: d.path, reason: d.kind === 'R-old'
819
- ? `重命名源文件未在 design 清单(→ ${d.renamedTo})`
820
- : 'design 清单未列出该删除文件' })
821
- continue
822
- }
823
- if (hit.operation === '新增' || hit.operation === '修改') {
824
- highRisk.push({ path: d.path, declaredOp: hit.operation,
825
- reason: `design 声明「${hit.operation}」但 git 显示整文件删除` })
826
- } else if (hit.operation === '删除') {
827
- compliant.push({ path: d.path, declaredOp: '删除' })
828
- } else {
829
- // operation=null(未声明操作)或 重命名:删除发生但声明不明确
830
- mediumRisk.push({ path: d.path,
831
- reason: `design 列出但操作为「${hit.operation || '未声明'}」,与删除不一致` })
832
- }
833
- }
834
-
835
- if (highRisk.length > 0 || mediumRisk.length > 0) {
836
- return { status: 'warning', highRisk, mediumRisk, compliant,
837
- summary: `${highRisk.length} 个高风险删除 + ${mediumRisk.length} 个未声明删除`, reason: null }
838
- }
839
- return { status: 'passed', highRisk: [], mediumRisk: [], compliant,
840
- summary: `所有 ${compliant.length} 个删除均在 design 声明为「删除」`, reason: null }
841
- }
842
-
843
- /** 打印删除对账结果(advisory,不阻断 verify 完成) */
844
- export function printVerifyDeletionCheck(result) {
845
- if (result.status === 'skipped') return // 静默:无删除 / git 不可用 / 改动已 commit
846
- if (result.status === 'passed') {
847
- console.log(`\n✅ 删除对账通过:${result.summary}`)
848
- return
849
- }
850
- // warning
851
- if (result.highRisk.length > 0) {
852
- console.warn(`\n⚠️ 删除对账发现 ${result.highRisk.length} 个高风险删除(design 声明新增/修改却被整文件删除,advisory 不阻断):`)
853
- for (const m of result.highRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
854
- if (result.highRisk.length > 20) console.warn(` …还有 ${result.highRisk.length - 20} 个`)
855
- }
856
- if (result.mediumRisk.length > 0) {
857
- console.warn(`⚠️ ${result.mediumRisk.length} 个未声明删除(design 清单未列出 / 操作不一致):`)
858
- for (const m of result.mediumRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
859
- if (result.mediumRisk.length > 20) console.warn(` …还有 ${result.mediumRisk.length - 20} 个`)
860
- }
861
- console.warn(' 提示:确认删除是否预期。预期删除请在 design.md 清单用「删除」操作显式声明;误删请恢复。')
862
- }
1
+ /**
2
+ * Verify Postcheck — CLI 客观测试执行与自报告对账
3
+ *
4
+ * verify 阶段历史上完全依赖 agent 自报告(跑没跑测试、结果如何全写在
5
+ * verify-result.md 里),可被文案绕过。此模块让 CLI 在 verify 完成时
6
+ * 亲自执行 local.yaml 配置的测试命令,与 verify-result.md 的结论对账:
7
+ * 自报告 PASS 但实测失败 → 阻断 verify 完成。
8
+ *
9
+ * 未配置 commands.test(或标记 unavailable)时降级为 warning 不阻断,
10
+ * 兼容无测试项目。
11
+ *
12
+ * test_strategy 支持(D-002@v1):
13
+ * - full(默认):整跑 commands.test(brownfield 行为不变)
14
+ * - module:按 local.yaml modules 映射,仅跑 git diff 命中的模块子集
15
+ * 测试,避免 monorepo 全量测试超 gate timeout。
16
+ */
17
+
18
+ import { execSync } from 'child_process'
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
+ import { join } from 'path'
21
+ import { verifyApiParity } from './contract-matrix.js'
22
+ import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
23
+ import { filterDeliverableFiles } from './worktree-apply.js'
24
+
25
+ // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
26
+ const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
27
+ const OUTPUT_TAIL_CHARS = 4000
28
+
29
+ /**
30
+ * 从 local.yaml 文本提取 commands.test。
31
+ * 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
32
+ * 支持带引号与不带引号两种写法;'unavailable' 视为未配置。
33
+ */
34
+ export function extractTestCommand(yamlText) {
35
+ if (!yamlText) return null
36
+ const doubleQuoted = yamlText.match(/^\s*test:\s*"([^"]+)"\s*(?:#.*)?$/m)
37
+ const singleQuoted = yamlText.match(/^\s*test:\s*'([^']+)'\s*(?:#.*)?$/m)
38
+ const quoted = doubleQuoted || singleQuoted
39
+ if (quoted && quoted[1]) {
40
+ return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
41
+ }
42
+ const bare = yamlText.match(/^\s*test:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
43
+ if (bare && bare[1]) {
44
+ const cmd = bare[1].trim()
45
+ return cmd.toLowerCase() === 'unavailable' ? null : cmd
46
+ }
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * 从 local.yaml 文本提取顶层 test_strategy。
52
+ * 轻量正则(与 extractTestCommand 同风格,不引 yaml 依赖)。
53
+ *
54
+ * @param {string} yamlText
55
+ * @returns {'full'|'module'|null} - 解析到的策略;缺省/无法解析返回 null(调用方按 full 处理)
56
+ */
57
+ export function extractTestStrategy(yamlText) {
58
+ if (!yamlText) return null
59
+ const m = yamlText.match(/^\s*test_strategy:\s*([A-Za-z_]+)\s*(?:#.*)?$/m)
60
+ if (!m || !m[1]) return null
61
+ const v = m[1].trim().toLowerCase()
62
+ if (v === 'module') return 'module'
63
+ if (v === 'full') return 'full'
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * 计算全量 fallback 的原因文本(供 printVerifyTestCheck / facet 给 agent/daemon 明示)。
69
+ *
70
+ * 返回 null 表示不需要 hint:显式 test_strategy:full(用户有意跑全量),
71
+ * 或 test_strategy:module 已成功命中模块子集(该情况不会走到全量路径)。
72
+ * 其余走全量路径的情况都返回原因字符串,让结果可正确解读——
73
+ * 否则 agent/daemon 会把"全量 commands.test"当成"按变更范围测的",误把
74
+ * 未变更模块的预存错误归因到本次变更(见 3.24 verify 坑1)。
75
+ *
76
+ * 语义对齐 runVerifyTestCheck 的分支:
77
+ * - strategy==='full' → null
78
+ * - strategy==='module' 但无有效 modules 块 → hint
79
+ * - strategy==='module' 有块但 git 不可用(hitCount=-1)→ hint
80
+ * - strategy==='module' 有块但 0 命中(hitCount=0)→ hint
81
+ * - strategy==='module' 有块且命中(hitCount>0)→ null(走子集)
82
+ * - strategy===null(缺省)→ hint(默认全量)
83
+ *
84
+ * @param {object} ctx
85
+ * @param {'full'|'module'|null} ctx.strategy
86
+ * @param {boolean} ctx.modulesPresent - extractModules 是否返回有效映射
87
+ * @param {number} ctx.hitCount - git diff 命中的模块数;-1 表示 git 不可用/非仓库
88
+ * @returns {string|null}
89
+ */
90
+ export function computeFullFallbackReason({ strategy, modulesPresent, hitCount }) {
91
+ if (strategy === 'full') return null
92
+ if (strategy === 'module') {
93
+ if (!modulesPresent) {
94
+ return 'test_strategy: module 但 local.yaml 未配置有效的 modules: 块(需 inline flow: name: { path, test }),回退全量'
95
+ }
96
+ if (hitCount < 0) {
97
+ return 'test_strategy: module 但 git 不可用/非 git 仓库,无法判定命中模块,回退全量'
98
+ }
99
+ if (hitCount === 0) {
100
+ return 'test_strategy: module 但本次 git diff 未命中任何已配置 modules,回退全量'
101
+ }
102
+ return null
103
+ }
104
+ // strategy === null(缺省 → 默认全量)
105
+ return 'local.yaml 未配置 test_strategy(默认全量 commands.test,未按变更范围收窄)'
106
+ }
107
+
108
+ /**
109
+ * 从 local.yaml 文本解析 modules 映射块。
110
+ * 设计约定(见 change 2026-07-10-tooling-followups design.md #2 方案 A):
111
+ *
112
+ * modules:
113
+ * backend: { path: "backend/", test: "cd backend && uv run pytest" }
114
+ * frontend: { path: "frontend/", test: "cd frontend && pnpm test" }
115
+ *
116
+ * 每个模块一行 inline flow mapping,含 path 与 test 两个键。
117
+ * 轻量行扫描(与 modules.js parseModuleMapSimple 同风格,不引 yaml 依赖)。
118
+ *
119
+ * @param {string} yamlText
120
+ * @returns {Record<string, {path:string, test:string}>|null}
121
+ * - 找不到 modules 块 → null(调用方 fallback commands.test)
122
+ * - 找到块但无有效条目 → null
123
+ */
124
+ export function extractModules(yamlText) {
125
+ if (!yamlText) return null
126
+ const lines = yamlText.split('\n')
127
+
128
+ // 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
129
+ let startIdx = -1
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const m = lines[i].match(/^modules:\s*(?:#.*)?$/)
132
+ if (m) { startIdx = i; break }
133
+ }
134
+ if (startIdx === -1) return null
135
+
136
+ const modules = {}
137
+ for (let i = startIdx + 1; i < lines.length; i++) {
138
+ const line = lines[i]
139
+ // 子条目:以 2 空格缩进 + key + ':' 开头
140
+ const entry = line.match(/^ ([A-Za-z0-9_.\-]+):\s*(.*)$/)
141
+ if (!entry) {
142
+ // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
143
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
144
+ continue
145
+ }
146
+ const name = entry[1]
147
+ const rest = entry[2].trim()
148
+ if (rest === '' || rest.startsWith('#')) continue // 子块展开式(本实现只支持 inline flow)
149
+ // 解析 inline flow mapping: { path: "...", test: "..." }
150
+ const pathVal = parseFlowValue(rest, 'path')
151
+ const testVal = parseFlowValue(rest, 'test')
152
+ if (pathVal && testVal) {
153
+ modules[name] = { path: pathVal, test: testVal }
154
+ }
155
+ }
156
+
157
+ return Object.keys(modules).length > 0 ? modules : null
158
+ }
159
+
160
+ /**
161
+ * 从 inline flow mapping 文本(如 `{ path: "backend/", test: "cd ..." }`)
162
+ * 提取指定键的值。支持双引号/单引号/bare 值。
163
+ */
164
+ function parseFlowValue(flowText, key) {
165
+ // 键可能带引号也可能不带:path: "x" 或 "path": "x"
166
+ const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]+?))\s*(?=[,}]|$)`)
167
+ const m = flowText.match(re)
168
+ if (!m) return null
169
+ let v = m[1].trim()
170
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
171
+ v = v.slice(1, -1)
172
+ }
173
+ return v.length > 0 ? v : null
174
+ }
175
+
176
+ /**
177
+ * 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
178
+ * 文件路径以 module.path 为前缀(含子目录)即视为命中。
179
+ *
180
+ * @param {string[]} changedFiles - git diff 产出的相对路径列表
181
+ * @param {Record<string, {path:string, test:string}>} modules - extractModules 解析结果
182
+ * @returns {Array<{name:string, path:string, test:string}>} - 命中的模块(按 modules 声明顺序去重)
183
+ */
184
+ export function pickHitModules(changedFiles, modules) {
185
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
186
+ if (!modules || typeof modules !== 'object') return []
187
+
188
+ const files = changedFiles.map(f => String(f).replace(/\\/g, '/'))
189
+ const hits = []
190
+ for (const [name, mod] of Object.entries(modules)) {
191
+ if (!mod || !mod.path) continue
192
+ const modPath = String(mod.path).replace(/\\/g, '/')
193
+ const prefix = modPath.endsWith('/') ? modPath : modPath + '/'
194
+ // path 本身被改 或 path/ 下任意文件被改
195
+ const hit = files.some(f => f === modPath || f.startsWith(prefix))
196
+ if (hit) hits.push({ name, path: modPath, test: mod.test })
197
+ }
198
+ return hits
199
+ }
200
+
201
+ /**
202
+ * 聚合多个模块测试结果为单一 status。
203
+ * - 全 passed → 'passed'
204
+ * - 任一 failed → 'failed'
205
+ * - 空 → null(调用方按 fallback 处理)
206
+ *
207
+ * @param {Array<{status:'passed'|'failed'}>} results
208
+ * @returns {'passed'|'failed'|null}
209
+ */
210
+ export function aggregateStatus(results) {
211
+ if (!Array.isArray(results) || results.length === 0) return null
212
+ if (results.every(r => r && r.status === 'passed')) return 'passed'
213
+ return 'failed'
214
+ }
215
+
216
+ /**
217
+ * 从 local.yaml 提取 known_failures 声明(预存失败豁免清单,坑 verify-worktree-... 修复方向 2)。
218
+ * 支持块式与流式两种写法:
219
+ * known_failures:
220
+ * - "tests/test_ppm.py::test_legacy"
221
+ * - app/modules/plan/test_old
222
+ * 或 known_failures: [a, b]
223
+ * 每条作为子串(大小写不敏感)匹配测试输出中的失败行。
224
+ *
225
+ * @param {string} yamlText
226
+ * @returns {string[]} 模式列表;无声明返回 []
227
+ */
228
+ export function extractKnownFailures(yamlText) {
229
+ if (!yamlText) return []
230
+ const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
231
+ if (inline) {
232
+ return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
233
+ }
234
+ const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
235
+ if (block) {
236
+ return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
237
+ .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
238
+ .filter(Boolean)
239
+ }
240
+ return []
241
+ }
242
+
243
+ // 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
244
+ // 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
245
+ // "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
246
+ const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
247
+ // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
248
+ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|failed\s+in\s+\d)/i
249
+
250
+ /**
251
+ * 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
252
+ * @param {string} output
253
+ * @param {string[]} knownFailures 子串模式列表(大小写不敏感)
254
+ * @returns {{ failureLines: string[], exempted: string[], remaining: string[] }}
255
+ */
256
+ export function partitionFailures(output, knownFailures) {
257
+ const lines = String(output || '').split(/\r?\n/)
258
+ const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
259
+ const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
260
+ const exempted = []
261
+ const remaining = []
262
+ for (const l of failureLines) {
263
+ const ll = l.toLowerCase()
264
+ if (pats.some(p => ll.includes(p))) exempted.push(l)
265
+ else remaining.push(l)
266
+ }
267
+ return { failureLines, exempted, remaining }
268
+ }
269
+
270
+ /**
271
+ * 结合 known_failures 判定单次测试运行的状态(fail-safe)。
272
+ * - exitCode 0 → passed
273
+ * - exitCode≠0 且无 known_failures → failed(原行为)
274
+ * - exitCode≠0 且有 known_failures:
275
+ * 检测到失败行且全部命中豁免 → passed(披露:请人工复核清单是否过宽)
276
+ * 有未豁免失败行 / 检测不到失败行(保守)→ failed
277
+ * fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
278
+ *
279
+ * @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
280
+ */
281
+ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
282
+ if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
283
+ if (!knownFailures || knownFailures.length === 0) {
284
+ return { status: 'failed', reason: baseReason, exemptedCount: 0 }
285
+ }
286
+ const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
287
+ if (failureLines.length > 0 && remaining.length === 0) {
288
+ return {
289
+ status: 'passed',
290
+ reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
291
+ exemptedCount: exempted.length,
292
+ }
293
+ }
294
+ // 剩余未豁免失败行:列出具体行 + 预存债指引(坑 verify-known-failures-stale-list)。
295
+ // known_failures 清单常滞后于新预存测试 → 同源预存债漏入清单,agent 跑完才发现要手动补。
296
+ // 这里点出具体未命中行,让 agent 一眼分辨"补清单 vs 修代码",不替它判断(确定性:列已算出的 remaining)。
297
+ let detail
298
+ if (remaining.length > 0) {
299
+ const sample = remaining.slice(0, 5).map(l => {
300
+ const t = String(l).trim()
301
+ return t.length > 120 ? t.slice(0, 120) + '…' : t
302
+ })
303
+ const more = remaining.length > 5 ? `\n …(其余 ${remaining.length - 5} 行见上方测试输出)` : ''
304
+ detail = `${remaining.length} 个失败行未命中 known_failures 清单:\n - ${sample.join('\n - ')}${more}\n → 若是预存债(变更前就失败),加入 local.yaml 的 known_failures 清单;若是本次变更引入的真实失败,请修复`
305
+ } else {
306
+ detail = '失败输出未检测到可豁免的失败行(保守判 fail)'
307
+ }
308
+ return {
309
+ status: 'failed',
310
+ reason: baseReason ? `${baseReason}(${detail})` : detail,
311
+ exemptedCount: exempted.length,
312
+ }
313
+ }
314
+
315
+ /**
316
+ * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
317
+ * - module + 命中模块 → 'module-subset'
318
+ * - module + 0 命中 → 'module-zero-hit-skip'(不静默回退注定超时/预存失败的全量)
319
+ * - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
320
+ * @returns {'module-subset'|'module-zero-hit-skip'|'full'}
321
+ */
322
+ export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
323
+ if (strategy === 'module' && modulesPresent) {
324
+ if (hitCount > 0) return 'module-subset'
325
+ if (hitCount === 0) return 'module-zero-hit-skip'
326
+ return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
327
+ }
328
+ return 'full'
329
+ }
330
+
331
+ /**
332
+ * 跑单个模块的 test 命令(串行调用方逐个调用)。
333
+ * @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
334
+ */
335
+ function runOneModule(name, testCommand, cwd, knownFailures = []) {
336
+ const startedAt = Date.now()
337
+ let exitCode = 0
338
+ let output = ''
339
+ let reason = null
340
+ try {
341
+ output = execSync(testCommand, {
342
+ cwd,
343
+ encoding: 'utf8',
344
+ timeout: TEST_TIMEOUT_MS,
345
+ maxBuffer: 32 * 1024 * 1024,
346
+ stdio: ['ignore', 'pipe', 'pipe'],
347
+ })
348
+ } catch (e) {
349
+ exitCode = typeof e.status === 'number' ? e.status : 1
350
+ output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
351
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
352
+ ? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
353
+ : `模块 ${name} 测试退出码 ${exitCode}`
354
+ }
355
+ const durationMs = Date.now() - startedAt
356
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
357
+ const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
358
+ return {
359
+ name,
360
+ status: judged.status,
361
+ command: testCommand,
362
+ exitCode,
363
+ durationMs,
364
+ outputTail,
365
+ reason: judged.reason,
366
+ exemptedCount: judged.exemptedCount,
367
+ }
368
+ }
369
+
370
+ /**
371
+ * 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
372
+ * git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
373
+ */
374
+ function runGitDiffNameOnly(cwd, refSpec) {
375
+ try {
376
+ const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
377
+ const out = execSync(cmd, {
378
+ cwd,
379
+ encoding: 'utf8',
380
+ timeout: 30 * 1000,
381
+ stdio: ['ignore', 'pipe', 'pipe'],
382
+ })
383
+ return out.split('\n').map(l => l.trim()).filter(Boolean)
384
+ } catch {
385
+ return null
386
+ }
387
+ }
388
+
389
+ /**
390
+ * 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
391
+ * 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
392
+ */
393
+ function runGitDiffNameStatus(cwd, refSpec) {
394
+ try {
395
+ const cmd = refSpec ? `git diff --name-status ${refSpec}` : 'git diff --name-status'
396
+ return execSync(cmd, {
397
+ cwd,
398
+ encoding: 'utf8',
399
+ timeout: 30 * 1000,
400
+ stdio: ['ignore', 'pipe', 'pipe'],
401
+ })
402
+ } catch {
403
+ return null
404
+ }
405
+ }
406
+
407
+ /**
408
+ * 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
409
+ * `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
410
+ * 最适合 brownfield(apply 后未 commit / in-place 改动)场景。
411
+ * git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
412
+ *
413
+ * 注意:worktree 隔离模式下主仓只剩 .sillyspec/ 文档改动、代码在 worktree,
414
+ * 本函数看不到——runVerifyTestCheck 用 resolveVerifyChangedFiles 统一处理。
415
+ */
416
+ function gitChangedFiles(cwd) {
417
+ const head = runGitDiffNameOnly(cwd, 'HEAD')
418
+ if (head !== null) return head
419
+ // HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
420
+ return runGitDiffNameOnly(cwd, '')
421
+ }
422
+
423
+ /**
424
+ * 解析 verify 对账用的变更文件集(worktree-aware)。
425
+ *
426
+ * worktree 隔离模式下 execute 的代码改动落在 worktree,主仓工作区只剩
427
+ * .sillyspec/ 文档改动。若仍用主仓 `git diff --name-only HEAD`,hitCount=0
428
+ * → 回退注定超时且含预存失败的全量 commands.test → verify 完成被阻断
429
+ * (坑 verify-worktree-mode-test-reconciliation-fallback-full)。
430
+ *
431
+ * 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
432
+ * 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
433
+ * 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
434
+ * 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
435
+ *
436
+ * @param {string} cwd - 项目根目录(主仓)
437
+ * @param {string|null} changeName
438
+ * @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
439
+ */
440
+ export function resolveVerifyChangedFiles(cwd, changeName) {
441
+ if (changeName) {
442
+ const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
443
+ if (existsSync(metaPath)) {
444
+ let meta = null
445
+ try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
446
+ if (meta?.baseHash) {
447
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
448
+ ? meta.worktreePath
449
+ : cwd
450
+ const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
451
+ if (files !== null) return files
452
+ // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
453
+ }
454
+ }
455
+ }
456
+ return gitChangedFiles(cwd)
457
+ }
458
+
459
+ /**
460
+ * 执行 verify 实测:读取 local.yaml 配置,按 test_strategy 决定全量或模块子集。
461
+ *
462
+ * @param {object} opts
463
+ * @param {string} opts.cwd - 项目根目录(测试执行目录)
464
+ * @param {string} opts.specBase - .sillyspec(或平台 specRoot)目录
465
+ * @param {string|null} [opts.changeName]
466
+ * @returns {{
467
+ * status: 'passed'|'failed'|'skipped',
468
+ * command: string|null,
469
+ * exitCode: number|null,
470
+ * durationMs: number|null,
471
+ * outputTail: string|null,
472
+ * reason: string|null,
473
+ * resultPath: string|null,
474
+ * }}
475
+ */
476
+ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
477
+ const localYamlPath = join(specBase, 'local.yaml')
478
+ const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
479
+
480
+ const strategy = extractTestStrategy(yamlText)
481
+ const knownFailures = extractKnownFailures(yamlText)
482
+
483
+ // —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
484
+ // resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
485
+ let modulesPresent = false
486
+ let hitCount = 0
487
+ let hits = []
488
+ if (strategy === 'module') {
489
+ const modules = extractModules(yamlText)
490
+ if (modules) {
491
+ modulesPresent = true
492
+ const changedFiles = resolveVerifyChangedFiles(cwd, changeName)
493
+ if (changedFiles === null) {
494
+ hitCount = -1 // git 不可用 / 非仓库
495
+ } else {
496
+ hits = pickHitModules(changedFiles, modules)
497
+ hitCount = hits.length
498
+ }
499
+ }
500
+ }
501
+
502
+ const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
503
+ if (action === 'module-subset') {
504
+ return runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
505
+ }
506
+ if (action === 'module-zero-hit-skip') {
507
+ // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
508
+ // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
509
+ return {
510
+ status: 'skipped',
511
+ command: null,
512
+ exitCode: null,
513
+ durationMs: null,
514
+ outputTail: null,
515
+ reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。',
516
+ resultPath: null,
517
+ mode: 'module-zero-hit',
518
+ fallbackReason: null,
519
+ }
520
+ }
521
+
522
+ // —— 全量路径(full / module 无块 / module git 不可用)——
523
+ // fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
524
+ const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
525
+ return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
526
+ }
527
+
528
+ /**
529
+ * 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
530
+ */
531
+ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
532
+ const command = extractTestCommand(yamlText)
533
+
534
+ if (!command) {
535
+ return {
536
+ status: 'skipped',
537
+ command: null,
538
+ exitCode: null,
539
+ durationMs: null,
540
+ outputTail: null,
541
+ reason: yamlText
542
+ ? 'local.yaml 未配置 commands.test(或标记 unavailable)'
543
+ : `local.yaml 不存在(${localYamlPath})`,
544
+ resultPath: null,
545
+ mode: 'full',
546
+ fallbackReason,
547
+ }
548
+ }
549
+
550
+ const startedAt = Date.now()
551
+ let exitCode = 0
552
+ let output = ''
553
+ let reason = null
554
+ try {
555
+ output = execSync(command, {
556
+ cwd,
557
+ encoding: 'utf8',
558
+ timeout: TEST_TIMEOUT_MS,
559
+ maxBuffer: 32 * 1024 * 1024,
560
+ stdio: ['ignore', 'pipe', 'pipe'],
561
+ })
562
+ } catch (e) {
563
+ exitCode = typeof e.status === 'number' ? e.status : 1
564
+ output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
565
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
566
+ ? `测试命令超时(>${TEST_TIMEOUT_MS / 1000}s)`
567
+ : `测试命令退出码 ${exitCode}`
568
+ }
569
+ const durationMs = Date.now() - startedAt
570
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
571
+
572
+ const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
573
+ const result = {
574
+ status: judged.status,
575
+ command,
576
+ exitCode,
577
+ durationMs,
578
+ outputTail,
579
+ reason: judged.reason,
580
+ resultPath: null,
581
+ mode: 'full',
582
+ fallbackReason,
583
+ exemptedCount: judged.exemptedCount,
584
+ }
585
+
586
+ writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
587
+ return result
588
+ }
589
+
590
+ /**
591
+ * 串行跑命中的模块子集,聚合结果。
592
+ * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
593
+ */
594
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
595
+ const subsetStartedAt = Date.now()
596
+ const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
597
+ const status = aggregateStatus(perModule)
598
+
599
+ const command = `module[${hits.map(h => h.name).join(',')}]`
600
+ const exitCode = status === 'passed' ? 0 : 1
601
+ const durationMs = Date.now() - subsetStartedAt
602
+
603
+ // 合并各模块输出尾部(标注模块名)
604
+ const outputTail = perModule
605
+ .map(r => `── module ${r.name} (${r.status}) ──\n${r.outputTail || ''}`)
606
+ .join('\n')
607
+ const reason = status === 'passed'
608
+ ? null
609
+ : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
610
+
611
+ const result = {
612
+ status,
613
+ command,
614
+ exitCode,
615
+ durationMs,
616
+ outputTail: outputTail.length > OUTPUT_TAIL_CHARS ? '…' + outputTail.slice(-OUTPUT_TAIL_CHARS) : outputTail,
617
+ reason,
618
+ resultPath: null,
619
+ mode: 'module-subset',
620
+ fallbackReason: null,
621
+ exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
622
+ }
623
+
624
+ writeRunResult({
625
+ specBase,
626
+ changeName,
627
+ result,
628
+ extra: {
629
+ modules: perModule.map(r => ({
630
+ name: r.name,
631
+ command: r.command,
632
+ exit_code: r.exitCode,
633
+ status: r.status,
634
+ duration_ms: r.durationMs,
635
+ output_tail: r.outputTail,
636
+ reason: r.reason,
637
+ })),
638
+ },
639
+ })
640
+ return result
641
+ }
642
+
643
+ /**
644
+ * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
645
+ * 多模块时 extra.modules 描述各模块明细。
646
+ */
647
+ function writeRunResult({ specBase, changeName, result, extra = {} }) {
648
+ try {
649
+ const ts = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '')
650
+ const runDir = join(specBase, '.runtime', 'verify-runs', ts)
651
+ mkdirSync(runDir, { recursive: true })
652
+ const resultPath = join(runDir, 'test-result.json')
653
+ writeFileSync(resultPath, JSON.stringify({
654
+ change: changeName,
655
+ command: result.command,
656
+ exit_code: result.exitCode,
657
+ status: result.status,
658
+ duration_ms: result.durationMs,
659
+ output_tail: result.outputTail,
660
+ reason: result.reason,
661
+ ran_at: new Date().toISOString(),
662
+ ...extra,
663
+ }, null, 2) + '\n')
664
+ result.resultPath = resultPath
665
+ } catch (e) {
666
+ console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
667
+ }
668
+ }
669
+
670
+ /** 打印实测结果(人类/agent 可读) */
671
+ export function printVerifyTestCheck(result) {
672
+ if (result.status === 'skipped') {
673
+ console.warn(`\n⚠️ Verify 实测跳过:${result.reason}`)
674
+ console.warn(' 建议在 local.yaml 的 commands.test 配置真实测试命令,让 CLI 可客观对账。')
675
+ return
676
+ }
677
+ if (result.status === 'passed') {
678
+ if (result.exemptedCount > 0) {
679
+ console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
680
+ console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
681
+ } else {
682
+ console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
683
+ }
684
+ } else {
685
+ console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
686
+ if (result.outputTail) {
687
+ const tail = result.outputTail.split('\n').slice(-20).join('\n')
688
+ console.error(' 输出(末尾):')
689
+ for (const line of tail.split('\n')) console.error(` | ${line}`)
690
+ }
691
+ }
692
+ // —— 全量 fallback 明示(skipped 已提前 return,此处仅 passed/failed)——
693
+ // 让 agent 知道本次跑的是全量 commands.test、非变更范围子集;失败可能含未变更模块
694
+ // 的预存错误,需先核对用例归属再归因到本次变更(见 3.24 verify 坑1)。
695
+ if (result.mode === 'full' && result.fallbackReason) {
696
+ if (result.status === 'failed') {
697
+ console.warn(` ⚠️ 本次跑的是 commands.test 全量(${result.fallbackReason})。`)
698
+ console.warn(' 失败可能含与本变更无关的预存错误——先核对失败用例是否属于你的变更范围;')
699
+ console.warn(' 或在 local.yaml 配置 test_strategy: module + modules: 块以收窄到变更模块。')
700
+ } else {
701
+ console.warn(` ⚠️ 本次跑的是 commands.test 全量(${result.fallbackReason})。`)
702
+ console.warn(' 如耗时过长,可在 local.yaml 配置 test_strategy: module + modules: 块按模块收窄。')
703
+ }
704
+ }
705
+ if (result.resultPath) {
706
+ console.log(`📄 实测结果已写入: ${result.resultPath}`)
707
+ }
708
+ }
709
+
710
+ /**
711
+ * API parity 对账(advisory):前端调用 vs execute 提取的后端 endpoint artifact。
712
+ * 三态:skipped(无 provider artifact → 非全栈项目不打扰)/ warning(missingBackend>0)/ passed。
713
+ * 启发式正则、多假阳源 → advisory 不阻断 verify 完成(与 SillySpec 确定性校验定位一致)。
714
+ *
715
+ * @param {object} opts
716
+ * @param {string} opts.cwd - 扫描前端调用的根(主工作区,verify 时代码在此)
717
+ * @param {string} opts.specBase
718
+ * @param {string|null} [opts.changeName]
719
+ * @param {string|null} [opts.runtimeRoot]
720
+ * @returns {{ status: 'skipped'|'warning'|'passed', missingBackend: Array, unusedBackend: Array, summary: string, reason: string|null }}
721
+ */
722
+ export function runVerifyParityCheck({ cwd, specBase, changeName = null, runtimeRoot = null }) {
723
+ const r = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
724
+ // 无 provider artifact(execute 未提取 / 非后端项目)→ 不打扰
725
+ if (r.backendCount === 0) {
726
+ return { status: 'skipped', missingBackend: [], unusedBackend: [], summary: r.summary, reason: '无后端契约 artifact(非全栈项目或 execute 未提取端点)' }
727
+ }
728
+ if (r.missingBackend.length > 0) {
729
+ return { status: 'warning', missingBackend: r.missingBackend, unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
730
+ }
731
+ return { status: 'passed', missingBackend: [], unusedBackend: r.unusedBackend, summary: r.summary, reason: null }
732
+ }
733
+
734
+ /** 打印 parity 对账结果(advisory,不阻断) */
735
+ export function printVerifyParityCheck(result) {
736
+ if (result.status === 'skipped') return // 静默,不打扰非全栈项目
737
+ if (result.status === 'passed') {
738
+ console.log(`\n✅ API parity 对账通过:${result.summary}`)
739
+ return
740
+ }
741
+ // warning
742
+ console.warn(`\n⚠️ API parity 对账发现 ${result.missingBackend.length} 个前端调用无对应后端端点(advisory,不阻断归档):`)
743
+ for (const m of result.missingBackend.slice(0, 20)) {
744
+ console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
745
+ }
746
+ if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
747
+ console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
748
+ }
749
+
750
+ /**
751
+ * 删除探针(advisory):用 git 事实客观对账本次变更删除的文件 vs design.md 声明的操作。
752
+ *
753
+ * 切斯特顿栅栏护栏——verify 对「agent 静默删除代码」本是盲区:agent 删一段它看不懂的
754
+ * 旧代码,只要路径合规、不碰风险关键词,5 探针 + 风险分级 + 测试对账全都不会响。本探针
755
+ * 不信任 agent 自报告,用 `git diff --name-status HEAD` 客观提取删除文件,对账 design
756
+ * 清单声明的操作(声明「新增/修改」却整文件删除 = 高风险)。warning 不阻断 verify 完成
757
+ * (advisory 起步)——「检测到删除」是确定性事实(做),「该不该删」是意图(只报不拦)。
758
+ *
759
+ * 信号源:apply(git apply --3way)不 commit,verify 时主仓 HEAD 仍是变更前 commit,
760
+ * 删除的文件在工作树消失但仍在 HEAD → `git diff --name-status HEAD` 显示 D。
761
+ *
762
+ * @param {object} opts
763
+ * @param {string} opts.cwd - 项目根(主仓,代码在此)
764
+ * @param {string} opts.specBase - .sillyspec(或平台 specRoot)
765
+ * @param {string|null} [opts.changeName]
766
+ * @returns {{
767
+ * status: 'skipped'|'warning'|'passed',
768
+ * highRisk: Array<{path:string, declaredOp:string, reason:string}>,
769
+ * mediumRisk: Array<{path:string, reason:string}>,
770
+ * compliant: Array<{path:string, declaredOp:string}>,
771
+ * summary: string,
772
+ * reason: string|null,
773
+ * }}
774
+ */
775
+ export function runVerifyDeletionCheck({ cwd, specBase, changeName = null }) {
776
+ const statusRaw = runGitDiffNameStatus(cwd, 'HEAD')
777
+ if (statusRaw === null) {
778
+ return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
779
+ summary: '', reason: 'git 不可用或非仓库,删除对账跳过' }
780
+ }
781
+
782
+ // 解析 D(删除);R/C 的旧路径等价删除(防御 committed rename;标准 apply 流程
783
+ // rename 表现为纯 D,故本探针不加 -M,D 统一处理)。
784
+ const deletions = []
785
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
786
+ const parts = line.split('\t')
787
+ const st = parts[0]
788
+ if (!st) continue
789
+ if (st.startsWith('D')) {
790
+ deletions.push({ path: parts[1], kind: 'D' })
791
+ } else if ((st.startsWith('R') || st.startsWith('C')) && parts.length >= 3) {
792
+ // rename/copy 旧路径(parts[1])等价删除,新路径 parts[2]
793
+ deletions.push({ path: parts[1], kind: 'R-old', renamedTo: parts[2] })
794
+ }
795
+ }
796
+
797
+ // 排除交付物外文件(.sillyspec/ 变更包/运行时/quicklog + meta.json),避免污染删除信号。
798
+ // 复用 worktree-apply.js 的 filterDeliverableFiles 去双写(坑3:保留 .sillyspec/docs/)。
799
+ const deliverable = filterDeliverableFiles(deletions.map(d => d.path))
800
+ .map(p => deletions.find(d => d.path === p))
801
+
802
+ if (deliverable.length === 0) {
803
+ return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
804
+ summary: '', reason: '本次变更无文件删除(或改动已被 commit,主仓 HEAD 已推进,删除对账无锚点)' }
805
+ }
806
+
807
+ // 读 design 声明(operation)。无清单章节 → [],所有删除归「未声明」。
808
+ const designPath = changeName
809
+ ? join(specBase, 'changes', changeName, 'design.md') : null
810
+ const designEntries = designPath && existsSync(designPath)
811
+ ? parseFileChangeListDetailed(designPath) : []
812
+
813
+ const highRisk = []
814
+ const mediumRisk = []
815
+ const compliant = []
816
+ for (const d of deliverable) {
817
+ const hit = designEntries.find(e => pathMatches(d.path, e.path))
818
+ if (!hit) {
819
+ mediumRisk.push({ path: d.path, reason: d.kind === 'R-old'
820
+ ? `重命名源文件未在 design 清单(→ ${d.renamedTo})`
821
+ : 'design 清单未列出该删除文件' })
822
+ continue
823
+ }
824
+ if (hit.operation === '新增' || hit.operation === '修改') {
825
+ highRisk.push({ path: d.path, declaredOp: hit.operation,
826
+ reason: `design 声明「${hit.operation}」但 git 显示整文件删除` })
827
+ } else if (hit.operation === '删除') {
828
+ compliant.push({ path: d.path, declaredOp: '删除' })
829
+ } else {
830
+ // operation=null(未声明操作)或 重命名:删除发生但声明不明确
831
+ mediumRisk.push({ path: d.path,
832
+ reason: `design 列出但操作为「${hit.operation || '未声明'}」,与删除不一致` })
833
+ }
834
+ }
835
+
836
+ if (highRisk.length > 0 || mediumRisk.length > 0) {
837
+ return { status: 'warning', highRisk, mediumRisk, compliant,
838
+ summary: `${highRisk.length} 个高风险删除 + ${mediumRisk.length} 个未声明删除`, reason: null }
839
+ }
840
+ return { status: 'passed', highRisk: [], mediumRisk: [], compliant,
841
+ summary: `所有 ${compliant.length} 个删除均在 design 声明为「删除」`, reason: null }
842
+ }
843
+
844
+ /** 打印删除对账结果(advisory,不阻断 verify 完成) */
845
+ export function printVerifyDeletionCheck(result) {
846
+ if (result.status === 'skipped') return // 静默:无删除 / git 不可用 / 改动已 commit
847
+ if (result.status === 'passed') {
848
+ console.log(`\n✅ 删除对账通过:${result.summary}`)
849
+ return
850
+ }
851
+ // warning
852
+ if (result.highRisk.length > 0) {
853
+ console.warn(`\n⚠️ 删除对账发现 ${result.highRisk.length} 个高风险删除(design 声明新增/修改却被整文件删除,advisory 不阻断):`)
854
+ for (const m of result.highRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
855
+ if (result.highRisk.length > 20) console.warn(` …还有 ${result.highRisk.length - 20} 个`)
856
+ }
857
+ if (result.mediumRisk.length > 0) {
858
+ console.warn(`⚠️ ${result.mediumRisk.length} 个未声明删除(design 清单未列出 / 操作不一致):`)
859
+ for (const m of result.mediumRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
860
+ if (result.mediumRisk.length > 20) console.warn(` …还有 ${result.mediumRisk.length - 20} 个`)
861
+ }
862
+ console.warn(' 提示:确认删除是否预期。预期删除请在 design.md 清单用「删除」操作显式声明;误删请恢复。')
863
+ }
864
+
865
+ /**
866
+ * required-evidence 对账(advisory,不阻断 verify 完成)。
867
+ *
868
+ * execute 阶段 Task Review Gate 把 cannot_verify 任务的 requiredEvidence 写入
869
+ * changes/<change>/verify-required-evidence.json(schema: {generatedAt, schemaVersion, items:[{task, verdict, evidence:string[]}]})。
870
+ * 历史"死链"(sss1.md 矛盾2):只写不读——verify agent 不在 verify-result.md 体现也不会被发现,
871
+ * 且 verify.js prompt 让 agent 读 `requiredEvidence` 键(字段名错配,实际是 items[].evidence),照做必落空。
872
+ * 本探针闭合该链:读 evidence 文件,对每个 cannot_verify 任务检查 verify-result.md 是否提及该任务 id;
873
+ * 未提及 → warning(advisory)。CLI 只做"任务被提及"的机械存在性检查,evidence 是否真正满足
874
+ * (satisfied/missing/partial)由 agent 在 verify-result.md 诚实自报告——不假装语义判定(与删除探针同 altitude)。
875
+ *
876
+ * @param {{cwd:string, specBase:string, changeName?:string}} args
877
+ * @returns {{status:'skipped'|'passed'|'warning', items:Array, unacknowledged:Array, summary:string, reason:string|null}}
878
+ */
879
+ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = null }) {
880
+ if (!changeName) {
881
+ return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 changeName(quick 等无关联变更场景),evidence 对账跳过' }
882
+ }
883
+ const evidencePath = join(specBase, 'changes', changeName, 'verify-required-evidence.json')
884
+ if (!existsSync(evidencePath)) {
885
+ return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: '无 verify-required-evidence.json(execute 无 cannot_verify 任务),evidence 对账跳过' }
886
+ }
887
+
888
+ let data
889
+ try {
890
+ data = JSON.parse(readFileSync(evidencePath, 'utf8'))
891
+ } catch (e) {
892
+ return { status: 'skipped', items: [], unacknowledged: [], summary: '', reason: `verify-required-evidence.json 解析失败(${e.message}),evidence 对账跳过` }
893
+ }
894
+ const items = Array.isArray(data.items) ? data.items : []
895
+
896
+ // 读 verify-result.md(不存在则所有 evidence 任务都"未体现")
897
+ const verifyResultPath = join(specBase, 'changes', changeName, 'verify-result.md')
898
+ const report = existsSync(verifyResultPath) ? readFileSync(verifyResultPath, 'utf8') : ''
899
+
900
+ const unacknowledged = []
901
+ for (const item of items) {
902
+ const task = item && item.task
903
+ if (!task) continue
904
+ // 机械存在性检查:verify-result.md 是否提及该 cannot_verify 任务 id。
905
+ // evidence 数组的具体满足度(satisfied/missing/partial)由 agent 自报告,CLI 不判定。
906
+ if (!report.includes(task)) {
907
+ const evidenceCount = Array.isArray(item.evidence) ? item.evidence.length : 0
908
+ unacknowledged.push({ task, verdict: item.verdict || 'cannot_verify', evidenceCount,
909
+ reason: `cannot_verify 任务 ${task} 未在 verify-result.md 中体现(需逐条 evidence 给结论 satisfied/missing/partial)` })
910
+ }
911
+ }
912
+
913
+ if (unacknowledged.length > 0) {
914
+ return { status: 'warning', items, unacknowledged,
915
+ summary: `${items.length} 个 cannot_verify evidence 任务中 ${unacknowledged.length} 个未在 verify-result.md 体现`, reason: null }
916
+ }
917
+ return { status: 'passed', items, unacknowledged: [],
918
+ summary: `${items.length} 个 cannot_verify evidence 任务均在 verify-result.md 体现(满足度由 agent 自报告)`, reason: null }
919
+ }
920
+
921
+ /** 打印 required-evidence 对账结果(advisory,不阻断 verify 完成) */
922
+ export function printVerifyRequiredEvidenceCheck(result) {
923
+ if (result.status === 'skipped') return // 静默:无 evidence 文件 / 无 changeName / 解析失败
924
+ if (result.status === 'passed') {
925
+ console.log(`\n✅ required-evidence 对账通过:${result.summary}`)
926
+ return
927
+ }
928
+ // warning
929
+ console.warn(`\n⚠️ required-evidence 对账发现 ${result.unacknowledged.length} 个 cannot_verify 任务未在 verify-result.md 体现(advisory 不阻断):`)
930
+ for (const u of result.unacknowledged.slice(0, 20)) console.warn(` - ${u.task} (${u.reason})`)
931
+ if (result.unacknowledged.length > 20) console.warn(` …还有 ${result.unacknowledged.length - 20} 个`)
932
+ console.warn(' 提示:execute 标记的 cannot_verify 任务需在 verify-result.md 逐条给 evidence 结论(satisfied/missing/partial)。CLI 仅查任务被提及,是否真满足由你诚实判定。')
933
+ }