sillyspec 3.29.4 → 3.30.0

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 (148) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +18 -116
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-archive.md +26 -0
  10. package/assets/command-cards/run-brainstorm.md +33 -0
  11. package/assets/command-cards/run-execute.md +29 -0
  12. package/assets/command-cards/run-plan.md +27 -0
  13. package/assets/command-cards/run-quick.md +17 -0
  14. package/assets/command-cards/run-verify.md +29 -0
  15. package/assets/command-cards/status.md +27 -0
  16. package/package.json +2 -1
  17. package/packages/dashboard/server/executor.js +2 -1
  18. package/packages/dashboard/server/parser.js +7 -7
  19. package/r7-test-env/README-R7-TEST.md +93 -0
  20. package/r7-test-env/demo-project/lib/calc.js +6 -0
  21. package/r7-test-env/demo-project/package.json +1 -0
  22. package/r7-test-env/demo-project/test.js +10 -0
  23. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  24. package/r7-test-env/sillyspec-r7.cmd +3 -0
  25. package/round4/account.mjs +98 -0
  26. package/round4/b1-analyze-task08.mjs +40 -0
  27. package/round4/b1-b1-target-validation.md +32 -0
  28. package/round4/b3a-prompt-audit-result.md +66 -0
  29. package/round4/b3a-prompt-audit.mjs +89 -0
  30. package/round4/brief-autocompact.md +34 -0
  31. package/round4/brief-session-replay.md +50 -0
  32. package/round4/deep-read-synthesis.md +38 -0
  33. package/round4/extract-full.mjs +38 -0
  34. package/round4/extract-timeline.mjs +43 -0
  35. package/round4/find-sessions.mjs +13 -0
  36. package/round4/full-r4-l.md +4707 -0
  37. package/round4/full-r4-o-l.md +4300 -0
  38. package/round4/full-r4-os.md +1681 -0
  39. package/round4/full-r4-sf.md +4255 -0
  40. package/round4/full-r4-sq.md +1734 -0
  41. package/round4/gate-audit-relay-receipt.md +30 -0
  42. package/round4/gate-value-audit.md +52 -0
  43. package/round4/optimization-plan.md +165 -0
  44. package/round4/prereg.md +67 -0
  45. package/round4/prompt-R4-L.md +63 -0
  46. package/round4/prompt-R4-O-L.md +59 -0
  47. package/round4/prompt-R4-O-S.md +56 -0
  48. package/round4/prompt-R4-S-F.md +60 -0
  49. package/round4/prompt-R4-S-Q.md +66 -0
  50. package/round4/r4-final-report.html +347 -0
  51. package/round4/r4-os-timeline.txt +297 -0
  52. package/round4/r4-sf-timeline.txt +738 -0
  53. package/round4/r4-sq-timeline.txt +322 -0
  54. package/round4/r4l-timeline.txt +793 -0
  55. package/round4/r4ol-timeline.txt +729 -0
  56. package/round4/r4sf-timeline.txt +852 -0
  57. package/round4/r5-scenario-matrix.md +46 -0
  58. package/round4/result-S.md +171 -0
  59. package/round4/result-final.md +46 -0
  60. package/round4/result-quality.md +63 -0
  61. package/src/change-list.js +9 -0
  62. package/src/classify-change.js +6 -0
  63. package/src/command-cards.js +74 -0
  64. package/src/commit-guard.js +2 -0
  65. package/src/config-schema.js +64 -12
  66. package/src/contract-matrix.js +26 -5
  67. package/src/db.js +35 -4
  68. package/src/decision-distill.js +9 -3
  69. package/src/diagnostic-codes.js +2 -1
  70. package/src/docs-check.js +1 -1
  71. package/src/doctor-diagnostics.js +44 -2
  72. package/src/flow-draft.js +748 -0
  73. package/src/flow-parity.js +236 -0
  74. package/src/flow-review.js +184 -0
  75. package/src/flow.js +1492 -0
  76. package/src/foreign-declared.js +17 -1
  77. package/src/fr-index.js +396 -22
  78. package/src/fs-atomic.js +5 -1
  79. package/src/git-helper.js +2 -2
  80. package/src/greenfield-bootstrap.js +98 -0
  81. package/src/handoff.js +120 -7
  82. package/src/hunk-attribution.js +191 -0
  83. package/src/index.js +602 -21
  84. package/src/init.js +25 -8
  85. package/src/knowledge-classify.js +47 -1
  86. package/src/knowledge-digest.js +162 -0
  87. package/src/knowledge-quicklog.js +246 -0
  88. package/src/machine-draft.js +128 -0
  89. package/src/machine-interface.js +194 -7
  90. package/src/mcp-server.js +1 -0
  91. package/src/module-impact.js +51 -22
  92. package/src/module-resolve.js +3 -1
  93. package/src/preview-progress.js +160 -0
  94. package/src/progress/change-registry.js +11 -3
  95. package/src/progress/shared.js +3 -2
  96. package/src/progress.js +56 -13
  97. package/src/quicklog.js +44 -6
  98. package/src/redomain.js +147 -0
  99. package/src/review-material-pack.js +204 -1
  100. package/src/run/archive-distill.js +4 -2
  101. package/src/run/bg-sync.js +67 -0
  102. package/src/run/command.js +127 -10
  103. package/src/run/complete-handlers.js +363 -73
  104. package/src/run/complete.js +356 -74
  105. package/src/run/gate-snapshot-ledger.js +213 -0
  106. package/src/run/gate-snapshot.js +260 -38
  107. package/src/run/gates.js +482 -268
  108. package/src/run/green-cache.js +123 -0
  109. package/src/run/junction-rm.js +70 -0
  110. package/src/run/next.js +14 -0
  111. package/src/run/prompt.js +185 -55
  112. package/src/run/quick-audit.js +213 -14
  113. package/src/run/shared.js +123 -4
  114. package/src/run/stage.js +182 -216
  115. package/src/run/test-ledger.js +264 -0
  116. package/src/run/verify-quality-scan.js +84 -7
  117. package/src/scope-audit.js +6 -1
  118. package/src/sentinel-assertions.js +114 -0
  119. package/src/setup.js +3 -3
  120. package/src/spec-sync.js +41 -1
  121. package/src/stage-contract.js +26 -8
  122. package/src/stage-review-checklist.js +2 -0
  123. package/src/stage-review.js +7 -2
  124. package/src/stage-templates.js +157 -0
  125. package/src/stages/archive.js +2 -0
  126. package/src/stages/brainstorm.js +24 -106
  127. package/src/stages/execute.js +222 -85
  128. package/src/stages/knowledge.js +74 -1
  129. package/src/stages/plan-postcheck.js +385 -20
  130. package/src/stages/plan.js +23 -1
  131. package/src/stages/quick.js +10 -2
  132. package/src/stages/verify.js +4 -2
  133. package/src/sync.js +81 -23
  134. package/src/task-done.js +181 -0
  135. package/src/task-review.js +8 -55
  136. package/src/taskcard-frontmatter.js +57 -0
  137. package/src/test-bindings.js +549 -0
  138. package/src/timeline.js +218 -0
  139. package/src/ui-visual.js +175 -0
  140. package/src/verify-draft.js +214 -0
  141. package/src/verify-postcheck.js +919 -159
  142. package/src/verify-probes.js +103 -25
  143. package/src/watcher.js +1031 -0
  144. package/src/worktree-apply.js +44 -4
  145. package/src/worktree-deps.js +22 -8
  146. package/src/worktree-policy.js +42 -0
  147. package/src/worktree.js +102 -15
  148. package/templates/agents-instruction.md +37 -18
@@ -28,7 +28,9 @@ import { IR_STRICT_SINCE } from './constants.js'
28
28
  import { gitQuiet } from './git-helper.js'
29
29
  import { resolveRuntimeRoot } from './run/shared.js'
30
30
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
31
- import { join } from 'path'
31
+ import { join, resolve } from 'path'
32
+ import { readChangeTrace, testAnchorFile } from './test-bindings.js'
33
+ import { collectFrLinkedTests } from './fr-index.js'
32
34
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
33
35
  import { reconcileCrossRepoDeclarations } from './cross-repo-reconcile.js'
34
36
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
@@ -56,6 +58,19 @@ import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.
56
58
 
57
59
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
58
60
  const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
61
+
62
+ /**
63
+ * 测试超时配置链(R9 实证 2026-09-23):local.yaml commands.test_timeout_sec > env
64
+ * SILLYSPEC_TEST_TIMEOUT_MS > 缺省 600s。全量套件实测可达 27min(change-events-r9 后端
65
+ * 8181 例 27:03),固定帽必杀——慢仓在 commands: 块配 test_timeout_sec。纯函数 export 供
66
+ * 测试与 runFullCommand 共用(yamlText 传调用侧同源文本)。
67
+ */
68
+ export function resolveTestTimeoutMs(yamlText, env = process.env) {
69
+ const m = String(yamlText || '').match(/^\s*test_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
70
+ if (m && Number(m[1]) > 0) return Number(m[1]) * 1000
71
+ const e = Number(env.SILLYSPEC_TEST_TIMEOUT_MS)
72
+ return Number.isFinite(e) && e > 0 ? e * 1000 : TEST_TIMEOUT_MS
73
+ }
59
74
  const OUTPUT_TAIL_CHARS = 4000
60
75
  // 判账失败行台账上限(坑 verify-test-reconcile-tail-blindspot):对账按完整输出判、tail 只存
61
76
  // 末 4000 字符 → 失败行落在盲区时只剩 reason 里 5 行×120 字符采样,归因必须全量复跑。修法是
@@ -64,6 +79,30 @@ const OUTPUT_TAIL_CHARS = 4000
64
79
  const FAILURE_LEDGER_MAX_LINES = 200
65
80
  const FAILURE_LEDGER_MAX_CHARS = 300
66
81
 
82
+ // ── 子进程输出解码(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,2026-09-23)──
83
+ // zh-Windows 的 cmd 报错('X' 不是内部或外部命令…)按控制台代码页(GBK/CP936)输出,
84
+ // execSync encoding:'utf8' 直接解出乱码——CNF 签名匹配失效 + 门禁失败输出不可读(坑文档
85
+ // 原话「乱码一行难归因」)。修法:buffer 捕获 + 本函数智能解码——utf8 无损(零 U+FFFD)
86
+ // 优先原样返回;含替换符时试 GBK 解码取更优(Node full-icu 内置,无则维持 utf8 口径)。
87
+ // ASCII/合法 UTF-8(vitest/pytest 等)输出与旧口径逐字节一致,零行为变化。
88
+ let _gbkDecoder = null
89
+ export function decodeShellOutput(buf) {
90
+ if (buf == null) return ''
91
+ if (typeof buf === 'string') return buf
92
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
93
+ if (b.length === 0) return ''
94
+ const utf8 = b.toString('utf8')
95
+ const utf8Bad = (utf8.match(/\ufffd/g) || []).length
96
+ if (utf8Bad === 0) return utf8
97
+ try {
98
+ if (!_gbkDecoder) _gbkDecoder = new TextDecoder('gbk')
99
+ const gbk = _gbkDecoder.decode(b)
100
+ const gbkBad = (gbk.match(/\ufffd/g) || []).length
101
+ if (gbkBad < utf8Bad) return gbk
102
+ } catch { /* 无 full-icu(小体积构建)→ 维持 utf8 口径,CNF 兜底签名接手 */ }
103
+ return utf8
104
+ }
105
+
67
106
  /** 失败行台账截断(落盘/展示用;判账本身不受影响)。export 供 test 验证截断语义 */
68
107
  export function capFailureLedger(lines) {
69
108
  if (!Array.isArray(lines) || lines.length === 0) return []
@@ -135,6 +174,44 @@ function extractLintCommand(yamlText) {
135
174
  return null
136
175
  }
137
176
 
177
+ /**
178
+ * lint 失败输出中提及的文件路径提取(lint 归属鉴定·R4-S-F 实证:verify 侧 lint 硬门 7 轮
179
+ * 全是 HEAD 存量债——快照含基线债文件时恒败,逼 agent 范围外清偿或逃生口)。
180
+ * 口径:按空白切 token,剥引号/行号尾缀(ruff/eslint 的 path:line:col 形态),保留带路径
181
+ * 分隔符且以代码扩展名结尾的 token(不带分隔符的普通单词不收,防误报)。去重保序。
182
+ */
183
+ export function extractLintFailureFiles(output) {
184
+ if (!output || typeof output !== 'string') return []
185
+ const LINT_FILE_EXT_RE = /\.(?:js|mjs|cjs|ts|tsx|jsx|vue|svelte|py|pyw|go|rs|java|kt|kts|rb|php|cs|c|h|cpp|cc|hpp|swift|scala|css|scss)$/
186
+ const seen = new Set()
187
+ for (const raw of output.split(/\s+/)) {
188
+ let t = raw.replace(/^["'`(\[]+/, '').replace(/["'`),;]+$/, '')
189
+ t = t.replace(/:(?:\d+)(?::\d+)*$/, '') // 剥 :line / :line:col
190
+ if (!LINT_FILE_EXT_RE.test(t)) continue
191
+ if (!/[\\/]/.test(t)) continue
192
+ const norm = t.replace(/\\/g, '/').replace(/^\.\//, '')
193
+ if (norm.length > 1) seen.add(norm)
194
+ }
195
+ return [...seen]
196
+ }
197
+
198
+ /**
199
+ * lint 失败归属判定(纯函数):失败输出提及的文件 × 本变更文件集。
200
+ * - owned:有交集 → 失败可能由本变更引入 → 维持硬拦(保守)。
201
+ * - pre-existing:零交集 → 全是 HEAD 存量债 → 调用方降档 advisory(债不是本变更的错)。
202
+ * - unattributable:失败文件提取为空(输出无可识别路径,如纯配置错误)→ 维持硬拦(无法
203
+ * 排除本变更责任,宁可拦不可漏)。
204
+ * 匹配宽松双向 endsWith(输出可能是绝对路径/仓相对路径,两侧口径不一定同源)。
205
+ */
206
+ export function triageLintOwnership({ failureFiles = [], changeFiles = [] } = {}) {
207
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
208
+ const ffs = (Array.isArray(failureFiles) ? failureFiles : []).map(norm).filter(Boolean)
209
+ if (ffs.length === 0) return { verdict: 'unattributable', overlap: [] }
210
+ const cfs = (Array.isArray(changeFiles) ? changeFiles : []).map(norm).filter(Boolean)
211
+ const overlap = ffs.filter(f => cfs.some(c => c === f || c.endsWith('/' + f) || f.endsWith('/' + c)))
212
+ return overlap.length > 0 ? { verdict: 'owned', overlap } : { verdict: 'pre-existing', overlap: [] }
213
+ }
214
+
138
215
  /**
139
216
  * lint advisory 观察期计数(刀③,2026-09-08):advisory 失败不阻断,但「观察期后升级硬门」
140
217
  * 需要失败率数据支撑(更硬 vs 更吵的决策依据——硬门误伤纯文档/跨平台路径会制造新噪音)。
@@ -191,32 +268,53 @@ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
191
268
  }
192
269
 
193
270
  // timeoutMs(2026-09-12 驾驭第十七批 dogfood):门禁快照内 node_modules 经 junction I/O 较慢,
194
- // 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 > env > 默认
195
- const LINT_TIMEOUT_MS = timeoutMs || Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
271
+ // 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 >
272
+ // local.yaml lint_timeout_sec(R9 后补,2026-09-23)> env > 默认
273
+ const yamlLintTimeoutMs = (() => {
274
+ const m = String(yamlText || '').match(/^\s*lint_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
275
+ return m && Number(m[1]) > 0 ? Number(m[1]) * 1000 : 0
276
+ })()
277
+ const LINT_TIMEOUT_MS = timeoutMs || yamlLintTimeoutMs || Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
196
278
  const startedAt = Date.now()
197
279
  let exitCode = 0
198
280
  let output = ''
199
281
  let reason = null
200
282
  try {
201
- output = execSync(command, {
283
+ output = decodeShellOutput(execSync(command, {
202
284
  cwd,
203
- encoding: 'utf8',
285
+ encoding: 'buffer',
204
286
  timeout: LINT_TIMEOUT_MS,
205
287
  maxBuffer: 32 * 1024 * 1024,
206
288
  stdio: ['ignore', 'pipe', 'pipe'],
207
- })
289
+ windowsHide: true,
290
+ }))
208
291
  } catch (e) {
209
292
  exitCode = typeof e.status === 'number' ? e.status : 1
210
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
293
+ output = [decodeShellOutput(e.stdout), decodeShellOutput(e.stderr)].filter(Boolean).join('\n') || e.message
211
294
  reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= LINT_TIMEOUT_MS
212
295
  ? `lint 命令超时(>${LINT_TIMEOUT_MS / 1000}s)`
213
296
  : `lint 命令退出码 ${exitCode}`
214
297
  }
215
298
  const durationMs = Date.now() - startedAt
216
299
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
217
- const status = exitCode === 0 ? 'passed' : 'failed'
218
- const finalReason = exitCode === 0 ? null : reason
219
- const tally = recordVerifyLintTally({ specBase, result: { status, reason: finalReason } })
300
+ let status = exitCode === 0 ? 'passed' : 'failed'
301
+ let finalReason = exitCode === 0 ? null : reason
302
+ // 环境缺件降档(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,2026-09-23 实证):
303
+ // 链式 lint 在缺失二进制处中止(&& 语义保证其前段全绿),CNF 是环境信号非代码失败——硬拦
304
+ // 只会逼 SILLYSPEC_*_GATE=skip 逃生口,降档 skipped 带修复指引。护栏:失败输出无可归属
305
+ // 文件路径才降(真实 lint 债恒带 path——extractLintFailureFiles 非空维持硬拦,CNF 噪音
306
+ // 不得掩盖真债);降档不进 tally(环境跳过非质量信号,不稀释硬门失败率)。
307
+ let envDowngraded = false
308
+ if (status === 'failed') {
309
+ const cnf = detectCommandMissingFailure(output)
310
+ if (cnf.matched && extractLintFailureFiles(output).length === 0) {
311
+ status = 'skipped'
312
+ finalReason = `环境缺件:${cnf.binary ? `二进制「${cnf.binary}」` : '命令二进制'}不存在(${cnf.evidence})——非代码失败不拦门;在对应包目录补装依赖(pnpm/npm install、uv sync 等)后 lint 恢复实测`
313
+ envDowngraded = true
314
+ console.warn(`⚠️ lint 实测环境缺件降档为跳过:${cnf.evidence}——命令链在缺失二进制处中止(&& 链其前段全绿);修复环境(安装依赖)而非改代码;疑沙箱环境差异可设 SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF=1 对照主仓口径`)
315
+ }
316
+ }
317
+ const tally = envDowngraded ? null : recordVerifyLintTally({ specBase, result: { status, reason: finalReason } })
220
318
 
221
319
  return {
222
320
  status,
@@ -226,6 +324,7 @@ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
226
324
  outputTail,
227
325
  reason: finalReason,
228
326
  tally, // { failedRuns, totalRuns } | null——观察期失败率,升级硬门的决策依据
327
+ failureFiles: status === 'failed' ? extractLintFailureFiles(output) : [], // 归属鉴定输入(全文口径,outputTail 有截断盲区)
229
328
  }
230
329
  }
231
330
 
@@ -266,7 +365,7 @@ export function printVerifyLintCheck(result) {
266
365
  console.error(`\n⚠️ Verify lint 实测失败(advisory 档,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
267
366
  console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
268
367
  if (result.tally) {
269
- console.error(` 📊 lint advisory 失败累计 ${result.tally.failedRuns}/${result.tally.totalRuns} 次(数据落 .runtime/verify-lint-tally.json)。`)
368
+ console.error(` 📊 lint advisory 观察期:实测 ${result.tally.totalRuns} 次中失败 ${result.tally.failedRuns} 次(数据落 .runtime/verify-lint-tally.json)。`)
270
369
  }
271
370
  }
272
371
  if (result.outputTail) {
@@ -579,7 +678,29 @@ export function extractModules(yamlText) {
579
678
  }
580
679
  const name = entry[2]
581
680
  const rest = (entry[3] || '').trim()
582
- if (rest === '' || rest.startsWith('#')) continue // 子块展开式(本实现只支持 inline flow)
681
+ if (rest === '' || rest.startsWith('#')) {
682
+ // 子块展开式(2026-09-24 R10/R11 双实证修复:块状 path:/test: 缩进子行——此前只支持
683
+ // inline flow,块状被静默跳过 → modulesPresent=false 回退全量;两轮受试分别撞 25min
684
+ // 全量与文案矛盾)。向前看本条目的子行(缩进 > 条目行缩进)取 path/test 标量。
685
+ let pathVal = null
686
+ let testVal = null
687
+ for (let j = i + 1; j < lines.length; j++) {
688
+ const sub = lines[j]
689
+ if (sub.trim() === '' || sub.trim().startsWith('#')) continue
690
+ const subM = sub.match(/^([ ]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
691
+ if (!subM || subM[1].length <= entry[1].length) break // 子行缩进必须更深
692
+ const key = subM[2]
693
+ const val = (subM[3] || '').trim().replace(/^["']|["']$/g, '')
694
+ if (key === 'path' && val) pathVal = val
695
+ if (key === 'test' && val) testVal = val
696
+ if (pathVal && testVal) break
697
+ }
698
+ if (pathVal && testVal) {
699
+ modules[name] = { path: pathVal, test: testVal }
700
+ continue
701
+ }
702
+ continue
703
+ }
583
704
  // 解析 inline flow mapping: { path: "...", test: "..." }
584
705
  const pathVal = parseFlowValue(rest, 'path')
585
706
  const testVal = parseFlowValue(rest, 'test')
@@ -629,27 +750,65 @@ function parseFlowValue(flowText, key) {
629
750
  * @param {{cwd: string, changedFiles: string[], coveredCommands?: string[]}} args
630
751
  * @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
631
752
  */
753
+ /**
754
+ * 测试文件判定(polyglot,2026-09-24 deps-auto 泛化):JS/TS 测试(*.test/spec.[cm]js/ts)
755
+ * + Python 测试(test_*.py / *_test.py),任意目录深度(tests?/ 目录或散置均可)。
756
+ */
757
+ function isTestFilePath(p) {
758
+ return /\.(test|spec)\.[cm]?(js|ts)$/.test(p) || /(^|\/)test_[^\/]+\.py$/.test(p) || /(^|\/)[^\/]+_test\.py$/.test(p)
759
+ }
760
+
761
+ /** 递归收集测试文件(跳过 node_modules/.venv/.git/dist/build/.runtime/.sillyspec,深度帽 6 层防野目录)。 */
762
+ function collectTestFiles(rootDir, base = '', depth = 0, acc = []) {
763
+ if (depth > 6) return acc
764
+ let entries = []
765
+ try { entries = readdirSync(rootDir, { withFileTypes: true }) } catch { return acc }
766
+ for (const e of entries) {
767
+ if (['node_modules', '.venv', 'venv', '.git', 'dist', 'build', '.runtime', '.sillyspec', '.claude', '.worktrees'].includes(e.name)) continue
768
+ const rel = base ? `${base}/${e.name}` : e.name
769
+ if (e.isDirectory()) collectTestFiles(join(rootDir, e.name), rel, depth + 1, acc)
770
+ else if (isTestFilePath(rel)) acc.push(rel)
771
+ }
772
+ return acc
773
+ }
774
+
775
+ /** 变更 .py → 候选导入串(点串后缀族:完整模块及其各级包后缀——匹配 from X import / import X)。 */
776
+ function pythonImportCandidates(changedPy) {
777
+ const parts = changedPy.replace(/\.py$/, '').split('/')
778
+ const out = new Set()
779
+ for (let take = 2; take <= parts.length; take++) {
780
+ out.add(parts.slice(parts.length - take).join('.'))
781
+ }
782
+ return [...out]
783
+ }
784
+
632
785
  export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
633
786
  if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
634
787
  const norm = (p) => String(p).replace(/\\/g, '/')
635
- const changedSrc = [...new Set(changedFiles.map(norm))]
636
- .filter(p => p.startsWith('src/') && /\.(js|mjs|cjs|ts)$/.test(p))
637
- const changedTests = [...new Set(changedFiles.map(norm))]
638
- .filter(p => /^test\//.test(p) && /\.test\.[cm]?js$/.test(p))
788
+ const changedAll = [...new Set(changedFiles.map(norm))]
789
+ // 代码面:非测试文件本身(JS/TS + Python 源——deps-auto 泛化,不再限定 src/ 前缀与自家目录形态)
790
+ const changedSrc = changedAll.filter(p => !isTestFilePath(p) && /\.(js|mjs|cjs|ts|tsx|py)$/.test(p))
791
+ const changedTests = changedAll.filter(p => isTestFilePath(p))
639
792
  if (changedSrc.length === 0 && changedTests.length === 0) return []
640
793
  const covered = coveredCommands.filter(Boolean).join(' ')
641
794
  const found = new Set()
642
- const testDir = join(cwd, 'test')
643
- let entries = []
644
- try { entries = readdirSync(testDir) } catch { entries = [] }
645
- for (const f of entries) {
646
- if (!/\.test\.[cm]?js$/.test(f)) continue
647
- if (covered.includes(f)) continue
795
+
796
+ const pyCandidates = new Set()
797
+ for (const src of changedSrc.filter(p => p.endsWith('.py'))) {
798
+ for (const d of pythonImportCandidates(src)) pyCandidates.add(d)
799
+ }
800
+ const jsSrc = changedSrc.filter(p => /\.(js|mjs|cjs|ts|tsx)$/.test(p))
801
+
802
+ for (const tf of collectTestFiles(cwd)) {
803
+ const fname = tf.split('/').pop()
804
+ if (covered.includes(fname) || covered.includes(tf)) continue
648
805
  let content = ''
649
- try { content = readFileSync(join(testDir, f), 'utf8') } catch { continue }
650
- for (const src of changedSrc) {
651
- if (content.includes(`../${src}`)) { found.add(`test/${f}`); break }
806
+ try { content = readFileSync(join(cwd, tf), 'utf8') } catch { continue }
807
+ let hit = jsSrc.some(src => content.includes(src))
808
+ if (!hit) {
809
+ hit = [...pyCandidates].some(d => content.includes(`from ${d}`) || content.includes(`import ${d}`))
652
810
  }
811
+ if (hit) found.add(tf)
653
812
  }
654
813
  for (const t of changedTests) {
655
814
  const fname = t.split('/').pop()
@@ -703,15 +862,18 @@ export function pickHitModules(changedFiles, modules) {
703
862
  * 聚合多个模块测试结果为单一 status。
704
863
  * - 全 passed → 'passed'
705
864
  * - 任一 failed → 'failed'
865
+ * - 无 failed 且非全 passed → 'skipped'(环境缺件跳过单元族,2026-09-23 CNF 降档引入:
866
+ * 跑过的模块全绿、缺件的在二进制处中止——调用方按跳过族口径呈现,不硬拦)
706
867
  * - 空 → null(调用方按 fallback 处理)
707
868
  *
708
- * @param {Array<{status:'passed'|'failed'}>} results
709
- * @returns {'passed'|'failed'|null}
869
+ * @param {Array<{status:'passed'|'failed'|'skipped'}>} results
870
+ * @returns {'passed'|'failed'|'skipped'|null}
710
871
  */
711
872
  export function aggregateStatus(results) {
712
873
  if (!Array.isArray(results) || results.length === 0) return null
874
+ if (results.some(r => r && r.status === 'failed')) return 'failed'
713
875
  if (results.every(r => r && r.status === 'passed')) return 'passed'
714
- return 'failed'
876
+ return 'skipped'
715
877
  }
716
878
 
717
879
  /**
@@ -965,7 +1127,7 @@ const ARTIFACT_ENV_MISSING_RES = [
965
1127
  // 条目 H 签名族:pytest-xdist 缺失 → usage error(unrecognized -n/--dist)
966
1128
  /ERROR:\s*usage:\s*py(?:test|thon)[^\n]*(?:-n\b|--dist|xdist|unrecognized argument)/i,
967
1129
  /ModuleNotFoundError:\s*No module named\s+'?(?:pytest|pytest_xdist|xdist|_pytest)'?/i,
968
- /(?:command not found|not recognized as an internal or external command)[^\n]*(?:pytest|vitest|jest|mocha|ruff|tsc|eslint|python)/i,
1130
+ /(?:command not found|not recognized as an internal or external command|不是内部或外部命令)[^\n]*(?:pytest|vitest|jest|mocha|ruff|tsc|eslint|next|python)|(?:pytest|vitest|jest|mocha|ruff|tsc|eslint|next|python)[^\n]*(?:command not found|not recognized as an internal or external command|不是内部或外部命令)/i,
969
1131
  ]
970
1132
  const ARTIFACT_MODULE_IMPORT_RES = [
971
1133
  // overlay 部分态:改动引用方没改被引用方 → 本地顶层包 import 不到(①已先排除 pytest 家族)
@@ -1018,6 +1180,57 @@ export function classifyTestFailureArtifact(result) {
1018
1180
  return empty
1019
1181
  }
1020
1182
 
1183
+ // ══ CNF(command-not-found)环境缺件识别(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,
1184
+ // 2026-09-23 multi-agent-platform 实证:纯 backend 改动被 frontend 链段 next CNF 拦死 --done)═══
1185
+ // 链式 commands.test/lint 在缺失二进制处中止(&& 语义保证其前段全绿)——CNF 是环境信号非
1186
+ // 代码失败,处置=降档 skipped 带修复指引(对齐纯超时降档/存量债归属鉴定先例)。只扫输出
1187
+ // 尾部窗(wrapper 失败横幅必在链尾;测试 fixture 的预期 CNF 文案在输出中段不进窗——防
1188
+ // fixture 噪音触发降档)。注意 2026-09-23 同坑实证反转:沙箱 junction 机制无罪(健康 pnpm
1189
+ // node_modules 经 junction 实测 tsc/next 均可解析),CNF 命中时主仓口径大概率同样缺件
1190
+ //(半装 node_modules/.bin 缺失族),修复动作是补装环境而非绕沙箱。
1191
+ const COMMAND_NOT_FOUND_TAIL_CHARS = 1500
1192
+ const COMMAND_NOT_FOUND_RES = [
1193
+ // Windows cmd(zh/en):二进制名单引号前置于报错短语
1194
+ /'([^']+)'[^'\n]*(?:不是内部或外部命令|is not recognized as an internal or external command)/i,
1195
+ // POSIX bash/zsh:`bash: line 1: next: command not found`
1196
+ /([A-Za-z0-9_@./-]+):\s*command not found/i,
1197
+ // POSIX dash/debian sh:`sh: 1: next: not found`
1198
+ /\bsh(?:\s+\d+)?:\s*\d+:\s*([A-Za-z0-9_@./-]+):\s*not found/i,
1199
+ // pnpm exec/script 包装横幅
1200
+ /Command "([^"]+)" not found/i,
1201
+ // 乱码兜底(decodeShellOutput 不可用面:无 full-icu 构建/e.message 直采):zh-Windows cmd
1202
+ // 报错经错误代码页解码后短语成乱码,但「单引号 ASCII 二进制名 + 行内 U+FFFD 替换符」
1203
+ // 形态稳定可识别(e.message 回退路径的输出不经 decodeShellOutput)
1204
+ /^'([A-Za-z0-9_@./-]+)'[^\n]*\ufffd/,
1205
+ ]
1206
+ // 判账行集的 wrapper 噪声形态(降档护栏):remaining 里只允许这些形态,出现真失败行
1207
+ // (断言详情/文件行号/✕ 标记族)在场维持硬拦——CNF 噪音不得掩盖真债。runner 汇总族
1208
+ //(TAP `# fail N` / node:test spec `ℹ fail N`)计入噪声:链前段通过输出的 `# fail 0`
1209
+ // 会进判账行集(PER_TEST_FAIL_RE 命中 fail 词、SUMMARY_LINE_RE 不覆盖该形态),真实
1210
+ // 失败场景另有 not ok/断言行在场挡降档,不受此放宽影响。
1211
+ const CNF_NOISE_LINE_RE = /(?:^\s*$)|(?:ELIFECYCLE)|(?:ERR_PNPM_)|(?:不是内部或外部命令)|(?:is not recognized as an internal or external command)|(?:command not found)|(?:Command failed with exit code)|(?:^undefined$)|(?:^[#ℹ]\s*(?:pass|fail|tests|suites|cancelled|skipped|todo)\b)/i
1212
+
1213
+ /**
1214
+ * 检测命令输出的二进制缺失(CNF)失败签名(纯函数,尾部窗扫描)。
1215
+ * @param {string} output 命令完整输出(stdout+stderr 合并形态)
1216
+ * @returns {{ matched: boolean, binary: string|null, evidence: string }} matched=尾部窗命中;binary=缺失二进制名(签名可捕获时,否则 null);evidence=命中行(trim 后 ≤120 字,供 reason/日志引用)
1217
+ */
1218
+ export function detectCommandMissingFailure(output) {
1219
+ const empty = { matched: false, binary: null, evidence: '' }
1220
+ if (!output || typeof output !== 'string') return empty
1221
+ const tail = output.replace(ANSI_RE, '').slice(-COMMAND_NOT_FOUND_TAIL_CHARS)
1222
+ for (const line of tail.split(/\r?\n/)) {
1223
+ for (const re of COMMAND_NOT_FOUND_RES) {
1224
+ const m = line.match(re)
1225
+ if (m) {
1226
+ const t = line.trim()
1227
+ return { matched: true, binary: (m[1] || '').trim() || null, evidence: t.length > 120 ? t.slice(0, 120) + '…' : t }
1228
+ }
1229
+ }
1230
+ }
1231
+ return empty
1232
+ }
1233
+
1021
1234
  /**
1022
1235
  * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
1023
1236
  * - skip → 'skip'(真跳过,D-005@v2——不落 full 兜底;evidence-auto 经
@@ -1027,14 +1240,30 @@ export function classifyTestFailureArtifact(result) {
1027
1240
  * - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
1028
1241
  * @returns {'skip'|'module-subset'|'module-zero-hit-skip'|'full'}
1029
1242
  */
1030
- export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
1243
+ /**
1244
+ * 纯超时失败判定(R4 门禁价值考古 2026-09-21:227 跑 106 败里 36 次(34%)是 600s 帽杀的
1245
+ * 纯超时假拦——超时=未完成非测试失败,硬拦只是浪费一轮门禁)。所有失败单元的 reason 都
1246
+ * 含「超时」→ true(调用方降档 advisory);任一失败单元是真实退出码/挂测 → false(维持硬拦)。
1247
+ * 单元来源:module 模式读 result.modules(失败单元过滤),full 模式整体 reason。跨仓合并结果
1248
+ * 语义同 full(整体 reason 聚合)。
1249
+ */
1250
+ export function isTimeoutOnlyTestFailure(testCheck) {
1251
+ if (!testCheck || testCheck.status !== 'failed') return false
1252
+ const units = Array.isArray(testCheck.modules) && testCheck.modules.length > 0
1253
+ ? testCheck.modules.filter(u => u && u.status === 'failed')
1254
+ : [testCheck]
1255
+ if (units.length === 0) return false
1256
+ return units.every(u => /超时/.test(String(u.reason || '')))
1257
+ }
1258
+
1259
+ export function decideVerifyTestAction({ strategy, scopeCount = 0, gitUnavailable = false }) {
1031
1260
  if (strategy === 'skip') return 'skip'
1032
- if (strategy === 'module' && modulesPresent) {
1033
- if (hitCount > 0) return 'module-subset'
1034
- if (hitCount === 0) return 'module-zero-hit-skip'
1035
- return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
1036
- }
1037
- return 'full'
1261
+ if (strategy === 'full') return 'full'
1262
+ // 动态子集(2026-09-26-dynamic-test-inference 起缺省):git 不可用无法算文件面 → 全量兜底;
1263
+ // 三源(本变更测试/FR 关联回归/import 依赖)并集空 → dynamic-empty(不硬跑全量,防超时/
1264
+ // 预存失败面);非空 → dynamic-subset 实测。
1265
+ if (gitUnavailable) return 'full'
1266
+ return scopeCount > 0 ? 'dynamic-subset' : 'dynamic-empty-skip'
1038
1267
  }
1039
1268
 
1040
1269
  /**
@@ -1048,16 +1277,17 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1048
1277
  let reason = null
1049
1278
  warnPortRaceBeforeRun(testCommand)
1050
1279
  try {
1051
- output = execSync(testCommand, {
1280
+ output = decodeShellOutput(execSync(testCommand, {
1052
1281
  cwd,
1053
- encoding: 'utf8',
1282
+ encoding: 'buffer',
1054
1283
  timeout: TEST_TIMEOUT_MS,
1055
1284
  maxBuffer: 32 * 1024 * 1024,
1056
1285
  stdio: ['ignore', 'pipe', 'pipe'],
1057
- })
1286
+ windowsHide: true,
1287
+ }))
1058
1288
  } catch (e) {
1059
1289
  exitCode = typeof e.status === 'number' ? e.status : 1
1060
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
1290
+ output = [decodeShellOutput(e.stdout), decodeShellOutput(e.stderr)].filter(Boolean).join('\n') || e.message
1061
1291
  reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
1062
1292
  ? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
1063
1293
  : `模块 ${name} 测试退出码 ${exitCode}`
@@ -1069,6 +1299,28 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1069
1299
  const durationMs = Date.now() - startedAt
1070
1300
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
1071
1301
  const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
1302
+ // 环境缺件降档(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,2026-09-23 实证):
1303
+ // 模块命令在缺失二进制处中止——CNF 是环境信号非代码失败,降档 skipped(aggregateStatus
1304
+ // 认识 skipped 单元)。护栏:判账行集全为 wrapper 噪声才降(真失败行在场维持硬拦)。
1305
+ if (judged.status === 'failed') {
1306
+ const cnf = detectCommandMissingFailure(output)
1307
+ if (cnf.matched && judged.remainingLines.every(l => CNF_NOISE_LINE_RE.test(String(l)))) {
1308
+ const skipReason = `模块 ${name} 环境缺件跳过:${cnf.binary ? `二进制「${cnf.binary}」` : '命令二进制'}不存在(${cnf.evidence})——非代码失败;补装依赖后恢复实测`
1309
+ console.warn(`⚠️ test 实测${skipReason}`)
1310
+ return {
1311
+ name,
1312
+ status: 'skipped',
1313
+ command: testCommand,
1314
+ exitCode,
1315
+ durationMs,
1316
+ outputTail,
1317
+ reason: skipReason,
1318
+ exemptedCount: judged.exemptedCount,
1319
+ failureRemaining: judged.remainingLines,
1320
+ failureExempted: judged.exemptedLines,
1321
+ }
1322
+ }
1323
+ }
1072
1324
  return {
1073
1325
  name,
1074
1326
  status: judged.status,
@@ -1439,7 +1691,11 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1439
1691
  * resultPath: string|null,
1440
1692
  * }}
1441
1693
  */
1442
- export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null }) {
1694
+ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null, restrictFiles = null, faceOverride = null }) {
1695
+ // restrictFiles(坑 quick-gate-并行全流程变更脏文件误伤,2026-09-21 实证):本会话声明
1696
+ // 文件集(quick gate 传 allowedFiles)——提供时 module 子集的变更文件面收窄到
1697
+ // 「实际变更 ∩ restrictFiles」,窗口内未声明的并行全流程变更 WIP 文件不再进模块选择
1698
+ // 与 deps(auto)(保留在 git diff 审计记录里,不进快照实测面)。null = 旧行为零变化。
1443
1699
  // 信任边界声明(体检 SEC-02 核实):本函数三处 execSync(command) 的命令只来源于
1444
1700
  // ① 主仓 specBase(resolveSpecDir → 主仓 .sillyspec/local.yaml)② 跨仓仓根
1445
1701
  // <repo>/.sillyspec/local.yaml——均为仓库自有配置(与 `npm test` 同信任级:跑测试
@@ -1458,56 +1714,87 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1458
1714
  console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(local.yaml)`)
1459
1715
  }
1460
1716
 
1461
- // —— 缺省收窄(ql-20260920-010 修复一 c,对撞三轮 68 分钟单步构成之一=缺省全量)——
1462
- // modules: 已配置且未显式 test_strategy → 缺省按 module 子集实测。未配置 modules: 的仓
1463
- // 保持缺省 full 零打扰;显式 test_strategy: full 是用户有意跑全量,不受影响。
1464
- let defaultedToModule = false
1465
- if (rawStrategy === null && extractModules(yamlText)) {
1466
- defaultedToModule = true
1467
- console.warn('ℹ️ test_strategy 未配置但 modules: 已配置——缺省按 module 子集实测(v3.29.3 起收窄;显式写 test_strategy: full 恢复全量)')
1717
+ // ── local.yaml 测试配置退役(2026-09-26-dynamic-test-inference)──
1718
+ // modules.*.test 不再消费:静态配置写死单方向测试面、无法按变更收窄、并行变更须互改共享
1719
+ // 配置(平台仓三形态路径混杂 + R22 裸 fallback 600s×8 实证)。测试面缺省动态推断:
1720
+ // 本变更测试 ∪ FR 关联回归(active FR 覆盖面∩触碰文件→绑定 tests)∪ import 依赖测试;
1721
+ // runner 自项目结构推断(pyproject/uv.lock/package.json 就近祖先)。commands.test 降格为
1722
+ // 显式 test_strategy: full 的全量逃生阀;known_failures/test_strategy 照常生效。
1723
+ if (extractModules(yamlText)) {
1724
+ console.warn('ℹ️ local.yaml modules.*.test 已退役(2026-09-26-dynamic-test-inference):测试面缺省按变更动态推断、runner 自项目结构推断——modules 块可删')
1468
1725
  }
1469
1726
 
1470
- // —— evidence-auto 生效策略解析(D-005@v2 / task-11)——
1471
- // 按变更目录 module-impact.md 影响面取生效策略(行为→module、纯文档/门禁→skip、
1472
- // 缺失/不可解析→降级 module 并注记)再进既有链路;full/module/skip/缺省四路径
1473
- // 不经此分支(消费语义逐字不变)。
1474
- let strategy = defaultedToModule ? 'module' : rawStrategy
1727
+ // 策略折算:full/skip 显式语义保留;module / evidence-auto(解析 module) / 缺省统一进动态子集
1728
+ // (module 的按模块收窄语义被动态子集超集覆盖:按变更文件面收窄且带 FR 关联回归)。
1729
+ // evidence-auto 生效策略解析(D-005@v2 / task-11)照旧:按变更目录 module-impact.md 影响面
1730
+ // 取生效策略(行为→实测、纯文档/门禁→skip、缺失/不可解析→降级实测并注记)。
1731
+ let strategy = rawStrategy
1475
1732
  let evidenceAuto = null
1733
+ let eaNote = ''
1476
1734
  if (rawStrategy === 'evidence-auto') {
1477
1735
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
1478
1736
  const resolution = resolveTestStrategy({ yamlText, changeDir })
1479
1737
  strategy = resolution.strategy
1480
1738
  evidenceAuto = resolution.evidence_auto_recommendation
1739
+ eaNote = '(生效策略来自 test_strategy: evidence-auto 推荐)'
1481
1740
  }
1482
- // evidence-auto 解析为 module 后的 hint/reason 追加注记(full/module 路径为空串,输出逐字不变)
1483
- const eaNote = rawStrategy === 'evidence-auto' ? '(生效策略来自 test_strategy: evidence-auto 推荐)' : ''
1484
-
1485
- // —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
1486
- // resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
1487
- // 注:module 子集策略只用主仓 diff(跨仓仓不参与 module 子集,design §6 + §5.4),
1488
- // 故此处不传 ctx(避免跨仓路径误命中主仓 module 映射)。
1489
- let modulesPresent = false
1490
- let hitCount = 0
1491
- let hits = []
1492
- let lastChangedFiles = [] // 0 命中诊断用(diff 文件样例可见性,坑 module-path-layout-mismatch)
1493
1741
  if (strategy === 'module') {
1494
- const modules = extractModules(yamlText)
1495
- if (modules) {
1496
- modulesPresent = true
1497
- // includeWorkingTree(坑 module-subset-zero-hit-uncommitted):子代理不 commit 的改动
1498
- // 也参与 module 命中判定,0 命中跳过不再误伤 worktree 未提交的真实变更
1499
- const changedFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
1500
- lastChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
1501
- if (changedFiles === null) {
1502
- hitCount = -1 // git 不可用 / 非仓库
1742
+ console.warn('ℹ️ test_strategy: module 已退役——module 语义被动态子集覆盖(变更文件面收窄 + FR 关联回归 + import 依赖),本次按动态子集执行')
1743
+ strategy = 'dynamic'
1744
+ }
1745
+ if (strategy === null) strategy = 'dynamic'
1746
+
1747
+ // 变更文件面(动态子集命中源)。faceOverride 在场=调用方权威面(R23-thin 实证盲区修复:
1748
+ // 快照锚 HEAD + thin「先提交再收口」⇒ 快照内 diff 只剩真未提交文件,与权威面相交后动态子集
1749
+ // 假空跳过——权威面由 flow done 在主仓 cwd 按 baseline..HEAD+status 算好传入;快照只隔离
1750
+ // 执行,不再重推文件面)。缺省走快照/仓内推导(includeWorkingTree ∩ restrictFiles,
1751
+ // 坑 module-subset-zero-hit-uncommitted / 并行变更脏文件误伤;thin 先提交后收口 diff 恒空
1752
+ // 时回退调用方清单)。跨仓仓不参与动态子集(design §6 + §5.4),故不传 ctx。
1753
+ let lastChangedFiles = []
1754
+ let gitUnavailable = false
1755
+ if (strategy === 'dynamic') {
1756
+ if (Array.isArray(faceOverride) && faceOverride.length > 0) {
1757
+ const norm = (f) => String(f).replace(/\\/g, '/').replace(/^\.\//, '')
1758
+ lastChangedFiles = [...new Set(faceOverride.map(norm))]
1759
+ console.log(`ℹ️ 文件面=调用方权威面 ${lastChangedFiles.length} 个文件(含已提交交付——快照只隔离执行不重推文件面)`)
1760
+ } else {
1761
+ let cf = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
1762
+ if (cf === null) {
1763
+ gitUnavailable = true
1503
1764
  } else {
1504
- hits = pickHitModules(changedFiles, modules)
1505
- hitCount = hits.length
1765
+ if (Array.isArray(restrictFiles) && restrictFiles.length > 0) {
1766
+ const norm = (f) => String(f).replace(/\\/g, '/').replace(/^\.\//, '')
1767
+ const restrictSet = new Set(restrictFiles.map(norm))
1768
+ const before = cf.length
1769
+ cf = cf.filter(f => restrictSet.has(norm(f)))
1770
+ const dropped = before - cf.length
1771
+ if (dropped > 0) {
1772
+ console.log(`ℹ️ 文件面已收窄到本会话声明的 ${cf.length}/${before} 个变更文件(${dropped} 个未声明脏文件不进实测面——并行变更 WIP 留在审计记录)`)
1773
+ }
1774
+ }
1775
+ if (Array.isArray(cf) && cf.length === 0 && Array.isArray(restrictFiles) && restrictFiles.length > 0) {
1776
+ cf = restrictFiles.map((f) => String(f).replace(/\\/g, '/').replace(/^\.\//, ''))
1777
+ console.log(`ℹ️ diff 命中源为空(先提交后收口的 thin 常态)——回退调用方清单 ${cf.length} 个文件作命中源(与快照 overlay 同源)`)
1778
+ }
1779
+ lastChangedFiles = cf
1506
1780
  }
1507
1781
  }
1508
1782
  }
1509
1783
 
1510
- const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
1784
+ // 动态子集三源预计算(决策与 runModuleSubset 共用,免二次扫描):
1785
+ // ① 本变更测试 ∪ ③ import 依赖(discoverModuleDependentTests);② FR 关联回归(fail-open)。
1786
+ let depsAutoFiles = []
1787
+ let frPre = null
1788
+ if (strategy === 'dynamic' && !gitUnavailable) {
1789
+ try {
1790
+ depsAutoFiles = discoverModuleDependentTests({ cwd, changedFiles: lastChangedFiles, coveredCommands: [] })
1791
+ } catch { depsAutoFiles = [] }
1792
+ try {
1793
+ frPre = collectFrLinkedTests({ specBase, changeName, changedFiles: lastChangedFiles, projectRoot: cwd })
1794
+ } catch { frPre = null }
1795
+ }
1796
+ const scopeFiles = frPre ? [...new Set([...depsAutoFiles, ...frPre.files])] : [...new Set(depsAutoFiles)]
1797
+ const action = decideVerifyTestAction({ strategy, scopeCount: scopeFiles.length, gitUnavailable })
1511
1798
  let mainResult
1512
1799
  if (action === 'skip') {
1513
1800
  // —— skip 真跳过(D-005@v2 / R-07)——
@@ -1516,7 +1803,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1516
1803
  // evidence-auto→skip:测试不在推荐组合内(module-impact.md 无行为类影响),
1517
1804
  // reason 附推荐依据与否决路径。
1518
1805
  const reason = rawStrategy === 'skip'
1519
- ? '测试已按 test_strategy=skip 配置跳过(D-005@v2 兑现声明语义:不回退全量 commands.test)。⚠️ 行为变化提示(R-07):此前配置 skip 实际仍跑全量,本版本起真跳过——本次 verify 结论不含测试客观核验;如需恢复实测,把 local.yaml 的 test_strategy 改回 full/module。'
1806
+ ? '测试已按 test_strategy=skip 配置跳过(D-005@v2 兑现声明语义:不回退全量 commands.test)。⚠️ 行为变化提示(R-07):此前配置 skip 实际仍跑全量,本版本起真跳过——本次 verify 结论不含测试客观核验;如需恢复实测,把 local.yaml 的 test_strategy 改回 full。'
1520
1807
  : `测试已按 test_strategy=evidence-auto 推荐跳过:${evidenceAuto && evidenceAuto.summary ? evidenceAuto.summary : 'module-impact.md 判定无行为类影响'}。本次 verify 结论不含测试客观核验。`
1521
1808
  mainResult = {
1522
1809
  status: 'skipped',
@@ -1546,53 +1833,39 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1546
1833
  } : {}),
1547
1834
  },
1548
1835
  })
1549
- } else if (action === 'module-subset') {
1550
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
1551
- } else if (action === 'module-zero-hit-skip') {
1552
- // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
1553
- // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
1554
- // 诊断可见性(坑 module-path-layout-mismatch,2026-08-22 实证:0 命中「靠第一次跑过的
1555
- // 记录兜底」——为何没命中完全黑箱):落 modules 配置 path vs diff 文件样例,配置前缀
1556
- // 对不上(如 packages/frontend vs frontend/)一眼可见
1557
- const modules_ = extractModules(yamlText) || {}
1558
- const diagLines = [
1559
- `已配置 modules(${Object.keys(modules_).length} 个): ${Object.entries(modules_).map(([k, m]) => `${k}→${m.path}`).join('、') || '(无)'}`,
1560
- `本次 diff(${lastChangedFiles.length} 个文件,前 5): ${lastChangedFiles.slice(0, 5).join(', ') || '(空)'}`,
1561
- ]
1562
- console.warn(`⚠️ 模块 0 命中诊断(对照 path 前缀与 diff 布局是否一致,如 packages/<name> vs <name>/):`)
1563
- for (const l of diagLines) console.warn(` ${l}`)
1564
- // 变更测试兜底(ql-20260919-007,2026-09-19 实测踩:test-only 变更 0 模块命中时裸 skip,
1565
- // quick --done 的 test 门禁沦为「据自报告判定」虚设):diff 含测试文件时按 deps(auto)
1566
- // 同款口径(discoverModuleDependentTests)直接跑「变更的测试文件」——范围仍限本次变更
1567
- // 不回退全量(0 命中防超时/预存失败的初衷不变);无测试文件维持裸 skip(docs-only 等
1568
- // 变更无测试面)。hits 传空数组:runModuleSubset 内部同样经 deps(auto) 执行并落台账。
1569
- const zeroHitDeps = discoverModuleDependentTests({ cwd, changedFiles: lastChangedFiles, coveredCommands: [] })
1570
- if (zeroHitDeps.length > 0) {
1571
- console.log(`ℹ️ 模块 0 命中但 diff 含 ${zeroHitDeps.length} 个测试文件——按变更测试子集实测(不回退全量、不再裸 skip)`)
1572
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles })
1573
- } else {
1574
- mainResult = {
1575
- status: 'skipped',
1576
- command: null,
1577
- exitCode: null,
1578
- durationMs: null,
1579
- outputTail: null,
1580
- reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
1581
- ` 诊断:${diagLines.join(';')}` + eaNote
1582
- + (defaultedToModule ? '(注:module 为缺省收窄——local.yaml 未显式配置 test_strategy;常跑全量请显式写 test_strategy: full)' : ''),
1583
- resultPath: null,
1584
- mode: 'module-zero-hit',
1585
- fallbackReason: null,
1586
- }
1587
- }
1588
- } else {
1589
- // —— 全量路径(full / module 无块 / module git 不可用)——
1590
- // fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
1591
- // evidence-auto 解析为 module 后落全量兜底时追加推荐来源注记(eaNote;其余路径空串零变化)。
1592
- let fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
1593
- if (fallbackReason && eaNote) fallbackReason = fallbackReason + eaNote
1594
- if (fallbackReason && defaultedToModule) fallbackReason = fallbackReason + '(注:module 为缺省收窄——local.yaml 未显式配置 test_strategy)'
1836
+ } else if (action === 'full') {
1837
+ // —— 全量路径(显式 test_strategy: full / evidence-auto 解析 full / git 不可用兜底)——
1838
+ // commands.test 在场仍生效(全量逃生阀);否则自项目结构推断全量命令(package.json
1839
+ // scripts.test / pytest 发现)——不再「未配置即 skipped」(R22 实证缺配置裸 skip/裸跑之痛)。
1840
+ const fallbackReason = rawStrategy === 'full'
1841
+ ? null
1842
+ : gitUnavailable
1843
+ ? 'git 不可用,动态子集文件面不可解析——全量兜底'
1844
+ : 'evidence-auto 解析为 full' + eaNote
1595
1845
  mainResult = runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
1846
+ } else if (action === 'dynamic-subset') {
1847
+ // —— 动态子集(2026-09-26-dynamic-test-inference 缺省路径)——
1848
+ // 三源并集:本变更测试 ∪ FR 关联回归 ∪ import 依赖;runner 自项目结构推断;
1849
+ // 全量语义留 CI / 显式 test_strategy: full。
1850
+ const frCount = frPre && frPre.files.length ? frPre.files.length : 0
1851
+ console.log(`ℹ️ 动态测试子集(缺省):本变更测试 ∪ FR 关联回归 ∪ import 依赖 = ${scopeFiles.length} 个(deps ${depsAutoFiles.length}${frCount ? ` + FR 绑定 ${frCount}` : ''});runner 自项目结构推断。显式 test_strategy: full 恢复全量`)
1852
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre })
1853
+ } else {
1854
+ // —— dynamic-empty:变更与测试面零关系——
1855
+ // 无自身测试、无 import 依赖测试、无 FR 关联绑定 → 不硬跑全量(防超时/预存失败面,
1856
+ // 同旧 module 0 命中防误伤初衷);全量覆盖请显式 test_strategy: full。
1857
+ mainResult = {
1858
+ status: 'skipped',
1859
+ command: null,
1860
+ exitCode: null,
1861
+ durationMs: null,
1862
+ outputTail: null,
1863
+ reason: `变更与测试面零关系(${lastChangedFiles.length} 个变更文件无自身测试、无 import 依赖测试、无 FR 关联绑定)——动态子集空,不硬跑全量(防超时/预存失败面)。全量覆盖请显式 test_strategy: full。` + eaNote,
1864
+ resultPath: null,
1865
+ mode: 'dynamic-empty',
1866
+ fallbackReason: null,
1867
+ }
1868
+ writeRunResult({ specBase, changeName, result: mainResult, extra: { strategy: rawStrategy, scope: 'dynamic-empty', changed_files: lastChangedFiles.slice(0, 20) } })
1596
1869
  }
1597
1870
 
1598
1871
  // —— 跨仓仓 per-repo cwd 跑 full npm test(task-06 / D-004 / design §5.4 + §6 A6)——
@@ -1603,6 +1876,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1603
1876
  // 主仓 skipped(test_strategy: skip / evidence-auto 推荐跳过 / module 0 命中)→ 进短路档:
1604
1877
  // 未自配 commands.test 的跨仓不再 fallback npm test 假败(task-05 / FR-07,见
1605
1878
  // runCrossRepoTestUnderMainSkip 逐仓三态)。
1879
+ // ── trace residual 并入(读侧):悬空 fail-fast / 空残差直通 / 保守差集 / 残差段加法 ──
1880
+ // 可证覆盖集=动态子集实际跑的三源并集(scopeFiles);命令型动作(full/skip)照旧全补。
1881
+ mainResult = applyTraceResidual({ mainResult, action, cwd, specBase, changeName, depsAutoFiles: scopeFiles, hits: [], knownFailures })
1606
1882
  return mergeCrossRepoResults(mainResult, ctx)
1607
1883
  }
1608
1884
 
@@ -1795,6 +2071,7 @@ function runCrossRepoFullTest(entry) {
1795
2071
  timeout: TEST_TIMEOUT_MS,
1796
2072
  maxBuffer: 32 * 1024 * 1024,
1797
2073
  stdio: ['ignore', 'pipe', 'pipe'],
2074
+ windowsHide: true,
1798
2075
  })
1799
2076
  } catch (e) {
1800
2077
  exitCode = typeof e.status === 'number' ? e.status : 1
@@ -1805,6 +2082,21 @@ function runCrossRepoFullTest(entry) {
1805
2082
  }
1806
2083
  const durationMs = Date.now() - startedAt
1807
2084
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
2085
+ // node:test TAP 摘要级判定(坑 verify-gate-worktree-crossrepo-three-defects 缺陷三,
2086
+ // 2026-09-21 实证:跨仓 npm test 自身 EXIT=0 全绿,但外层命令链(管道/包装脚本)退出码
2087
+ // 丢失变非 0 → 行级 PER_TEST_FAIL_RE 把通过用例的预期错误文案(❌/AssertionError 等
2088
+ // fixture 噪音)全计入未豁免失败行阻断 verify)。node:test 输出自带权威摘要行 `ℹ fail N`
2089
+ // ——摘要存在且 fail=0 且 pass≥1 时,非 0 退出码判为包装层丢失,按摘要覆盖为通过
2090
+ //(摘要 fail>0 维持失败由行级台账归因,无摘要输出不适用零变化)。
2091
+ if (exitCode !== 0) {
2092
+ const tapFail = output.match(/^ℹ fail (\d+)$/m)
2093
+ const tapPass = output.match(/^ℹ pass (\d+)$/m)
2094
+ if (tapFail && Number(tapFail[1]) === 0 && tapPass && Number(tapPass[1]) > 0) {
2095
+ console.warn(`⚠️ 跨仓 repo "${entry.repoKey}" 退出码 ${exitCode} 但 node:test 摘要 ℹ pass ${tapPass[1]} / ℹ fail 0——判定包装命令退出码丢失(管道/脚本链),按 TAP 摘要覆盖为通过`)
2096
+ exitCode = 0
2097
+ reason = `跨仓 repo "${entry.repoKey}" node:test 摘要 ℹ pass ${tapPass[1]} / ℹ fail 0(外层退出码丢失已按摘要覆盖)`
2098
+ }
2099
+ }
1808
2100
  const judged = judgeWithKnownFailures(exitCode, output, reason, crossKnownFailures)
1809
2101
 
1810
2102
  return {
@@ -1895,7 +2187,7 @@ function isPortOccupiedSync(port) {
1895
2187
  try {
1896
2188
  const r = spawnSync(process.execPath, ['-e',
1897
2189
  `const n=require('node:net');const s=n.connect(${port},'127.0.0.1',()=>{console.log('Y');s.end()});s.on('error',()=>console.log('N'));setTimeout(()=>{console.log('N');process.exit(0)},1500)`],
1898
- { encoding: 'utf8', timeout: 4000 })
2190
+ { encoding: 'utf8', timeout: 4000, windowsHide: true })
1899
2191
  return (r.stdout || '').trim().endsWith('Y')
1900
2192
  } catch { return false }
1901
2193
  }
@@ -1918,42 +2210,59 @@ function warnPortRaceBeforeRun(command) {
1918
2210
  }
1919
2211
 
1920
2212
  function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
1921
- const command = extractTestCommand(yamlText)
1922
-
2213
+ let command = extractTestCommand(yamlText)
2214
+ let inferredNote = null
1923
2215
  if (!command) {
1924
- return {
1925
- status: 'skipped',
1926
- command: null,
1927
- exitCode: null,
1928
- durationMs: null,
1929
- outputTail: null,
1930
- reason: yamlText
1931
- ? 'local.yaml 未配置 commands.test(或标记 unavailable)'
1932
- : `local.yaml 不存在(${localYamlPath})`,
1933
- resultPath: null,
1934
- mode: 'full',
1935
- fallbackReason,
2216
+ // 全量命令结构推断(2026-09-26-dynamic-test-inference):无 commands.test 不再直接
2217
+ // skipped——package.json scripts.test / 根 pytest 清单可推断即跑(R22 实证缺配置裸 skip
2218
+ // 之痛)。commands.test 现仅作显式 test_strategy: full 的全量逃生阀(缺省路径已是动态
2219
+ // 子集,不再依赖此配置)。
2220
+ const inferred = inferFullTestCommand(cwd)
2221
+ if (inferred) {
2222
+ command = inferred.command
2223
+ inferredNote = `(全量命令自项目结构推断:${inferred.source})`
2224
+ console.log(`ℹ️ local.yaml 未配置 commands.test——全量命令按项目结构推断:${command} ${inferredNote}`)
2225
+ } else {
2226
+ return {
2227
+ status: 'skipped',
2228
+ command: null,
2229
+ exitCode: null,
2230
+ durationMs: null,
2231
+ outputTail: null,
2232
+ reason: yamlText
2233
+ ? 'local.yaml 未配置 commands.test 且项目结构无可推断全量命令(package.json scripts.test / pyproject pytest 均无)——全量无执行面。缺省路径无需此配置(动态子集实测);确要全量请补 package.json scripts.test 或临时 commands.test'
2234
+ : `local.yaml 不存在(${localYamlPath})且项目结构无可推断全量命令——全量无执行面。缺省路径无需此配置(动态子集实测);确要全量请先 sillyspec init`,
2235
+ resultPath: null,
2236
+ mode: 'full',
2237
+ fallbackReason,
2238
+ }
1936
2239
  }
1937
2240
  }
1938
2241
 
1939
2242
  const startedAt = Date.now()
2243
+ const timeoutMs = resolveTestTimeoutMs(yamlText)
1940
2244
  let exitCode = 0
1941
2245
  let output = ''
1942
2246
  let reason = null
1943
2247
  warnPortRaceBeforeRun(command)
1944
2248
  try {
1945
- output = execSync(command, {
2249
+ output = decodeShellOutput(execSync(command, {
1946
2250
  cwd,
1947
- encoding: 'utf8',
1948
- timeout: TEST_TIMEOUT_MS,
2251
+ encoding: 'buffer',
2252
+ timeout: timeoutMs,
1949
2253
  maxBuffer: 32 * 1024 * 1024,
1950
2254
  stdio: ['ignore', 'pipe', 'pipe'],
1951
- })
2255
+ windowsHide: true,
2256
+ }))
1952
2257
  } catch (e) {
1953
2258
  exitCode = typeof e.status === 'number' ? e.status : 1
1954
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
1955
- reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
1956
- ? `测试命令超时(>${TEST_TIMEOUT_MS / 1000}s)`
2259
+ output = [decodeShellOutput(e.stdout), decodeShellOutput(e.stderr)].filter(Boolean).join('\n') || e.message
2260
+ // 冻结鉴别(R9 实证 2026-09-23):超时且 stdout/stderr 双空 = 进程零产出(真慢套件有
2261
+ // 持续输出)——快照 junction/沙箱环境冻结特征,给主仓口径复跑的可行动指引
2262
+ const frozen = e.signal === 'SIGTERM' && Date.now() - startedAt >= timeoutMs
2263
+ && ![e.stdout, e.stderr].filter(Boolean).join('').trim()
2264
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= timeoutMs
2265
+ ? `测试命令超时(>${timeoutMs / 1000}s)${frozen ? '且零输出——疑似环境冻结(快照 junction/沙箱),建议主仓口径复跑(quick 门禁会自动回退;gate 场景带 SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF=1)' : '——超时≠测试挂:配 commands.test_timeout_sec 提帽或 test_strategy: module 收窄'}`
1957
2266
  : `测试命令退出码 ${exitCode}`
1958
2267
  // 资源竞争鉴别提示(坑 verify-devserver-port-race):EADDRINUSE/端口占用类失败极可能是
1959
2268
  // 自留 dev server 竞争而非代码问题——输出里明示鉴别路径,防误报 FAIL
@@ -1965,6 +2274,32 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1965
2274
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
1966
2275
 
1967
2276
  const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
2277
+ // 环境缺件降档(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,2026-09-23 实证,
2278
+ // 与 runOneModule/runVerifyLintCheck 同口径):全量测试命令在缺失二进制处中止(&& 链其前
2279
+ // 段全绿)——CNF 环境信号非代码失败,降档 skipped 带修复指引。护栏同 runOneModule:
2280
+ // 判账行集全为 wrapper 噪声才降。
2281
+ if (judged.status === 'failed') {
2282
+ const cnf = detectCommandMissingFailure(output)
2283
+ if (cnf.matched && judged.remainingLines.every(l => CNF_NOISE_LINE_RE.test(String(l)))) {
2284
+ const result = {
2285
+ status: 'skipped',
2286
+ command,
2287
+ exitCode,
2288
+ durationMs,
2289
+ outputTail,
2290
+ reason: `环境缺件:${cnf.binary ? `二进制「${cnf.binary}」` : '命令二进制'}不存在(${cnf.evidence})——非代码失败不拦门;在对应包目录补装依赖(pnpm/npm install、uv sync 等)后恢复实测`,
2291
+ resultPath: null,
2292
+ mode: 'full',
2293
+ fallbackReason,
2294
+ exemptedCount: judged.exemptedCount,
2295
+ failureRemaining: judged.remainingLines,
2296
+ failureExempted: judged.exemptedLines,
2297
+ }
2298
+ console.warn(`⚠️ test 实测环境缺件降档为跳过:${cnf.evidence}——命令链在缺失二进制处中止(&& 链其前段全绿);修复环境(安装依赖)而非改代码`)
2299
+ writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
2300
+ return result
2301
+ }
2302
+ }
1968
2303
  const result = {
1969
2304
  status: judged.status,
1970
2305
  command,
@@ -1988,21 +2323,362 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1988
2323
  * 串行跑命中的模块子集,聚合结果。
1989
2324
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
1990
2325
  */
1991
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [] }) {
2326
+
2327
+ // ── runner 结构推断(2026-09-26-dynamic-test-inference)────────────────────────────
2328
+ // local.yaml 测试命令退役后的执行层:从项目文件(pyproject.toml/uv.lock/package.json/锁文件)
2329
+ // 推断「怎么跑」。就近原则:测试文件最近的清单祖先是它的运行环境(monorepo 子项目各自独立)。
2330
+
2331
+ /** .py 运行环境:自文件目录向上找最近 pyproject.toml/uv.lock 祖先(uv.lock 优先——uv 系环境
2332
+ * 用 `uv run pytest` 防解析到错误解释器,deps-cwd-prefix 实证 aiobotocore 假红家族)。 */
2333
+ function inferPyRunner(projectRoot, pyFile) {
2334
+ let dir = String(pyFile || '').replace(/\\/g, '/')
2335
+ const i = dir.lastIndexOf('/')
2336
+ dir = i === -1 ? '' : dir.slice(0, i)
2337
+ for (;;) {
2338
+ if (existsSync(join(projectRoot, dir, 'uv.lock'))) {
2339
+ return { command: dir ? `cd ${dir} && uv run pytest` : 'uv run pytest', dir: dir || null }
2340
+ }
2341
+ if (existsSync(join(projectRoot, dir, 'pyproject.toml'))) {
2342
+ return { command: dir ? `cd ${dir} && python -m pytest` : 'python -m pytest', dir: dir || null }
2343
+ }
2344
+ if (dir === '' || dir === '.') return { command: 'python -m pytest', dir: null }
2345
+ const j = dir.lastIndexOf('/')
2346
+ dir = j === -1 ? '' : dir.slice(0, j)
2347
+ }
2348
+ }
2349
+
2350
+ /** node 包管理器(最近 package.json 目录的锁文件):pnpm-lock→pnpm / yarn.lock→yarn / 其余 npm */
2351
+ function inferPm(pkgDir) {
2352
+ if (existsSync(join(pkgDir, 'pnpm-lock.yaml'))) return 'pnpm'
2353
+ if (existsSync(join(pkgDir, 'yarn.lock'))) return 'yarn'
2354
+ return 'npm'
2355
+ }
2356
+
2357
+ /** .tsx/.jsx 运行环境:最近 package.json 祖先含 vitest/jest 依赖(或 scripts.test 提及)→
2358
+ * `cd <dir> && <pm> exec <runner>`;找不到 → null(调用方整批 skip 转项目运行器,不造恒败段)。 */
2359
+ function inferJsxRunner(projectRoot, jsxFile) {
2360
+ let dir = String(jsxFile || '').replace(/\\/g, '/')
2361
+ const i = dir.lastIndexOf('/')
2362
+ dir = i === -1 ? '' : dir.slice(0, i)
2363
+ for (;;) {
2364
+ const pkgPath = join(projectRoot, dir, 'package.json')
2365
+ if (existsSync(pkgPath)) {
2366
+ let pkg = null
2367
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { pkg = null }
2368
+ if (pkg) {
2369
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
2370
+ const scriptsTest = String((pkg.scripts && pkg.scripts.test) || '')
2371
+ let runner = null
2372
+ if (deps.vitest || /\bvitest\b/.test(scriptsTest)) runner = 'vitest'
2373
+ else if (deps.jest || /\bjest\b/.test(scriptsTest)) runner = 'jest'
2374
+ if (runner) {
2375
+ const pm = inferPm(join(projectRoot, dir))
2376
+ return { command: dir ? `cd ${dir} && ${pm} exec ${runner}` : `${pm} exec ${runner}`, dir: dir || null }
2377
+ }
2378
+ }
2379
+ }
2380
+ if (dir === '' || dir === '.') return null
2381
+ const j = dir.lastIndexOf('/')
2382
+ dir = j === -1 ? '' : dir.slice(0, j)
2383
+ }
2384
+ }
2385
+
2386
+ /** 全量命令结构推断(显式 test_strategy: full 且无 commands.test 的逃生阀):
2387
+ * 根 package.json scripts.test → `<pm> run test`;否则根 pyproject/uv → pytest 全量;
2388
+ * 均无 → null(调用方 skipped 带指引)。 */
2389
+ function inferFullTestCommand(projectRoot) {
2390
+ const pkgPath = join(projectRoot, 'package.json')
2391
+ if (existsSync(pkgPath)) {
2392
+ try {
2393
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
2394
+ if (pkg.scripts && pkg.scripts.test) return { command: `${inferPm(projectRoot)} run test`, source: 'package.json scripts.test' }
2395
+ } catch { /* 损坏 package.json 落 python 侧/无 */ }
2396
+ }
2397
+ if (existsSync(join(projectRoot, 'uv.lock'))) return { command: 'uv run pytest', source: 'uv.lock' }
2398
+ if (existsSync(join(projectRoot, 'pyproject.toml'))) return { command: 'python -m pytest', source: 'pyproject.toml' }
2399
+ return null
2400
+ }
2401
+
2402
+ /**
2403
+ * deps(auto) 运行器推断 + 公平组卷(2026-09-24 R10 实证修复):
2404
+ * - R10 缺陷①:deps 一律 `node --test` 执行——.py 依赖测试全 SyntaxError 伪败(只能靠豁免放行)。
2405
+ * - R10 缺陷②:字母序 slice(0,30) 截断——前端 .tsx 依赖测试恒被后端 .py 挤出跑面。
2406
+ * 修法:按扩展名分组(.py 一组 / 其余 node --test 一组),组内「本次变更的测试文件优先」排序,
2407
+ * 每组按比例分 30 帽(保底 5);.py 组运行器从命中模块命令推断(含 pytest 的命令取其 pytest
2408
+ * 前缀——如 `cd backend && uv run pytest` 的 `uv run pytest`;无命中模块兜底 python -m pytest)。
2409
+ */
2410
+ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, priorityFiles = [] }) {
2411
+ const norm = (p2) => String(p2).replace(/\\/g, '/')
2412
+ const changedSet = new Set((changedFiles || []).map(norm))
2413
+ // priorityFiles(FR 关联回归面):与变更自身测试同级优先——绑定面钦定的回归不该被字母序挤出跑面
2414
+ const prioritySet = new Set((priorityFiles || []).map(norm))
2415
+ const prio = (f) => (changedSet.has(norm(f)) || prioritySet.has(norm(f))) ? 0 : 1 // 变更/FR 回归测试文件优先
2416
+ const py = deps.filter(f => f.endsWith('.py')).sort((a, b) => (prio(a) > prio(b) ? 1 : prio(a) < prio(b) ? -1 : a.localeCompare(b)))
2417
+ const js = deps.filter(f => !f.endsWith('.py')).sort((a, b) => (prio(a) > prio(b) ? 1 : prio(a) < prio(b) ? -1 : a.localeCompare(b)))
2418
+ const CAP = 30
2419
+ const pyCap = py.length === 0 ? 0 : js.length === 0 ? CAP : Math.max(5, Math.min(CAP - 5, Math.round(CAP * py.length / (py.length + js.length))))
2420
+ const jsCap = CAP - pyCap
2421
+ const pyRun = py.slice(0, pyCap)
2422
+ const jsRun = js.slice(0, jsCap)
2423
+ // .py 运行器推断双源(2026-09-26-dynamic-test-inference):① 命中模块命令串(旧路径,兼容期);
2424
+ // ② 无命中命令时自项目结构——首个 .py 文件最近 pyproject/uv.lock 祖先(uv 优先)。
2425
+ // cd 前缀保留(2026-09-25-deps-cwd-prefix,R15/R16 实证):剥前缀后从 worktree 根跑 uv,根上无
2426
+ // pyproject.toml → uv 解析到无 dev extras 的错环境 → aiobotocore 假红(两轮 known_failures 顶着)。
2427
+ // 前缀在则批次文件路径按该 dir 重定基(模块命令的文件参即 dir 相对口径,如 app/modules/...)。
2428
+ let pyRunner = null
2429
+ for (const h of hits || []) {
2430
+ const cmd = String(h.test || '')
2431
+ const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?pytest)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*pytest)/.exec(cmd)
2432
+ if (m) { pyRunner = m[1].trim(); break }
2433
+ }
2434
+ if (!pyRunner && cwd && pyRun.length > 0) {
2435
+ const inferred = inferPyRunner(cwd, pyRun[0])
2436
+ pyRunner = inferred.command
2437
+ }
2438
+ if (!pyRunner) pyRunner = 'python -m pytest'
2439
+ // JSX 运行器推断(2026-09-26-residual-runner-parity 起 + 结构推断源):.tsx/.jsx 是 node
2440
+ // 原生 type stripping 跑不了的(node --test 直跑恒败——R18 verify 段 ~11 次尝试的坑),
2441
+ // ① 命中命令串提取 vitest/jest(旧路径)② 无命中命令时最近含 vitest/jest 的 package.json;
2442
+ // 双源均提不出则整批 skip 转项目运行器(不制造恒败段)。.ts/.js 照旧 node --test(原生可跑)。
2443
+ // R19(2026-09-27-core-pages-visual-redesign verify 实证,用户授权修复):import 项目测试
2444
+ // 框架(vitest/@playwright/jest/bun:test)的 .ts 文件与 .tsx 同病——node --test 直跑 import
2445
+ // 解析/协议全不符恒败(frontend/src/styles/themes.test.ts + e2e/auth.spec.ts 两轮假红)。
2446
+ // 按文件内容分流:内容命中框架 import 的 .ts/.js 归 jsProject(项目运行器),node:test/
2447
+ // 纯 node 协议照旧 jsNative;文件读不到按 jsNative(保守,与旧口径一致)。
2448
+ const _norm = (p2) => String(p2).replace(/\\/g, '/')
2449
+ const PROJECT_FRAMEWORK_IMPORT_RE =
2450
+ /^\s*import[\s{][^;"']*}?\s*from\s*["'](vitest|@vitest\/|@playwright\/test|jest|bun:test)["']/m
2451
+ const needsProjectByContent = (f) => {
2452
+ try {
2453
+ return PROJECT_FRAMEWORK_IMPORT_RE.test(readFileSync(join(cwd ?? '.', f), 'utf8'))
2454
+ } catch {
2455
+ return false
2456
+ }
2457
+ }
2458
+ const NEEDS_PROJECT_RE = /\.(tsx|jsx)$/
2459
+ const jsProject = jsRun.filter(
2460
+ (f) => NEEDS_PROJECT_RE.test(_norm(f)) || needsProjectByContent(f),
2461
+ )
2462
+ const jsNative = jsRun.filter(
2463
+ (f) => !NEEDS_PROJECT_RE.test(_norm(f)) && !needsProjectByContent(f),
2464
+ )
2465
+ let jsxRunner = null
2466
+ for (const h of hits || []) {
2467
+ const cmd = String(h.test || '')
2468
+ const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?(?:vitest|jest))(?=\s|$)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*(?:vitest|jest)(?:\s|$))/.exec(cmd)
2469
+ if (m) { jsxRunner = m[1].trim(); break }
2470
+ }
2471
+ if (!jsxRunner && cwd && jsProject.length > 0) {
2472
+ const inferred = inferJsxRunner(cwd, jsProject[0])
2473
+ if (inferred) { jsxRunner = inferred.command }
2474
+ }
2475
+ const cdDir = /(?:^|\s)cd\s+(\S+)\s*&&/.exec(pyRunner)?.[1]
2476
+ const rebase = (f) => (cdDir && f.startsWith(cdDir + '/')) ? f.slice(cdDir.length + 1) : f
2477
+ const batches = []
2478
+ if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, dropped: py.length - pyRun.length })
2479
+ if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test ${jsNative.join(' ')}`, count: jsNative.length, dropped: js.length - jsRun.length })
2480
+ if (jsProject.length > 0) {
2481
+ if (jsxRunner) {
2482
+ const isVitest = /vitest/.test(jsxRunner)
2483
+ // jsx 批的 cd 重定基(评审 MEDIUM 清偿):cdDir 只源 pyRunner——jsxRunner 自带 cd 前缀
2484
+ // (cd frontend && pnpm exec vitest)时文件未重定基产双前缀错路径;独立提取 jsxDir。
2485
+ const jsxDir = /(?:^|\s)cd\s+(\S+)\s*&&/.exec(jsxRunner)?.[1]
2486
+ const rebaseJsx = (f) => (jsxDir && f.startsWith(jsxDir + '/')) ? f.slice(jsxDir.length + 1) : f
2487
+ // R20(2026-09-27-thin-display-fix 实证,用户授权修复):Windows cmd 执行器对含
2488
+ // 括号/方括号/空格的路径(src/app/(dashboard)/workspaces/[id]/...)不加引号会以
2489
+ // 「此时不应有 <。」语法错误炸整批——文件参数含特殊字符时逐个 wrap 双引号。
2490
+ const quoteWinPath = (f) => (/[()\[\]\s&^%,;!]/.test(f) ? `"${f}"` : f)
2491
+ batches.push({ name: 'deps(auto-jsx)', short: 'jsx', command: `${jsxRunner} ${isVitest ? 'run ' : ''}${jsProject.map(rebaseJsx).map(quoteWinPath).join(' ')}`, count: jsProject.length, dropped: 0 })
2492
+ } else {
2493
+ batches.push({ name: 'deps(auto-jsx-skip)', short: 'jsx-skip', command: null, count: jsProject.length, dropped: 0, skip: true, files: jsProject, reason: `JSX 测试文件(tsx/jsx,${jsProject.length} 个)node 原生不可跑且项目结构无 vitest/jest 运行器可推断——转项目运行器执行并如实披露,不制造恒败段(R18-SF-full 残差段 11 连败的坑)` })
2494
+ }
2495
+ }
2496
+ return batches
2497
+ }
2498
+
2499
+ export { buildDepsBatches }
2500
+
2501
+ // ── trace residual adapter(2026-09-24-fr-test-readside,fr-test-binding §3.3/§3.6 读侧)──
2502
+ // 锚点集=本变更 task 卡 requirement_ids 并集;残差=trace active 行 tests 并集按保守差集
2503
+ // (仅 deps-auto-subset 文件集可证覆盖,命令型动作一律全补——禁解析命令串猜覆盖面)。
2504
+ // 现选测(decideVerifyTestAction 及各执行分支)逐字保留,残差只在结果层加法(D-002@v1)。
2505
+
2506
+ /** 锚点集:tasks/*.md frontmatter requirement_ids 局部号并集(无 tasks 目录 → 空,fail-open) */
2507
+ export function resolveVerifyAnchorSet({ specBase, changeName }) {
2508
+ const ids = new Set()
2509
+ try {
2510
+ const tasksDir = join(specBase, 'changes', changeName, 'tasks')
2511
+ if (existsSync(tasksDir)) {
2512
+ for (const f of readdirSync(tasksDir).filter(x => /^task-[\d.]+\.md$/.test(x))) {
2513
+ const m = readFileSync(join(tasksDir, f), 'utf8').match(/^requirement_ids:\s*\[([^\]]*)\]/m)
2514
+ if (m) for (const x of m[1].split(',')) { const t = x.trim().replace(/['"]/g, ''); if (t) ids.add(t) }
2515
+ }
2516
+ }
2517
+ } catch { /* fail-open:锚点集空 → 残差空 → 完全沿用现选测 */ }
2518
+ return [...ids]
2519
+ }
2520
+
2521
+ /** 残差解析:active·非 superseded·锚命中的 trace 行 → { anchors, rows, files, dangling } */
2522
+ export function resolveTraceResidual({ specBase, changeName, cwd }) {
2523
+ const anchors = resolveVerifyAnchorSet({ specBase, changeName })
2524
+ if (anchors.length === 0) return { anchors, rows: [], files: [], dangling: [] }
2525
+ const rows = readChangeTrace(join(specBase, 'changes', changeName))
2526
+ .filter(r => anchors.includes(r.anchor) && r.state === 'active' && r.status !== 'superseded')
2527
+ // tests 条目可携带用例锚(2026-09-26-binding-anchor-fidelity)——文件面/悬空判定走剥锚
2528
+ const files = [...new Set(rows.flatMap(r => (r.tests || []).map(testAnchorFile)))].sort()
2529
+ const dangling = files.filter(f => !existsSync(resolve(cwd, f)))
2530
+ return { anchors, rows, files, dangling }
2531
+ }
2532
+
2533
+ /** 残差执行段:按 buildDepsBatches 同口径组卷(扩展名分语言/pytest 前缀自模块命令推断),
2534
+ * 30/块分卷破 deps 面的帽(残差是绑定面钦定必须跑的,不适用发现面防膨胀帽)。 */
2535
+ function runTraceResidual({ cwd, files, hits, knownFailures = [] }) {
2536
+ // 嵌套 test-runner 防污染:孙代 node --test 会误连外层 reporter(NODE_TEST_* 环境变量,
2537
+ // 单测实测:嵌套下挂测被误判 passed)——spawn 前剥除、跑完还原(同步窗口安全;真实门禁
2538
+ // 进程无此变量,零影响)。
2539
+ const savedEnv = { ...process.env }
2540
+ const strippedKeys = []
2541
+ for (const k of Object.keys(process.env)) { if (k.startsWith('NODE_TEST')) { strippedKeys.push(k); delete process.env[k] } }
2542
+ try {
2543
+ return runTraceResidualInner({ cwd, files, hits, knownFailures })
2544
+ } finally {
2545
+ for (const k of strippedKeys) process.env[k] = savedEnv[k]
2546
+ }
2547
+ }
2548
+ function runTraceResidualInner({ cwd, files, hits, knownFailures }) {
2549
+ const norm = (p) => String(p).replace(/\\/g, '/')
2550
+ const segments = []
2551
+ for (let i = 0; i < files.length; i += 30) {
2552
+ const chunk = files.slice(i, i + 30)
2553
+ const batches = buildDepsBatches({ deps: chunk, changedFiles: files, hits, cwd })
2554
+ for (const b of batches) {
2555
+ if (b.skip) {
2556
+ // JSX 无项目运行器批(residual-runner-parity):不跑不拦——残差披露如实记录转办,
2557
+ // 由 agent 用项目运行器执行(verify 段指引已含「命中 warning 复核」面)。
2558
+ segments.push({ runner: b.name, runnerKind: 'project-runner-transfer', files: b.files || [], status: 'skipped', reason: b.reason || '转项目运行器', durationMs: null, outputTail: null })
2559
+ console.warn(`⚠️ ${b.name}:${b.reason}`)
2560
+ continue
2561
+ }
2562
+ const isPy = b.short === 'py'
2563
+ const segFiles = chunk.filter((f) => (norm(f).endsWith('.py') === isPy))
2564
+ const r = runOneModule(b.name, b.command, cwd, knownFailures)
2565
+ segments.push({ runner: b.command, runnerKind: isPy ? 'pytest' : (b.short === 'jsx' ? 'vitest/jest' : 'node --test'), files: segFiles, status: r.status, reason: r.reason || null, durationMs: r.durationMs ?? null, outputTail: r.outputTail || null })
2566
+ }
2567
+ }
2568
+ return segments
2569
+ }
2570
+
2571
+ /** 披露 sidecar(D-005@v1:JSON 真源+console 一行;覆盖式幂等) */
2572
+ export function writeTraceDisclosure({ specBase, changeName, payload }) {
2573
+ const p = join(specBase, 'changes', changeName, 'verify-trace-disclosure.json')
2574
+ writeAtomicSync(p, JSON.stringify({ schemaVersion: 1, change: changeName, ...payload }, null, 2) + '\n')
2575
+ console.log(`📎 trace 披露:锚点 ${payload.anchors.length} 个 / 行 ${(payload.rows || []).length} / 残差 ${(payload.residualFiles || []).length} 文件(可证覆盖来源:${payload.provableSource})→ ${p}`)
2576
+ }
2577
+
2578
+ /** 主入口:悬空 fail-fast(D-004@v1)→ 空残差直通(零行为漂移)→ 披露 → 保守差集 → 残差并入 */
2579
+ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeName, depsAutoFiles, hits, knownFailures }) {
2580
+ let tr
2581
+ try { tr = resolveTraceResidual({ specBase, changeName, cwd }) } catch { return mainResult }
2582
+ if (tr.dangling.length > 0) {
2583
+ return {
2584
+ status: 'failed', command: null, exitCode: 1, durationMs: 0, outputTail: null,
2585
+ reason: `⛔ 测试绑定悬空:锚点集内 active 行引用的 ${tr.dangling.length} 个文件缺失(${tr.dangling.join('、')})——悬空行让绑定表成谎言,比没表更糟(方案 §3.4 写时闸同款)。修复:sillyspec tests --unbind --anchor <锚> --row-id <行>,或 --bind 重绑到现存路径`,
2586
+ resultPath: null, mode: 'trace-dangling', fallbackReason: null,
2587
+ }
2588
+ }
2589
+ if (tr.files.length === 0) return mainResult
2590
+ // 可证覆盖集(2026-09-26-dynamic-test-inference 起):动态子集(旧名 deps-auto-subset 兼容)
2591
+ // 跑的文件面可证覆盖绑定行——残差只补差集;命令型动作(full/显式命令)一律全补。
2592
+ const provable = (action === 'deps-auto-subset' || action === 'dynamic-subset')
2593
+ ? new Set((depsAutoFiles || []).map(f => String(f).replace(/\\/g, '/'))) : new Set()
2594
+ const residualFiles = tr.files.filter(f => !provable.has(f))
2595
+ try {
2596
+ writeTraceDisclosure({
2597
+ specBase, changeName,
2598
+ payload: {
2599
+ anchors: tr.anchors,
2600
+ rows: tr.rows.map(r => ({ anchor: r.anchor, row_id: r.row_id, tests: r.tests })),
2601
+ traceFiles: tr.files,
2602
+ provableSource: (action === 'deps-auto-subset' || action === 'dynamic-subset') ? action : 'none(command-shaped)',
2603
+ residualFiles,
2604
+ action,
2605
+ ranAt: new Date().toISOString(),
2606
+ },
2607
+ })
2608
+ } catch { /* 披露失败不拦门(sidecar 可复跑重写) */ }
2609
+ if (residualFiles.length === 0) return mainResult
2610
+ let segs
2611
+ try {
2612
+ segs = runTraceResidual({ cwd, files: residualFiles, hits, knownFailures })
2613
+ } catch (e) {
2614
+ return { ...mainResult, status: 'failed', reason: `trace 残差段执行异常(fail-closed):${e && e.message ? e.message : e}` }
2615
+ }
2616
+ const failed = segs.filter(x => x.status === 'failed')
2617
+ const mainSkipped = mainResult && mainResult.status === 'skipped'
2618
+ const status = (mainResult && mainResult.status === 'failed') || failed.length > 0 ? 'failed' : 'passed'
2619
+ const mode = mainSkipped ? 'trace-residual' : `${(mainResult && mainResult.mode) || action}+trace(${residualFiles.length})`
2620
+ return {
2621
+ ...mainResult,
2622
+ status,
2623
+ mode,
2624
+ command: [mainResult && mainResult.command, ...segs.map(x => x.runnerKind + '(' + x.files.length + ')')].filter(Boolean).join(' + '),
2625
+ exitCode: status === 'passed' ? 0 : 1,
2626
+ durationMs: ((mainResult && mainResult.durationMs) || 0) + segs.reduce((n, x) => n + (x.durationMs || 0), 0),
2627
+ outputTail: [mainResult && mainResult.outputTail, ...segs.map(x => `── trace-residual ${x.runnerKind} ${x.files.length} 文件 (${x.status}) ──\n${x.outputTail || ''}`)].filter(Boolean).join('\n'),
2628
+ reason: failed.length > 0
2629
+ ? `trace 残差段失败(${failed.map(x => x.runnerKind).join(', ')})${mainResult && mainResult.reason ? ';主跑:' + mainResult.reason : ''}`
2630
+ : (mainResult && mainResult.reason) || null,
2631
+ }
2632
+ }
2633
+
2634
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null }) {
1992
2635
  const subsetStartedAt = Date.now()
1993
2636
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
1994
2637
  // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
1995
2638
  // 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
1996
2639
  // 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
1997
2640
  const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
1998
- if (deps.length > 0) {
1999
- perModule.push(runOneModule('deps(auto)', `node --test ${deps.slice(0, 30).join(' ')}`, cwd, knownFailures))
2000
- console.log(`ℹ️ module 子集已附加依赖测试伪模块 deps(auto):${deps.length} 个(import 变更 src/变更的 test 本体,未被模块命令串覆盖——治硬编码清单腐烂)`)
2641
+ // FR 关联回归源(2026-09-26-dynamic-test-inference 三源之二):active FR 覆盖面(来源变更
2642
+ // patch ∪ 绑定)∩ 本次触碰文件 ≠ ∅ 者,其绑定 tests 解析仓根相对后并入实测面——「需求→
2643
+ // 用例」映射即测试需求。frPre 在场用调用方预计算(runVerifyTestCheck 决策已扫过,免二次
2644
+ // 扫描);fail-open:索引缺失/损坏零影响(三源其余照跑)。
2645
+ let frLinked = []
2646
+ let frReport = null
2647
+ let fr = frPre
2648
+ if (!fr) {
2649
+ try { fr = collectFrLinkedTests({ specBase, changeName, changedFiles: changedFiles || [], projectRoot: cwd }) } catch { fr = null }
2650
+ }
2651
+ if (fr) {
2652
+ if (fr.files.length > 0) {
2653
+ const added = fr.files.filter(f => !deps.includes(f))
2654
+ frLinked = added
2655
+ frReport = { frCount: fr.frHits.length, fileCount: fr.files.length, addedCount: added.length }
2656
+ console.log(`ℹ️ FR 关联回归:触达 ${fr.frHits.length} 条 active FR(${fr.frHits.slice(0, 3).map(h => h.id).join('、')}${fr.frHits.length > 3 ? ' 等' : ''})→ 绑定测试 ${fr.files.length} 个${added.length < fr.files.length ? `(新增并入 ${added.length})` : ''} 进实测面`)
2657
+ }
2658
+ if (fr.unresolved.length > 0) {
2659
+ console.warn(`⚠️ ${fr.unresolved.length} 个 FR 绑定测试路径无法自仓根解析(不入实测面):${fr.unresolved.slice(0, 3).join('、')}${fr.unresolved.length > 3 ? ' 等' : ''}——sillyspec tests repair-paths 可修`)
2660
+ }
2661
+ }
2662
+ const depsAll = [...new Set([...deps, ...frLinked])].sort()
2663
+ let depsBatches = []
2664
+ if (depsAll.length > 0) {
2665
+ depsBatches = buildDepsBatches({ deps: depsAll, changedFiles, hits, cwd, priorityFiles: frLinked })
2666
+ for (const b of depsBatches) {
2667
+ if (b.skip) {
2668
+ // JSX 无项目运行器批(residual-runner-parity):skip 不跑不拦,reason 点名让漏测可见
2669
+ console.warn(`⚠️ ${b.name}:${b.reason}`)
2670
+ perModule.push({ name: b.name, status: 'skipped', reason: b.reason || '转项目运行器', command: null, exitCode: null, durationMs: null, outputTail: null })
2671
+ continue
2672
+ }
2673
+ perModule.push(runOneModule(b.name, b.command, cwd, knownFailures))
2674
+ console.log(`ℹ️ 动态子集批 ${b.name}:${b.count} 个${b.dropped > 0 ? `(超帽弃 ${b.dropped})` : ''}(${b.name === 'deps(auto-py)' ? 'pytest 运行器按最近 pyproject/uv 祖先推断' : b.short === 'jsx' ? 'vitest/jest 按最近 package.json 推断' : 'node --test'}——治 node 跑 .py 伪败与字母序偏科)`)
2675
+ }
2001
2676
  }
2002
2677
  const status = aggregateStatus(perModule)
2003
2678
 
2004
2679
  let command = `module[${hits.map(h => h.name).join(',')}]`
2005
- if (deps.length > 0) command += `+deps(${Math.min(deps.length, 30)})`
2680
+ if (depsBatches.length > 0) command += `+deps(${depsBatches.map(b => `${b.short}${b.count}`).join('+')})`
2681
+ if (frReport) command += `+fr(${frReport.fileCount})`
2006
2682
  const exitCode = status === 'passed' ? 0 : 1
2007
2683
  const durationMs = Date.now() - subsetStartedAt
2008
2684
 
@@ -2012,12 +2688,16 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2012
2688
  .join('\n')
2013
2689
  const reason = status === 'passed'
2014
2690
  ? null
2015
- : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
2016
- // 失败模块的 reason 明细透传(坑 verify-devserver-port-race:EADDRINUSE 资源竞争鉴别
2017
- // 提示在 runOneModule 的 reason 里,不透传会被顶层 reason 吞掉)
2018
- + (perModule.filter(r => r.status === 'failed' && r.reason).some(r => /EADDRINUSE|资源竞争/.test(r.reason))
2019
- ? '。' + perModule.filter(r => r.status === 'failed' && r.reason && /EADDRINUSE|资源竞争/.test(r.reason)).map(r => r.reason).join(';')
2020
- : '')
2691
+ : status === 'skipped'
2692
+ // 环境缺件跳过聚合(2026-09-23 CNF 降档):跑过的模块全绿、缺件模块中止——不硬拦,
2693
+ // reason 点名跳过模块让「本次实测没覆盖到它们」可见(防静默漏测)
2694
+ ? `模块子集含环境缺件跳过(已跑模块全绿):${perModule.filter(r => r.status === 'skipped').map(r => r.name).join('、')}——补装依赖后恢复实测`
2695
+ : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
2696
+ // 失败模块的 reason 明细透传(坑 verify-devserver-port-race:EADDRINUSE 资源竞争鉴别
2697
+ // 提示在 runOneModule 的 reason 里,不透传会被顶层 reason 吞掉)
2698
+ + (perModule.filter(r => r.status === 'failed' && r.reason).some(r => /EADDRINUSE|资源竞争/.test(r.reason))
2699
+ ? '。' + perModule.filter(r => r.status === 'failed' && r.reason && /EADDRINUSE|资源竞争/.test(r.reason)).map(r => r.reason).join(';')
2700
+ : '')
2021
2701
 
2022
2702
  const result = {
2023
2703
  status,
@@ -2027,13 +2707,16 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2027
2707
  outputTail: outputTail.length > OUTPUT_TAIL_CHARS ? '…' + outputTail.slice(-OUTPUT_TAIL_CHARS) : outputTail,
2028
2708
  reason,
2029
2709
  resultPath: null,
2030
- mode: 'module-subset',
2710
+ mode: 'dynamic-subset',
2031
2711
  fallbackReason: null,
2032
2712
  exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
2033
2713
  // 判账行集聚合(坑 verify-test-reconcile-tail-blindspot):合并 tail 会二次截断,行集不截——
2034
2714
  // 每模块台账另见 extra.modules[].failure_remaining(带模块归属)
2035
2715
  failureRemaining: perModule.flatMap(r => r.failureRemaining || []),
2036
2716
  failureExempted: perModule.flatMap(r => r.failureExempted || []),
2717
+ // 逐模块结论(isTimeoutOnlyTestFailure 的单元口径;writeRunResult extra 之外再随结果
2718
+ // 返回,调用方可精确分单元判超时/真挂,不依赖聚合 reason 文本)
2719
+ modules: perModule.map(r => ({ name: r.name, status: r.status, reason: r.reason, durationMs: r.durationMs })),
2037
2720
  }
2038
2721
 
2039
2722
  writeRunResult({
@@ -2736,6 +3419,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2736
3419
  // 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
2737
3420
  const foreignExcludedFiles = []
2738
3421
  let baseAnchor = null
3422
+ let commitWindowFiles = []
2739
3423
 
2740
3424
  if (form === 'worktree') {
2741
3425
  // —— 形态 A:worktree 存活,整链复用(锚点优先级 baselineCommit>actualBaseHash>baseHash、
@@ -2761,9 +3445,37 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2761
3445
  const branch = 'sillyspec/' + changeName
2762
3446
  const branchHash = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', branch + '^{commit}'], { timeout: 30 * 1000 })
2763
3447
  const auditTag = 'sillyspec-audit/' + branch
3448
+ // 第三候选(2026-09-26-reconcile-source-isolation,R18-SF-full 对账死锁实证):分支名约定
3449
+ // sillyspec/<change> 对非约定命名的分支(实验分支 r18/sf-full 等)落空 → B1 全缺 → agent
3450
+ // 发明「暂存物化」自救又被并行会话裸提交扫走(4 种面重建 matched=0 死循环)。硬化:扫
3451
+ // worktrees meta 的 changeName 键匹配取真实 branch(meta.branch 由 worktree 建立时写入)。
3452
+ let metaBranchRef = null
3453
+ if (!(typeof branchHash === 'string' && branchHash.trim()) && !gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 })) {
3454
+ try {
3455
+ const wtBase = join(runtimeRoot || join(sb, '.runtime'), 'worktrees')
3456
+ if (existsSync(wtBase)) {
3457
+ for (const e of readdirSync(wtBase, { withFileTypes: true })) {
3458
+ if (!e.isDirectory()) continue
3459
+ try {
3460
+ const mp = join(wtBase, e.name, 'meta.json')
3461
+ if (!existsSync(mp)) continue
3462
+ const m = JSON.parse(String(readFileSync(mp, 'utf8')).replace(/^\uFEFF/, ''))
3463
+ if (m && m.changeName === changeName && typeof m.branch === 'string' && m.branch.trim()) {
3464
+ if (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', m.branch.trim() + '^{commit}'], { timeout: 30 * 1000 })) {
3465
+ metaBranchRef = m.branch.trim()
3466
+ // 源串在 merge-base 验证成功后 push(评审 P3 清偿:此处早 push 时若 merge-base
3467
+ // 失败——非 main 主分支名——sources 谎报该源在场且 hint 假阴性)
3468
+ }
3469
+ break
3470
+ }
3471
+ } catch { /* 单 meta 损坏试下一个 */ }
3472
+ }
3473
+ }
3474
+ } catch { /* meta 扫描失败静默省略 */ }
3475
+ }
2764
3476
  const diffRef = (typeof branchHash === 'string' && branchHash.trim())
2765
3477
  ? branch
2766
- : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
3478
+ : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : (metaBranchRef || null))
2767
3479
  if (diffRef) {
2768
3480
  // 形态 B 定义下 meta 已删,baseBranch 无从读 → 缺省 'main'(run/prompt.js:764 同缺省);
2769
3481
  // 主分支叫 master 等仓库 merge-base 失败 → 与分支不存在同处置(省略该源)
@@ -2774,6 +3486,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2774
3486
  if (files !== null) {
2775
3487
  diffOk = true
2776
3488
  sources.push('main:diff-merge-base')
3489
+ if (diffRef === metaBranchRef) sources.push(`main:diff-merge-base(meta-branch:${metaBranchRef})`) // merge-base 验证成功才记源(评审 P3)
2777
3490
  for (const f of files) union.add(normalizeReconcilePath(f))
2778
3491
  }
2779
3492
  }
@@ -2794,6 +3507,25 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2794
3507
  }
2795
3508
  for (const f of files) union.add(normalizeReconcilePath(f))
2796
3509
  }
3510
+ // B4 isolation-worktree 提交窗口兜底(2026-09-24 R9/R11 根治:isolated worktree 内
3511
+ // per-task commit 后 porcelain 恒空,B1 分支已删/B2 未提交面零 → declared 13 条全 missing
3512
+ // 四连拦,逼出规则 23 reset 重组。收集 HEAD 近窗提交触及面(--name-only,窗口 20 提交
3513
+ // 与 buildSnapshot commits 同口径;fail-open 静默省略)——**declared-rescue-only**:
3514
+ // 不进全局 union(防其他变更提交文件污染 undeclared 假红),仅供消费方对
3515
+ // 「声明而 missing」的文件做窗口命中救赎。
3516
+ try {
3517
+ // 锚定变更分支/审计 tag(非 blanket 20 提交——base 提交文件不得误救)
3518
+ const rescueRef = (typeof branchHash === 'string' && branchHash.trim()) ? branch : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
3519
+ if (!rescueRef) throw new Error('no-rescue-ref')
3520
+ const recent = gitQuiet(cwd, ['log', '20', '--name-only', '--format=%h', rescueRef], { timeout: 30 * 1000, trim: false })
3521
+ if (typeof recent === 'string' && recent.trim()) {
3522
+ const files = recent.split('\n').map(l => l.trim()).filter(l => l && !/^[0-9a-f]{7,40}$/.test(l))
3523
+ if (files.length > 0) {
3524
+ commitWindowFiles = files
3525
+ sources.push(`main:log-20-commit-window(declared-rescue-only:${rescueRef})`)
3526
+ }
3527
+ }
3528
+ } catch { /* 窗口源失败静默省略 */ }
2797
3529
  // B3 apply-pathspec 兜底(存在则并入,读取失败静默忽略——前两源不受影响)
2798
3530
  const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
2799
3531
  if (existsSync(pathspecFile)) {
@@ -2812,7 +3544,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2812
3544
  }
2813
3545
 
2814
3546
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2815
- return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor }
3547
+ // 多会话推进死锁诊断(reconcile-source-isolation,R18-SF-full 实证):post-apply 形态下
3548
+ // 实际面全空(B1 无 ref + porcelain 无未提交面 + 无 apply-pathspec 兜底)= 典型「主仓被
3549
+ // 并行会话推进」形态(本变更提交/暂存被他人裸提交带走)——历史上 agent 被逼发明「暂存
3550
+ // 物化」自救(touch 文件进共享暂存区),随即又被扫走形成 matched=0 死循环。诊断先行指明
3551
+ // 安全出路,杜绝危险自救;消费方(reconcileTargetFiles/gates)把它带进 notes。
3552
+ const parallelAdvanceHint = (form === 'post-apply' && files.length === 0 && union.size === 0 && !sources.some((s) => s.startsWith('main:diff-merge-base') || s.startsWith('apply-pathspec')))
3553
+ ? '⚠️ actual 源全空(无分支锚点 diff + 主仓无未提交面 + 无 apply-pathspec 兜底)——典型「主仓被并行会话推进」形态(本变更的提交/暂存可能已被他侧裸提交扫走)。切勿用「暂存物化」自救(touch 文件进共享暂存区——会被再次扫走且污染他侧,R18-SF-full 实证 matched=0 死循环);出路:① 恢复/登记本变更分支(worktree meta.json 的 branch 字段)让锚定 diff 生效后重跑 ② 确属全量已在他侧提交:与该会话按 AGENTS 规则 18 对账后以该提交为锚核销'
3554
+ : null
3555
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor, commitWindowFiles, parallelAdvanceHint }
2816
3556
  }
2817
3557
 
2818
3558
  /**
@@ -2959,6 +3699,12 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2959
3699
 
2960
3700
  // —— actual 侧(三源并集,两形态)——
2961
3701
  const actual = resolveReconcileActualFiles({ cwd, specBase: sb, runtimeRoot: rt, changeName })
3702
+ if (actual.parallelAdvanceHint) {
3703
+ // console 直出(评审 P2 清偿):死锁时刻 agent 先见的是 gates 的 missing_declared 红色
3704
+ // 清单(通用修复指引),hint 只落 notes 会被淹没——诊断必须先于/伴随阻断清单可见。
3705
+ console.warn(actual.parallelAdvanceHint)
3706
+ notes.push(actual.parallelAdvanceHint)
3707
+ }
2962
3708
  if (actual.foreignExcluded > 0) {
2963
3709
  notes.push(`主仓 status 捕入的 ${actual.foreignExcluded} 个并行会话声明文件已剔除(不参与本变更对账)`)
2964
3710
  }
@@ -2984,6 +3730,20 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2984
3730
  const actualKeySet = new Set(actual.files.map(pathKey));
2985
3731
  const declaredPaths = [...new Set(decl.declarations.map(d => d.path))].sort()
2986
3732
  const declaredKeySet = new Set(declaredPaths.map(pathKey))
3733
+ // declared-rescue(2026-09-24 R9/R11 根治接线):isolated worktree per-task commit 形态下
3734
+ // porcelain 恒空/分支已删,declared 全落②类假红——近窗提交触及面只用于救赎「声明而
3735
+ // missing」的文件(窗口∩declared 才入 actualKeySet,不扩 undeclared 面)。
3736
+ const windowKeySet = new Set((actual.commitWindowFiles || []).map(pathKey))
3737
+ let rescuedCount = 0
3738
+ for (const path of declaredPaths) {
3739
+ if (!actualKeySet.has(pathKey(path)) && windowKeySet.has(pathKey(path))) {
3740
+ actualKeySet.add(pathKey(path))
3741
+ rescuedCount++
3742
+ }
3743
+ }
3744
+ if (rescuedCount > 0) {
3745
+ notes.push(`对账救赎:${rescuedCount} 个声明文件经近窗提交触及面命中(isolated worktree per-task commit 形态,porcelain 恒空——R9/R11 四连拦根治)`)
3746
+ }
2987
3747
  // ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
2988
3748
  for (const path of declaredPaths) {
2989
3749
  if (actualKeySet.has(pathKey(path))) matched.push(path)