sillyspec 3.29.5 → 3.31.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 (110) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
  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 +7 -105
  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-brainstorm.md +1 -1
  10. package/assets/command-cards/run-quick.md +8 -25
  11. package/package.json +2 -1
  12. package/packages/dashboard/server/executor.js +2 -1
  13. package/packages/dashboard/server/parser.js +7 -7
  14. package/r7-test-env/README-R7-TEST.md +93 -0
  15. package/r7-test-env/demo-project/lib/calc.js +6 -0
  16. package/r7-test-env/demo-project/package.json +1 -0
  17. package/r7-test-env/demo-project/test.js +10 -0
  18. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  19. package/r7-test-env/sillyspec-r7.cmd +3 -0
  20. package/round4/account.mjs +18 -5
  21. package/round4/b1-analyze-task08.mjs +40 -0
  22. package/round4/b1-b1-target-validation.md +32 -0
  23. package/round4/b3a-prompt-audit-result.md +66 -0
  24. package/round4/b3a-prompt-audit.mjs +89 -0
  25. package/round4/optimization-plan.md +165 -0
  26. package/round4/r4-final-report.html +347 -0
  27. package/round4/r5-scenario-matrix.md +46 -0
  28. package/src/change-list.js +9 -0
  29. package/src/classify-change.js +6 -0
  30. package/src/command-cards.js +2 -1
  31. package/src/commit-guard.js +2 -0
  32. package/src/config-schema.js +64 -12
  33. package/src/db.js +35 -4
  34. package/src/decision-distill.js +9 -3
  35. package/src/docs-check.js +1 -1
  36. package/src/doctor-diagnostics.js +44 -2
  37. package/src/flow-draft.js +748 -0
  38. package/src/flow-parity.js +236 -0
  39. package/src/flow-review.js +184 -0
  40. package/src/flow.js +1492 -0
  41. package/src/foreign-declared.js +17 -1
  42. package/src/fr-index.js +396 -22
  43. package/src/fs-atomic.js +5 -1
  44. package/src/git-helper.js +2 -2
  45. package/src/greenfield-bootstrap.js +98 -0
  46. package/src/handoff.js +120 -7
  47. package/src/hunk-attribution.js +191 -0
  48. package/src/index.js +570 -16
  49. package/src/knowledge-classify.js +47 -1
  50. package/src/knowledge-digest.js +162 -0
  51. package/src/knowledge-quicklog.js +246 -0
  52. package/src/machine-draft.js +128 -0
  53. package/src/machine-interface.js +107 -5
  54. package/src/mcp-server.js +1 -0
  55. package/src/module-resolve.js +3 -1
  56. package/src/preview-progress.js +160 -0
  57. package/src/progress/change-registry.js +11 -3
  58. package/src/progress/shared.js +3 -2
  59. package/src/progress.js +56 -13
  60. package/src/quicklog.js +44 -6
  61. package/src/redomain.js +147 -0
  62. package/src/review-material-pack.js +204 -1
  63. package/src/run/archive-distill.js +4 -2
  64. package/src/run/bg-sync.js +67 -0
  65. package/src/run/command.js +123 -10
  66. package/src/run/complete-handlers.js +327 -72
  67. package/src/run/complete.js +350 -71
  68. package/src/run/gate-snapshot-ledger.js +213 -0
  69. package/src/run/gate-snapshot.js +250 -34
  70. package/src/run/gates.js +370 -233
  71. package/src/run/green-cache.js +123 -0
  72. package/src/run/junction-rm.js +70 -0
  73. package/src/run/next.js +14 -0
  74. package/src/run/prompt.js +185 -55
  75. package/src/run/quick-audit.js +185 -12
  76. package/src/run/shared.js +120 -3
  77. package/src/run/stage.js +182 -216
  78. package/src/run/test-ledger.js +264 -0
  79. package/src/run/verify-quality-scan.js +84 -7
  80. package/src/scope-audit.js +6 -1
  81. package/src/sentinel-assertions.js +114 -0
  82. package/src/setup.js +3 -3
  83. package/src/spec-sync.js +41 -1
  84. package/src/stage-contract.js +26 -8
  85. package/src/stage-review-checklist.js +2 -0
  86. package/src/stage-review.js +7 -2
  87. package/src/stage-templates.js +157 -0
  88. package/src/stages/archive.js +2 -0
  89. package/src/stages/brainstorm.js +24 -106
  90. package/src/stages/execute.js +221 -84
  91. package/src/stages/knowledge.js +74 -1
  92. package/src/stages/plan-postcheck.js +352 -1
  93. package/src/stages/plan.js +23 -1
  94. package/src/stages/quick.js +10 -2
  95. package/src/stages/verify.js +4 -2
  96. package/src/sync.js +81 -23
  97. package/src/task-done.js +181 -0
  98. package/src/task-review.js +8 -55
  99. package/src/test-bindings.js +549 -0
  100. package/src/timeline.js +218 -0
  101. package/src/ui-visual.js +175 -0
  102. package/src/verify-draft.js +214 -0
  103. package/src/verify-postcheck.js +860 -158
  104. package/src/verify-probes.js +59 -3
  105. package/src/watcher.js +1031 -0
  106. package/src/worktree-apply.js +44 -4
  107. package/src/worktree-deps.js +22 -8
  108. package/src/worktree-policy.js +42 -0
  109. package/src/worktree.js +102 -15
  110. 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 []
@@ -229,32 +268,53 @@ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
229
268
  }
230
269
 
231
270
  // timeoutMs(2026-09-12 驾驭第十七批 dogfood):门禁快照内 node_modules 经 junction I/O 较慢,
232
- // 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 > env > 默认
233
- 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
234
278
  const startedAt = Date.now()
235
279
  let exitCode = 0
236
280
  let output = ''
237
281
  let reason = null
238
282
  try {
239
- output = execSync(command, {
283
+ output = decodeShellOutput(execSync(command, {
240
284
  cwd,
241
- encoding: 'utf8',
285
+ encoding: 'buffer',
242
286
  timeout: LINT_TIMEOUT_MS,
243
287
  maxBuffer: 32 * 1024 * 1024,
244
288
  stdio: ['ignore', 'pipe', 'pipe'],
245
- })
289
+ windowsHide: true,
290
+ }))
246
291
  } catch (e) {
247
292
  exitCode = typeof e.status === 'number' ? e.status : 1
248
- 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
249
294
  reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= LINT_TIMEOUT_MS
250
295
  ? `lint 命令超时(>${LINT_TIMEOUT_MS / 1000}s)`
251
296
  : `lint 命令退出码 ${exitCode}`
252
297
  }
253
298
  const durationMs = Date.now() - startedAt
254
299
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
255
- const status = exitCode === 0 ? 'passed' : 'failed'
256
- const finalReason = exitCode === 0 ? null : reason
257
- 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 } })
258
318
 
259
319
  return {
260
320
  status,
@@ -618,7 +678,29 @@ export function extractModules(yamlText) {
618
678
  }
619
679
  const name = entry[2]
620
680
  const rest = (entry[3] || '').trim()
621
- 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
+ }
622
704
  // 解析 inline flow mapping: { path: "...", test: "..." }
623
705
  const pathVal = parseFlowValue(rest, 'path')
624
706
  const testVal = parseFlowValue(rest, 'test')
@@ -668,27 +750,65 @@ function parseFlowValue(flowText, key) {
668
750
  * @param {{cwd: string, changedFiles: string[], coveredCommands?: string[]}} args
669
751
  * @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
670
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
+
671
785
  export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
672
786
  if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
673
787
  const norm = (p) => String(p).replace(/\\/g, '/')
674
- const changedSrc = [...new Set(changedFiles.map(norm))]
675
- .filter(p => p.startsWith('src/') && /\.(js|mjs|cjs|ts)$/.test(p))
676
- const changedTests = [...new Set(changedFiles.map(norm))]
677
- .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))
678
792
  if (changedSrc.length === 0 && changedTests.length === 0) return []
679
793
  const covered = coveredCommands.filter(Boolean).join(' ')
680
794
  const found = new Set()
681
- const testDir = join(cwd, 'test')
682
- let entries = []
683
- try { entries = readdirSync(testDir) } catch { entries = [] }
684
- for (const f of entries) {
685
- if (!/\.test\.[cm]?js$/.test(f)) continue
686
- 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
687
805
  let content = ''
688
- try { content = readFileSync(join(testDir, f), 'utf8') } catch { continue }
689
- for (const src of changedSrc) {
690
- 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}`))
691
810
  }
811
+ if (hit) found.add(tf)
692
812
  }
693
813
  for (const t of changedTests) {
694
814
  const fname = t.split('/').pop()
@@ -742,15 +862,18 @@ export function pickHitModules(changedFiles, modules) {
742
862
  * 聚合多个模块测试结果为单一 status。
743
863
  * - 全 passed → 'passed'
744
864
  * - 任一 failed → 'failed'
865
+ * - 无 failed 且非全 passed → 'skipped'(环境缺件跳过单元族,2026-09-23 CNF 降档引入:
866
+ * 跑过的模块全绿、缺件的在二进制处中止——调用方按跳过族口径呈现,不硬拦)
745
867
  * - 空 → null(调用方按 fallback 处理)
746
868
  *
747
- * @param {Array<{status:'passed'|'failed'}>} results
748
- * @returns {'passed'|'failed'|null}
869
+ * @param {Array<{status:'passed'|'failed'|'skipped'}>} results
870
+ * @returns {'passed'|'failed'|'skipped'|null}
749
871
  */
750
872
  export function aggregateStatus(results) {
751
873
  if (!Array.isArray(results) || results.length === 0) return null
874
+ if (results.some(r => r && r.status === 'failed')) return 'failed'
752
875
  if (results.every(r => r && r.status === 'passed')) return 'passed'
753
- return 'failed'
876
+ return 'skipped'
754
877
  }
755
878
 
756
879
  /**
@@ -1004,7 +1127,7 @@ const ARTIFACT_ENV_MISSING_RES = [
1004
1127
  // 条目 H 签名族:pytest-xdist 缺失 → usage error(unrecognized -n/--dist)
1005
1128
  /ERROR:\s*usage:\s*py(?:test|thon)[^\n]*(?:-n\b|--dist|xdist|unrecognized argument)/i,
1006
1129
  /ModuleNotFoundError:\s*No module named\s+'?(?:pytest|pytest_xdist|xdist|_pytest)'?/i,
1007
- /(?: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,
1008
1131
  ]
1009
1132
  const ARTIFACT_MODULE_IMPORT_RES = [
1010
1133
  // overlay 部分态:改动引用方没改被引用方 → 本地顶层包 import 不到(①已先排除 pytest 家族)
@@ -1057,6 +1180,57 @@ export function classifyTestFailureArtifact(result) {
1057
1180
  return empty
1058
1181
  }
1059
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
+
1060
1234
  /**
1061
1235
  * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
1062
1236
  * - skip → 'skip'(真跳过,D-005@v2——不落 full 兜底;evidence-auto 经
@@ -1082,14 +1256,14 @@ export function isTimeoutOnlyTestFailure(testCheck) {
1082
1256
  return units.every(u => /超时/.test(String(u.reason || '')))
1083
1257
  }
1084
1258
 
1085
- export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
1259
+ export function decideVerifyTestAction({ strategy, scopeCount = 0, gitUnavailable = false }) {
1086
1260
  if (strategy === 'skip') return 'skip'
1087
- if (strategy === 'module' && modulesPresent) {
1088
- if (hitCount > 0) return 'module-subset'
1089
- if (hitCount === 0) return 'module-zero-hit-skip'
1090
- return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
1091
- }
1092
- 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'
1093
1267
  }
1094
1268
 
1095
1269
  /**
@@ -1103,16 +1277,17 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1103
1277
  let reason = null
1104
1278
  warnPortRaceBeforeRun(testCommand)
1105
1279
  try {
1106
- output = execSync(testCommand, {
1280
+ output = decodeShellOutput(execSync(testCommand, {
1107
1281
  cwd,
1108
- encoding: 'utf8',
1282
+ encoding: 'buffer',
1109
1283
  timeout: TEST_TIMEOUT_MS,
1110
1284
  maxBuffer: 32 * 1024 * 1024,
1111
1285
  stdio: ['ignore', 'pipe', 'pipe'],
1112
- })
1286
+ windowsHide: true,
1287
+ }))
1113
1288
  } catch (e) {
1114
1289
  exitCode = typeof e.status === 'number' ? e.status : 1
1115
- 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
1116
1291
  reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
1117
1292
  ? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
1118
1293
  : `模块 ${name} 测试退出码 ${exitCode}`
@@ -1124,6 +1299,28 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1124
1299
  const durationMs = Date.now() - startedAt
1125
1300
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
1126
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
+ }
1127
1324
  return {
1128
1325
  name,
1129
1326
  status: judged.status,
@@ -1494,7 +1691,11 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1494
1691
  * resultPath: string|null,
1495
1692
  * }}
1496
1693
  */
1497
- 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 = 旧行为零变化。
1498
1699
  // 信任边界声明(体检 SEC-02 核实):本函数三处 execSync(command) 的命令只来源于
1499
1700
  // ① 主仓 specBase(resolveSpecDir → 主仓 .sillyspec/local.yaml)② 跨仓仓根
1500
1701
  // <repo>/.sillyspec/local.yaml——均为仓库自有配置(与 `npm test` 同信任级:跑测试
@@ -1513,56 +1714,87 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1513
1714
  console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(local.yaml)`)
1514
1715
  }
1515
1716
 
1516
- // —— 缺省收窄(ql-20260920-010 修复一 c,对撞三轮 68 分钟单步构成之一=缺省全量)——
1517
- // modules: 已配置且未显式 test_strategy → 缺省按 module 子集实测。未配置 modules: 的仓
1518
- // 保持缺省 full 零打扰;显式 test_strategy: full 是用户有意跑全量,不受影响。
1519
- let defaultedToModule = false
1520
- if (rawStrategy === null && extractModules(yamlText)) {
1521
- defaultedToModule = true
1522
- 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 块可删')
1523
1725
  }
1524
1726
 
1525
- // —— evidence-auto 生效策略解析(D-005@v2 / task-11)——
1526
- // 按变更目录 module-impact.md 影响面取生效策略(行为→module、纯文档/门禁→skip、
1527
- // 缺失/不可解析→降级 module 并注记)再进既有链路;full/module/skip/缺省四路径
1528
- // 不经此分支(消费语义逐字不变)。
1529
- 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
1530
1732
  let evidenceAuto = null
1733
+ let eaNote = ''
1531
1734
  if (rawStrategy === 'evidence-auto') {
1532
1735
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
1533
1736
  const resolution = resolveTestStrategy({ yamlText, changeDir })
1534
1737
  strategy = resolution.strategy
1535
1738
  evidenceAuto = resolution.evidence_auto_recommendation
1739
+ eaNote = '(生效策略来自 test_strategy: evidence-auto 推荐)'
1536
1740
  }
1537
- // evidence-auto 解析为 module 后的 hint/reason 追加注记(full/module 路径为空串,输出逐字不变)
1538
- const eaNote = rawStrategy === 'evidence-auto' ? '(生效策略来自 test_strategy: evidence-auto 推荐)' : ''
1539
-
1540
- // —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
1541
- // resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
1542
- // 注:module 子集策略只用主仓 diff(跨仓仓不参与 module 子集,design §6 + §5.4),
1543
- // 故此处不传 ctx(避免跨仓路径误命中主仓 module 映射)。
1544
- let modulesPresent = false
1545
- let hitCount = 0
1546
- let hits = []
1547
- let lastChangedFiles = [] // 0 命中诊断用(diff 文件样例可见性,坑 module-path-layout-mismatch)
1548
1741
  if (strategy === 'module') {
1549
- const modules = extractModules(yamlText)
1550
- if (modules) {
1551
- modulesPresent = true
1552
- // includeWorkingTree(坑 module-subset-zero-hit-uncommitted):子代理不 commit 的改动
1553
- // 也参与 module 命中判定,0 命中跳过不再误伤 worktree 未提交的真实变更
1554
- const changedFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
1555
- lastChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
1556
- if (changedFiles === null) {
1557
- 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
1558
1764
  } else {
1559
- hits = pickHitModules(changedFiles, modules)
1560
- 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
1561
1780
  }
1562
1781
  }
1563
1782
  }
1564
1783
 
1565
- 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 })
1566
1798
  let mainResult
1567
1799
  if (action === 'skip') {
1568
1800
  // —— skip 真跳过(D-005@v2 / R-07)——
@@ -1571,7 +1803,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1571
1803
  // evidence-auto→skip:测试不在推荐组合内(module-impact.md 无行为类影响),
1572
1804
  // reason 附推荐依据与否决路径。
1573
1805
  const reason = rawStrategy === 'skip'
1574
- ? '测试已按 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。'
1575
1807
  : `测试已按 test_strategy=evidence-auto 推荐跳过:${evidenceAuto && evidenceAuto.summary ? evidenceAuto.summary : 'module-impact.md 判定无行为类影响'}。本次 verify 结论不含测试客观核验。`
1576
1808
  mainResult = {
1577
1809
  status: 'skipped',
@@ -1601,53 +1833,39 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1601
1833
  } : {}),
1602
1834
  },
1603
1835
  })
1604
- } else if (action === 'module-subset') {
1605
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
1606
- } else if (action === 'module-zero-hit-skip') {
1607
- // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
1608
- // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
1609
- // 诊断可见性(坑 module-path-layout-mismatch,2026-08-22 实证:0 命中「靠第一次跑过的
1610
- // 记录兜底」——为何没命中完全黑箱):落 modules 配置 path vs diff 文件样例,配置前缀
1611
- // 对不上(如 packages/frontend vs frontend/)一眼可见
1612
- const modules_ = extractModules(yamlText) || {}
1613
- const diagLines = [
1614
- `已配置 modules(${Object.keys(modules_).length} 个): ${Object.entries(modules_).map(([k, m]) => `${k}→${m.path}`).join('、') || '(无)'}`,
1615
- `本次 diff(${lastChangedFiles.length} 个文件,前 5): ${lastChangedFiles.slice(0, 5).join(', ') || '(空)'}`,
1616
- ]
1617
- console.warn(`⚠️ 模块 0 命中诊断(对照 path 前缀与 diff 布局是否一致,如 packages/<name> vs <name>/):`)
1618
- for (const l of diagLines) console.warn(` ${l}`)
1619
- // 变更测试兜底(ql-20260919-007,2026-09-19 实测踩:test-only 变更 0 模块命中时裸 skip,
1620
- // quick --done 的 test 门禁沦为「据自报告判定」虚设):diff 含测试文件时按 deps(auto)
1621
- // 同款口径(discoverModuleDependentTests)直接跑「变更的测试文件」——范围仍限本次变更
1622
- // 不回退全量(0 命中防超时/预存失败的初衷不变);无测试文件维持裸 skip(docs-only 等
1623
- // 变更无测试面)。hits 传空数组:runModuleSubset 内部同样经 deps(auto) 执行并落台账。
1624
- const zeroHitDeps = discoverModuleDependentTests({ cwd, changedFiles: lastChangedFiles, coveredCommands: [] })
1625
- if (zeroHitDeps.length > 0) {
1626
- console.log(`ℹ️ 模块 0 命中但 diff 含 ${zeroHitDeps.length} 个测试文件——按变更测试子集实测(不回退全量、不再裸 skip)`)
1627
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles })
1628
- } else {
1629
- mainResult = {
1630
- status: 'skipped',
1631
- command: null,
1632
- exitCode: null,
1633
- durationMs: null,
1634
- outputTail: null,
1635
- reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
1636
- ` 诊断:${diagLines.join(';')}` + eaNote
1637
- + (defaultedToModule ? '(注:module 为缺省收窄——local.yaml 未显式配置 test_strategy;常跑全量请显式写 test_strategy: full)' : ''),
1638
- resultPath: null,
1639
- mode: 'module-zero-hit',
1640
- fallbackReason: null,
1641
- }
1642
- }
1643
- } else {
1644
- // —— 全量路径(full / module 无块 / module git 不可用)——
1645
- // fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
1646
- // evidence-auto 解析为 module 后落全量兜底时追加推荐来源注记(eaNote;其余路径空串零变化)。
1647
- let fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
1648
- if (fallbackReason && eaNote) fallbackReason = fallbackReason + eaNote
1649
- 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
1650
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) } })
1651
1869
  }
1652
1870
 
1653
1871
  // —— 跨仓仓 per-repo cwd 跑 full npm test(task-06 / D-004 / design §5.4 + §6 A6)——
@@ -1658,6 +1876,9 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1658
1876
  // 主仓 skipped(test_strategy: skip / evidence-auto 推荐跳过 / module 0 命中)→ 进短路档:
1659
1877
  // 未自配 commands.test 的跨仓不再 fallback npm test 假败(task-05 / FR-07,见
1660
1878
  // runCrossRepoTestUnderMainSkip 逐仓三态)。
1879
+ // ── trace residual 并入(读侧):悬空 fail-fast / 空残差直通 / 保守差集 / 残差段加法 ──
1880
+ // 可证覆盖集=动态子集实际跑的三源并集(scopeFiles);命令型动作(full/skip)照旧全补。
1881
+ mainResult = applyTraceResidual({ mainResult, action, cwd, specBase, changeName, depsAutoFiles: scopeFiles, hits: [], knownFailures })
1661
1882
  return mergeCrossRepoResults(mainResult, ctx)
1662
1883
  }
1663
1884
 
@@ -1850,6 +2071,7 @@ function runCrossRepoFullTest(entry) {
1850
2071
  timeout: TEST_TIMEOUT_MS,
1851
2072
  maxBuffer: 32 * 1024 * 1024,
1852
2073
  stdio: ['ignore', 'pipe', 'pipe'],
2074
+ windowsHide: true,
1853
2075
  })
1854
2076
  } catch (e) {
1855
2077
  exitCode = typeof e.status === 'number' ? e.status : 1
@@ -1860,6 +2082,21 @@ function runCrossRepoFullTest(entry) {
1860
2082
  }
1861
2083
  const durationMs = Date.now() - startedAt
1862
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
+ }
1863
2100
  const judged = judgeWithKnownFailures(exitCode, output, reason, crossKnownFailures)
1864
2101
 
1865
2102
  return {
@@ -1950,7 +2187,7 @@ function isPortOccupiedSync(port) {
1950
2187
  try {
1951
2188
  const r = spawnSync(process.execPath, ['-e',
1952
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)`],
1953
- { encoding: 'utf8', timeout: 4000 })
2190
+ { encoding: 'utf8', timeout: 4000, windowsHide: true })
1954
2191
  return (r.stdout || '').trim().endsWith('Y')
1955
2192
  } catch { return false }
1956
2193
  }
@@ -1973,42 +2210,59 @@ function warnPortRaceBeforeRun(command) {
1973
2210
  }
1974
2211
 
1975
2212
  function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
1976
- const command = extractTestCommand(yamlText)
1977
-
2213
+ let command = extractTestCommand(yamlText)
2214
+ let inferredNote = null
1978
2215
  if (!command) {
1979
- return {
1980
- status: 'skipped',
1981
- command: null,
1982
- exitCode: null,
1983
- durationMs: null,
1984
- outputTail: null,
1985
- reason: yamlText
1986
- ? 'local.yaml 未配置 commands.test(或标记 unavailable)'
1987
- : `local.yaml 不存在(${localYamlPath})`,
1988
- resultPath: null,
1989
- mode: 'full',
1990
- 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
+ }
1991
2239
  }
1992
2240
  }
1993
2241
 
1994
2242
  const startedAt = Date.now()
2243
+ const timeoutMs = resolveTestTimeoutMs(yamlText)
1995
2244
  let exitCode = 0
1996
2245
  let output = ''
1997
2246
  let reason = null
1998
2247
  warnPortRaceBeforeRun(command)
1999
2248
  try {
2000
- output = execSync(command, {
2249
+ output = decodeShellOutput(execSync(command, {
2001
2250
  cwd,
2002
- encoding: 'utf8',
2003
- timeout: TEST_TIMEOUT_MS,
2251
+ encoding: 'buffer',
2252
+ timeout: timeoutMs,
2004
2253
  maxBuffer: 32 * 1024 * 1024,
2005
2254
  stdio: ['ignore', 'pipe', 'pipe'],
2006
- })
2255
+ windowsHide: true,
2256
+ }))
2007
2257
  } catch (e) {
2008
2258
  exitCode = typeof e.status === 'number' ? e.status : 1
2009
- output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
2010
- reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
2011
- ? `测试命令超时(>${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 收窄'}`
2012
2266
  : `测试命令退出码 ${exitCode}`
2013
2267
  // 资源竞争鉴别提示(坑 verify-devserver-port-race):EADDRINUSE/端口占用类失败极可能是
2014
2268
  // 自留 dev server 竞争而非代码问题——输出里明示鉴别路径,防误报 FAIL
@@ -2020,6 +2274,32 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
2020
2274
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
2021
2275
 
2022
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
+ }
2023
2303
  const result = {
2024
2304
  status: judged.status,
2025
2305
  command,
@@ -2043,21 +2323,362 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
2043
2323
  * 串行跑命中的模块子集,聚合结果。
2044
2324
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
2045
2325
  */
2046
- 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 }) {
2047
2635
  const subsetStartedAt = Date.now()
2048
2636
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
2049
2637
  // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
2050
2638
  // 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
2051
2639
  // 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
2052
2640
  const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
2053
- if (deps.length > 0) {
2054
- perModule.push(runOneModule('deps(auto)', `node --test ${deps.slice(0, 30).join(' ')}`, cwd, knownFailures))
2055
- 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
+ }
2056
2676
  }
2057
2677
  const status = aggregateStatus(perModule)
2058
2678
 
2059
2679
  let command = `module[${hits.map(h => h.name).join(',')}]`
2060
- 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})`
2061
2682
  const exitCode = status === 'passed' ? 0 : 1
2062
2683
  const durationMs = Date.now() - subsetStartedAt
2063
2684
 
@@ -2067,12 +2688,16 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2067
2688
  .join('\n')
2068
2689
  const reason = status === 'passed'
2069
2690
  ? null
2070
- : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
2071
- // 失败模块的 reason 明细透传(坑 verify-devserver-port-race:EADDRINUSE 资源竞争鉴别
2072
- // 提示在 runOneModule 的 reason 里,不透传会被顶层 reason 吞掉)
2073
- + (perModule.filter(r => r.status === 'failed' && r.reason).some(r => /EADDRINUSE|资源竞争/.test(r.reason))
2074
- ? '。' + perModule.filter(r => r.status === 'failed' && r.reason && /EADDRINUSE|资源竞争/.test(r.reason)).map(r => r.reason).join(';')
2075
- : '')
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
+ : '')
2076
2701
 
2077
2702
  const result = {
2078
2703
  status,
@@ -2082,7 +2707,7 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2082
2707
  outputTail: outputTail.length > OUTPUT_TAIL_CHARS ? '…' + outputTail.slice(-OUTPUT_TAIL_CHARS) : outputTail,
2083
2708
  reason,
2084
2709
  resultPath: null,
2085
- mode: 'module-subset',
2710
+ mode: 'dynamic-subset',
2086
2711
  fallbackReason: null,
2087
2712
  exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
2088
2713
  // 判账行集聚合(坑 verify-test-reconcile-tail-blindspot):合并 tail 会二次截断,行集不截——
@@ -2794,6 +3419,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2794
3419
  // 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
2795
3420
  const foreignExcludedFiles = []
2796
3421
  let baseAnchor = null
3422
+ let commitWindowFiles = []
2797
3423
 
2798
3424
  if (form === 'worktree') {
2799
3425
  // —— 形态 A:worktree 存活,整链复用(锚点优先级 baselineCommit>actualBaseHash>baseHash、
@@ -2819,9 +3445,37 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2819
3445
  const branch = 'sillyspec/' + changeName
2820
3446
  const branchHash = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', branch + '^{commit}'], { timeout: 30 * 1000 })
2821
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
+ }
2822
3476
  const diffRef = (typeof branchHash === 'string' && branchHash.trim())
2823
3477
  ? branch
2824
- : (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))
2825
3479
  if (diffRef) {
2826
3480
  // 形态 B 定义下 meta 已删,baseBranch 无从读 → 缺省 'main'(run/prompt.js:764 同缺省);
2827
3481
  // 主分支叫 master 等仓库 merge-base 失败 → 与分支不存在同处置(省略该源)
@@ -2832,6 +3486,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2832
3486
  if (files !== null) {
2833
3487
  diffOk = true
2834
3488
  sources.push('main:diff-merge-base')
3489
+ if (diffRef === metaBranchRef) sources.push(`main:diff-merge-base(meta-branch:${metaBranchRef})`) // merge-base 验证成功才记源(评审 P3)
2835
3490
  for (const f of files) union.add(normalizeReconcilePath(f))
2836
3491
  }
2837
3492
  }
@@ -2852,6 +3507,25 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2852
3507
  }
2853
3508
  for (const f of files) union.add(normalizeReconcilePath(f))
2854
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 { /* 窗口源失败静默省略 */ }
2855
3529
  // B3 apply-pathspec 兜底(存在则并入,读取失败静默忽略——前两源不受影响)
2856
3530
  const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
2857
3531
  if (existsSync(pathspecFile)) {
@@ -2870,7 +3544,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2870
3544
  }
2871
3545
 
2872
3546
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2873
- 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 }
2874
3556
  }
2875
3557
 
2876
3558
  /**
@@ -3017,6 +3699,12 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
3017
3699
 
3018
3700
  // —— actual 侧(三源并集,两形态)——
3019
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
+ }
3020
3708
  if (actual.foreignExcluded > 0) {
3021
3709
  notes.push(`主仓 status 捕入的 ${actual.foreignExcluded} 个并行会话声明文件已剔除(不参与本变更对账)`)
3022
3710
  }
@@ -3042,6 +3730,20 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
3042
3730
  const actualKeySet = new Set(actual.files.map(pathKey));
3043
3731
  const declaredPaths = [...new Set(decl.declarations.map(d => d.path))].sort()
3044
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
+ }
3045
3747
  // ①交集(计数进 evidence):唯一路径口径(多卡声明同文件只计一次)
3046
3748
  for (const path of declaredPaths) {
3047
3749
  if (actualKeySet.has(pathKey(path))) matched.push(path)