sillyspec 3.31.0 → 3.32.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 (74) 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 +10 -6
  5. package/.claude/skills/sillyspec-quick/SKILL.md +105 -7
  6. package/.zcodeignore +66 -0
  7. package/AGENTS.md +16 -16
  8. package/assets/command-cards/flow.md +9 -3
  9. package/assets/command-cards/run-quick.md +4 -1
  10. package/package.json +2 -2
  11. package/src/agent-session-log.js +27 -5
  12. package/src/config-schema.js +21 -0
  13. package/src/datetime.js +51 -0
  14. package/src/db.js +10 -3
  15. package/src/decision-distill.js +21 -12
  16. package/src/design-facts.js +46 -6
  17. package/src/flow-draft.js +468 -95
  18. package/src/flow-parity.js +62 -4
  19. package/src/flow-review.js +136 -6
  20. package/src/flow.js +729 -125
  21. package/src/foreign-declared.js +71 -3
  22. package/src/fr-index.js +148 -162
  23. package/src/git-helper.js +16 -3
  24. package/src/hooks/worktree-guard.js +16 -5
  25. package/src/hunk-attribution.js +28 -5
  26. package/src/index.js +186 -19
  27. package/src/knowledge-classify.js +3 -1
  28. package/src/knowledge-digest.js +55 -26
  29. package/src/knowledge-match.js +135 -7
  30. package/src/knowledge-stats.js +66 -4
  31. package/src/knowledge-vector.js +160 -0
  32. package/src/machine-interface.js +90 -2
  33. package/src/module-impact.js +22 -1
  34. package/src/modules.js +9 -0
  35. package/src/progress/change-registry.js +20 -0
  36. package/src/progress/stage-machine.js +4 -7
  37. package/src/progress.js +8 -1
  38. package/src/quicklog.js +20 -0
  39. package/src/redomain.js +23 -7
  40. package/src/route-hindsight.js +276 -0
  41. package/src/run/archive-distill.js +3 -1
  42. package/src/run/command.js +65 -12
  43. package/src/run/complete-handlers.js +219 -5
  44. package/src/run/complete.js +77 -11
  45. package/src/run/gate-snapshot.js +15 -0
  46. package/src/run/gates.js +60 -4
  47. package/src/run/prompt.js +159 -31
  48. package/src/run/quick-audit.js +66 -7
  49. package/src/run/shared.js +6 -11
  50. package/src/run/stage.js +5 -1
  51. package/src/run/verify-quality-scan.js +84 -0
  52. package/src/scan-facts.js +2 -1
  53. package/src/scope-audit.js +9 -2
  54. package/src/sentinel-assertions.js +67 -16
  55. package/src/stage-contract-engine.js +4 -1
  56. package/src/stage-contract-spec.js +103 -4
  57. package/src/stage-contract.js +55 -7
  58. package/src/stages/brainstorm.js +27 -14
  59. package/src/stages/knowledge.js +4 -3
  60. package/src/stages/plan-postcheck.js +98 -3
  61. package/src/stages/plan.js +22 -2
  62. package/src/stages/quick.js +1 -1
  63. package/src/stages/verify.js +2 -1
  64. package/src/task-review.js +1 -1
  65. package/src/task-tick.js +121 -0
  66. package/src/taskcard-frontmatter.js +31 -0
  67. package/src/ui-visual.js +43 -8
  68. package/src/verify-postcheck.js +509 -37
  69. package/src/verify-probes.js +216 -5
  70. package/src/watcher.js +1067 -1031
  71. package/src/worktree-apply.js +9 -1
  72. package/src/worktree.js +21 -6
  73. package/templates/agents-instruction.md +15 -15
  74. package/templates/prompts/taskcard-rules.md +1 -0
@@ -751,11 +751,16 @@ function parseFlowValue(flowText, key) {
751
751
  * @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
752
752
  */
753
753
  /**
754
- * 测试文件判定(polyglot,2026-09-24 deps-auto 泛化):JS/TS 测试(*.test/spec.[cm]js/ts)
755
- * + Python 测试(test_*.py / *_test.py),任意目录深度(tests?/ 目录或散置均可)。
754
+ * 测试文件判定(polyglot,2026-09-24 deps-auto 泛化):JS/TS 测试
755
+ * (*.test/spec.[cm]js/ts/jsx/tsx)+ Python 测试(test_*.py / *_test.py),
756
+ * 任意目录深度(tests?/ 目录或散置均可)。
757
+ * 2026-09-30 补 jsx|tsx(坑 dynamic-deps-e2e-vitest-exclude-false-red,用户授权修复):
758
+ * 原正则漏配 .test.tsx/.spec.jsx——changedTests 拆分把自家 tsx 测试当 src、changedSrc
759
+ * 内容匹配又因 import 走别名而不中,自家单测反而不进依赖面(而 .spec.ts 的 e2e 文件
760
+ * 反倒经 changedTests 直入,被 vitest exclude 拦成假红)。
756
761
  */
757
762
  function isTestFilePath(p) {
758
- return /\.(test|spec)\.[cm]?(js|ts)$/.test(p) || /(^|\/)test_[^\/]+\.py$/.test(p) || /(^|\/)[^\/]+_test\.py$/.test(p)
763
+ return /\.(test|spec)\.[cm]?(js|jsx|ts|tsx)$/.test(p) || /(^|\/)test_[^\/]+\.py$/.test(p) || /(^|\/)[^\/]+_test\.py$/.test(p)
759
764
  }
760
765
 
761
766
  /** 递归收集测试文件(跳过 node_modules/.venv/.git/dist/build/.runtime/.sillyspec,深度帽 6 层防野目录)。 */
@@ -782,6 +787,58 @@ function pythonImportCandidates(changedPy) {
782
787
  return [...out]
783
788
  }
784
789
 
790
+ /**
791
+ * 剥注释(字符串感知状态机)——依赖命中只认代码内引用(坑
792
+ * dynamic-deps-e2e-vitest-exclude-false-red,2026-09-30 multi-agent-platform 实证,
793
+ * 用户授权修复):原裸子串 content.includes(src) 连注释一起扫——e2e 用例**注释**里
794
+ * 字面引用源文件完整路径(出处标注)被误判成 import 依赖边,端到端用例进单测运行器
795
+ * 批又被 exclude 面拦成恒假红。剥注释后匹配:import/require/dynamic import/vi.mock
796
+ * 等代码内引用(含引号内路径串)照常命中,注释引用不再算依赖边。
797
+ * 状态机:单双引号/模板串内的注释起始符是字面量不剥;反斜杠转义不闭合串。
798
+ * 近似边界(可接受,方向=少误报):模板串 ${} 内嵌套注释/字符串不识别——最坏保留
799
+ * 一段注释(假阳性边缘存续)或剥掉嵌套串内容(假阴性),主场景(顶层注释块/行注释)全覆盖。
800
+ * @param {string} content 源文本
801
+ * @param {boolean} isPy Python 语义(# 行注释);JS 系走 // 与块注释
802
+ */
803
+ function stripCommentsForDepMatch(content, isPy = false) {
804
+ const n = content.length
805
+ let out = ''
806
+ let i = 0
807
+ let quote = null // 当前字符串引号字符(' " `),null=代码态
808
+ while (i < n) {
809
+ const c = content[i]
810
+ const next = i + 1 < n ? content[i + 1] : ''
811
+ if (quote) {
812
+ out += c
813
+ if (c === '\\') {
814
+ if (i + 1 < n) { out += next; i += 2; continue }
815
+ } else if (c === quote) {
816
+ quote = null
817
+ }
818
+ i++
819
+ continue
820
+ }
821
+ if (c === '\'' || c === '"' || c === '`') { quote = c; out += c; i++; continue }
822
+ if (!isPy && c === '/' && next === '/') {
823
+ while (i < n && content[i] !== '\n') i++
824
+ continue // 行注释整段剥(换行符留给后续行)
825
+ }
826
+ if (!isPy && c === '/' && next === '*') {
827
+ i += 2
828
+ while (i < n && !(content[i] === '*' && content[i + 1] === '/')) i++
829
+ i += 2
830
+ continue
831
+ }
832
+ if (isPy && c === '#') {
833
+ while (i < n && content[i] !== '\n') i++
834
+ continue
835
+ }
836
+ out += c
837
+ i++
838
+ }
839
+ return out
840
+ }
841
+
785
842
  export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
786
843
  if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
787
844
  const norm = (p) => String(p).replace(/\\/g, '/')
@@ -804,9 +861,13 @@ export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommand
804
861
  if (covered.includes(fname) || covered.includes(tf)) continue
805
862
  let content = ''
806
863
  try { content = readFileSync(join(cwd, tf), 'utf8') } catch { continue }
807
- let hit = jsSrc.some(src => content.includes(src))
864
+ // 2026-09-30(坑 dynamic-deps-e2e-vitest-exclude-false-red,用户授权修复):依赖命中
865
+ // 改在剥注释后的内容上做——注释里的路径/导入串字面引用不再算依赖边(字符串感知剥离,
866
+ // 代码内 import/require/dynamic import/vi.mock 引用照常命中)。
867
+ const contentCode = stripCommentsForDepMatch(content, tf.endsWith('.py'))
868
+ let hit = jsSrc.some(src => contentCode.includes(src))
808
869
  if (!hit) {
809
- hit = [...pyCandidates].some(d => content.includes(`from ${d}`) || content.includes(`import ${d}`))
870
+ hit = [...pyCandidates].some(d => contentCode.includes(`from ${d}`) || contentCode.includes(`import ${d}`))
810
871
  }
811
872
  if (hit) found.add(tf)
812
873
  }
@@ -938,7 +999,7 @@ export function extractKnownFailures(yamlText) {
938
999
  // 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
939
1000
  // "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
940
1001
  // ×(U+00D7) 是 vitest 失败行的实际前缀标记(✕✗✘ 是 jest/mocha 形态)。
941
- const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|×|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
1002
+ const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✖|✕|✗|✘|×|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
942
1003
  // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、vitest "Test Files N failed"/"Tests N failed | M passed"
943
1004
  // (无冒号形态)、裸 "N failed/passed"、go "FAILED in Ns"、pnpm "ELIFECYCLE Test failed" 退出横幅等。
944
1005
  const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|^\s*(?:test\s+files|tests?)\s+\d|failed\s+in\s+\d|failed\s+tests\s+\d|elifecycle)/i
@@ -995,6 +1056,50 @@ const ADVISORY_LINE_RE = /^[ \t]*(?:⚠️?|ℹ️?|🔄)/
995
1056
  const FAIL_COMPOUND_NEUTRALIZE_RE = /fail-(?:closed|open|soft|safe|loud|fast)/gi
996
1057
  // ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
997
1058
  const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
1059
+ // 泛用裸模式(2026-09-28-known-failures-hardening):整条模式文本是失败标记词本身或极短标点/
1060
+ // 符号串的裸子串,豁免资格停用(须改锚定式 ^…)——防「裸 "--- " 吞 go --- FAIL: 行」「裸
1061
+ // "AssertionError" 吞真实断言行」(M1 评审实证)。文件名/用例名等具体模式(主用例:按名豁免
1062
+ // 预存失败测试)不在此列,不受限。
1063
+ /**
1064
+ * 嵌套测试环境清洗(2026-09-28-tap-judge 实证根因 + 本变更具象为共用单点):父级 node:test
1065
+ * 进程带 NODE_TEST_CONTEXT env,execSync 全量继承会让内层 node --test 误入 child 模式
1066
+ * stdout 全空——所有跑测试命令的 execSync 调用点统一经此剥离(普通进程本无此键零变化)。
1067
+ */
1068
+ // 摘 export(2026-09-29:lint 22e-b 死导出——src+test 其余文件零引用,本文件内三处调用保留)
1069
+ function stripNestedTestEnv(env = process.env) {
1070
+ const next = { ...env }
1071
+ delete next.NODE_TEST_CONTEXT
1072
+ return next
1073
+ }
1074
+
1075
+ const GENERIC_BARE_PATTERN_RE = /^(?:[-=_~*#·✖✕✗✘×✗\s]{1,6}|assertionerror|error|fail|failed|failure|failing|not ok|exception|traceback|panic|assert|error:|fail:)$/i
1076
+
1077
+ /** 豁免模式编译(partitionFailures 与 TAP 判账共用单点,2026-09-28-tap-judge):锚定式(^…/$…
1078
+ * 对 trim 后整行正则)/裸子串(兼容语义),泛用裸模式停用。 */
1079
+ function buildExemptPats(knownFailures) {
1080
+ return (knownFailures || [])
1081
+ .map(p => String(p).replace(ANSI_RE, '').trim())
1082
+ .filter(Boolean)
1083
+ .map(p => {
1084
+ const anchored = p.startsWith('^') || p.endsWith('$')
1085
+ if (anchored) { try { return { sub: null, regex: new RegExp(p) } } catch { return { sub: p.toLowerCase(), regex: null } } }
1086
+ if (GENERIC_BARE_PATTERN_RE.test(p)) return { sub: null, regex: null, refused: true }
1087
+ return { sub: p.toLowerCase(), regex: null }
1088
+ })
1089
+ }
1090
+
1091
+ /** 单行豁免匹配(剥 ANSI 后):返回 {pattern, anchored} 或 null。 */
1092
+ function matchExemptLine(rawLine, pats, knownFailures) {
1093
+ const bare = String(rawLine).replace(ANSI_RE, '')
1094
+ const ll = bare.toLowerCase()
1095
+ const trimmed = bare.trim()
1096
+ for (let i = 0; i < pats.length; i++) {
1097
+ const p = pats[i]
1098
+ const matched = p.regex ? p.regex.test(trimmed) : (p.sub != null && ll.includes(p.sub))
1099
+ if (matched) return { pattern: (knownFailures || [])[i], anchored: !!p.regex }
1100
+ }
1101
+ return null
1102
+ }
998
1103
 
999
1104
  /**
1000
1105
  * 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
@@ -1046,15 +1151,23 @@ export function partitionFailures(output, knownFailures) {
1046
1151
  // 工具复盘):TTY 捕获的失败行里色码把可见词拦腰拆开(`× \x1b[31mtests/foo.test.ts\x1b[0m > case`
1047
1152
  // 连不成 `tests/foo.test.ts > case`),同一行被迫拆成两段各配一条模式才能豁免。模式侧同步剥
1048
1153
  // ANSI(从原始输出誊抄来的模式可能自带码);返回仍保留原文(remaining/exempted 展示不变形)。
1049
- const pats = (knownFailures || []).map(p => String(p).replace(ANSI_RE, '').toLowerCase()).filter(Boolean)
1154
+ //
1155
+ // 锚定式与泛用裸模式停用(2026-09-28-known-failures-hardening,M1 评审实证「裸子串吞真失败」):
1156
+ // - 模式以 ^ 开头或以 $ 结尾 = 锚定式(对 trim 后的原始大小写整行跑正则);
1157
+ // - 泛用裸模式停用:整条模式文本就是失败标记词/极短标点串(如 "---"、"AssertionError"、"✖")
1158
+ // 的裸子串失去豁免资格(物理关闭「裸 "--- " 吞 go --- FAIL: 行」通路)——须改写为锚定式;
1159
+ // - 具体裸模式(文件名、用例名——known_failures 的主用例:按名豁免预存失败测试)不受限,
1160
+ // 硬失败行照常可被其豁免(跨仓零破坏)。
1161
+ // 匹配器编译/单行匹配抽为 buildExemptPats/matchExemptLine 共用单点(2026-09-28-tap-judge)。
1162
+ const pats = buildExemptPats(knownFailures)
1050
1163
  const exempted = []
1051
1164
  const remaining = []
1165
+ const exemptedBy = []
1052
1166
  for (const l of failureLines) {
1053
- const ll = l.replace(ANSI_RE, '').toLowerCase()
1054
- if (pats.some(p => ll.includes(p))) exempted.push(l)
1055
- else remaining.push(l)
1167
+ const hit = matchExemptLine(l, pats, knownFailures)
1168
+ if (hit) { exempted.push(l); exemptedBy.push(hit) } else remaining.push(l)
1056
1169
  }
1057
- return { failureLines, exempted, remaining }
1170
+ return { failureLines, exempted, remaining, exemptedBy }
1058
1171
  }
1059
1172
 
1060
1173
  /**
@@ -1075,19 +1188,27 @@ export function partitionFailures(output, knownFailures) {
1075
1188
  * remainingLines: string[], exemptedLines: string[] }}
1076
1189
  */
1077
1190
  export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
1078
- if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0, remainingLines: [], exemptedLines: [] }
1079
- const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures || [])
1191
+ if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0, remainingLines: [], exemptedLines: [], exemptedBy: [] }
1192
+ const { failureLines, exempted, remaining, exemptedBy } = partitionFailures(output, knownFailures || [])
1080
1193
  if (!knownFailures || knownFailures.length === 0) {
1081
1194
  // 无清单分支同样带行集:豁免与否都不该丢判账依据(tail 盲区归因同权)
1082
- return { status: 'failed', reason: baseReason, exemptedCount: 0, remainingLines: remaining, exemptedLines: [] }
1195
+ return { status: 'failed', reason: baseReason, exemptedCount: 0, remainingLines: remaining, exemptedLines: [], exemptedBy: [] }
1083
1196
  }
1084
1197
  if (failureLines.length > 0 && remaining.length === 0) {
1198
+ // 豁免形态披露(2026-09-28-known-failures-hardening):锚定式(^…/$…)与裸子串命中分别
1199
+ // 计数——裸子串命中给收敛提示(硬行已不可被裸子串吞,非硬行的裸子串命中仍建议锚定收窄)。
1200
+ const anchoredHits = (exemptedBy || []).filter(x => x && x.anchored).length
1201
+ const bareHits = exempted.length - anchoredHits
1202
+ const formNote = bareHits > 0
1203
+ ? `;其中裸子串命中 ${bareHits} 行(建议收敛为锚定式 ^…,硬失败行已不可被裸子串豁免)`
1204
+ : '(全部为锚定式命中)'
1085
1205
  return {
1086
1206
  status: 'passed',
1087
- reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
1207
+ reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)${formNote} — 请人工复核豁免清单是否过宽`,
1088
1208
  exemptedCount: exempted.length,
1089
1209
  remainingLines: [],
1090
1210
  exemptedLines: exempted,
1211
+ exemptedBy: exemptedBy || [],
1091
1212
  }
1092
1213
  }
1093
1214
  // 剩余未豁免失败行:列出具体行 + 预存债指引(坑 verify-known-failures-stale-list)。
@@ -1266,16 +1387,150 @@ export function decideVerifyTestAction({ strategy, scopeCount = 0, gitUnavailabl
1266
1387
  return scopeCount > 0 ? 'dynamic-subset' : 'dynamic-empty-skip'
1267
1388
  }
1268
1389
 
1390
+ // ── 实测门增量重跑(2026-10-07-test-incremental-rerun)──
1391
+ // postmortem 实测:verify --done 跑 10 分钟级测试子集 → 挂 N 个 → 修一行 → 重跑 --done 又
1392
+ // 全量重跑子集(green cache 只在指纹不变时命中,改一行即失效)——每轮返工整轮全跑。修法:
1393
+ // 失败轮把「失败批测试文件 + 当时 git HEAD」落稳定指针 ledger;下一轮先算增量面 =
1394
+ // 「自失败 HEAD 以来的变更文件(含未提交与 untracked)∪ 前轮失败批文件」,是其真子集时
1395
+ // 只跑增量面(三源推断以增量输入重算——修复文件的 import 依赖与 FR 关联回归自然入面),
1396
+ // 基线绿面(未触碰文件)复用不重跑。逃生:local.yaml verify.test_rerun: full / env
1397
+ // SILLYSPEC_TEST_RERUN=full 恒全子集(2026-10-07 前现状)。
1398
+
1399
+ /** 增量重跑档位读取:env SILLYSPEC_TEST_RERUN 优先,次 local.yaml verify: 段 test_rerun(宽松匹配行,fail-open incremental)。 */
1400
+ function readTestRerunConfig(specBase) {
1401
+ const env = String(process.env.SILLYSPEC_TEST_RERUN || '').trim().toLowerCase()
1402
+ if (env === 'full' || env === 'incremental') return env
1403
+ try {
1404
+ const p = join(specBase, 'local.yaml')
1405
+ if (!existsSync(p)) return 'incremental'
1406
+ const m = readFileSync(p, 'utf8').match(/^\s*test_rerun:\s*(full|incremental)\s*$/m)
1407
+ return m ? m[1] : 'incremental'
1408
+ } catch { /* 读失败 → 缺省 */ }
1409
+ return 'incremental'
1410
+ }
1411
+
1412
+ function testRerunLedgerPath(specBase, changeName) {
1413
+ return join(specBase, '.runtime', `test-rerun-${changeName || 'change'}.json`)
1414
+ }
1415
+
1416
+ /**
1417
+ * 读增量重跑 ledger(fail-soft:缺失/损坏/null)。shape: { head, failedFiles, inputFiles, mode, status, ranAt }
1418
+ */
1419
+ export function readTestRerunLedger({ specBase, changeName }) {
1420
+ try {
1421
+ return JSON.parse(readFileSync(testRerunLedgerPath(specBase, changeName), 'utf8'))
1422
+ } catch { return null }
1423
+ }
1424
+
1425
+ /** 写增量重跑 ledger(fail-soft:git 失败记 head=null——下一轮自动回全子集)。 */
1426
+ export function writeTestRerunLedger({ specBase, changeName, head, inputFiles, failedFiles, scopeFiles = [], mode, status }) {
1427
+ try {
1428
+ const dir = join(specBase, '.runtime')
1429
+ mkdirSync(dir, { recursive: true })
1430
+ writeFileSync(testRerunLedgerPath(specBase, changeName), JSON.stringify({
1431
+ change: changeName || null, head: head || null,
1432
+ inputFiles: inputFiles || [], failedFiles: failedFiles || [], scopeFiles: scopeFiles || [],
1433
+ mode: mode || null, status: status || null, ranAt: new Date().toISOString(),
1434
+ }, null, 2) + '\n')
1435
+ } catch { /* ledger 写失败不阻断实测门(下轮退化全子集) */ }
1436
+ }
1437
+
1438
+ /** 自基线 HEAD 以来的变更文件(committed+unstaged diff ∪ untracked),posix 归一。 */
1439
+ function listFilesSince({ cwd, sinceHead }) {
1440
+ const out = []
1441
+ try {
1442
+ const diff = gitQuiet(cwd, ['diff', '--name-only', sinceHead])
1443
+ if (diff) out.push(...diff.split('\n'))
1444
+ } catch { /* diff 失败 → 空贡献 */ }
1445
+ try {
1446
+ const untracked = gitQuiet(cwd, ['ls-files', '--others', '--exclude-standard'])
1447
+ if (untracked) out.push(...untracked.split('\n'))
1448
+ } catch { /* untracked 失败 → 空贡献 */ }
1449
+ return [...new Set(out.map(s => s.trim().replace(/\\/g, '/')).filter(Boolean))]
1450
+ }
1451
+
1452
+ function currentGitHead(cwd) {
1453
+ try { return (gitQuiet(cwd, ['rev-parse', 'HEAD']) || '').trim() || null } catch { return null }
1454
+ }
1455
+
1456
+ /**
1457
+ * 增量重跑面计算(纯函数,export 供测):返回 null = 不增量(走全子集现状),否则返回
1458
+ * { files, note }。判定:ledger 在场、有失败文件、有基线 head;since 非空或失败面非空;
1459
+ * 对比基线 = ledger.scopeFiles(基线实测的测试文件集——评审 P2 清偿:拿「失败测试文件数」
1460
+ * 对比「输入面文件数」是口径错位,级联失败(失败测试数≥输入面数)时增量永不触发,恰是其
1461
+ * 动机场景失效);缺 scopeFiles 回退 inputFiles;增量面必须严格小于基线测试集(≥ 时回全
1462
+ * 子集——宁多跑不绕过基线)。
1463
+ */
1464
+ export function computeIncrementalFace({ ledger, filesSince, fullFace }) {
1465
+ if (!ledger || !Array.isArray(ledger.failedFiles) || ledger.failedFiles.length === 0 || !ledger.head) return null
1466
+ const since = Array.isArray(filesSince) ? filesSince : []
1467
+ const inc = [...new Set([...since, ...ledger.failedFiles])]
1468
+ .map(f => String(f).replace(/\\/g, '/'))
1469
+ .filter(f => f && !f.startsWith('.sillyspec/'))
1470
+ if (inc.length === 0) return null
1471
+ const baselineScope = Array.isArray(ledger.scopeFiles) && ledger.scopeFiles.length > 0
1472
+ ? ledger.scopeFiles
1473
+ : (ledger.inputFiles || fullFace || [])
1474
+ if (inc.length >= baselineScope.length) return null
1475
+ return {
1476
+ files: inc,
1477
+ note: `增量重跑面 ${inc.length} 文件(前轮失败批 ${ledger.failedFiles.length} ∪ 基线以来变更 ${since.length};基线实测测试集 ${baselineScope.length} 文件,未触碰绿面复用不重跑)`,
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * TAP 结构化判账(2026-09-28-tap-judge,P2 一期):node --test 双报告器(spec→stderr 人读、
1483
+ * tap→stdout 机读)批次按 `not ok` 行用例粒度判账——自由文本正则误计(fixture 预期错误文案、
1484
+ * 控制台噪声)对 TAP 路径物理消失。豁免匹配复用 buildExemptPats/matchExemptLine 单点
1485
+ * (锚定式/裸子串/泛用停用语义与 partitionFailures 完全一致)。
1486
+ * @returns {null | {status:'passed'|'failed', reason, exemptedCount, remainingLines, exemptedLines, exemptedBy, tap:{total,failed}}}
1487
+ * null=输出非 TAP(旧 node/异构命令)——调用方回退 legacy judgeWithKnownFailures。
1488
+ */
1489
+ export function judgeTapOutput(exitCode, output, baseReason, knownFailures) {
1490
+ const text = String(output || '')
1491
+ if (!/^TAP version \d+/m.test(text.split('\n')[0] || '')) return null
1492
+ const allLines = text.split(/\r?\n/)
1493
+ const okCount = allLines.filter(l => /^ok\s+\d+/.test(l)).length
1494
+ const notOkLines = allLines.filter(l => /^not ok\s+\d+/.test(l))
1495
+ const tap = { total: okCount + notOkLines.length, failed: notOkLines.length }
1496
+ if (exitCode === 0) {
1497
+ return { status: 'passed', reason: `${baseReason || ''}(TAP ${tap.total} 用例 0 失败)`.trim(), exemptedCount: 0, remainingLines: [], exemptedLines: [], exemptedBy: [], tap }
1498
+ }
1499
+ if (!knownFailures || knownFailures.length === 0) {
1500
+ return { status: 'failed', reason: baseReason, exemptedCount: 0, remainingLines: notOkLines, exemptedLines: [], exemptedBy: [], tap }
1501
+ }
1502
+ const pats = buildExemptPats(knownFailures)
1503
+ const exempted = []
1504
+ const remaining = []
1505
+ const exemptedBy = []
1506
+ for (const l of notOkLines) {
1507
+ const hit = matchExemptLine(l, pats, knownFailures)
1508
+ if (hit) { exempted.push(l); exemptedBy.push(hit) } else remaining.push(l)
1509
+ }
1510
+ if (remaining.length === 0 && notOkLines.length > 0) {
1511
+ const anchoredHits = exemptedBy.filter(x => x && x.anchored).length
1512
+ const formNote = exempted.length - anchoredHits > 0 ? `;其中裸子串命中 ${exempted.length - anchoredHits} 行(建议锚定式)` : '(全部锚定式命中)'
1513
+ return { status: 'passed', reason: `TAP ${notOkLines.length} 个失败用例全部命中 known_failures 已豁免${formNote} — 请人工复核`, exemptedCount: exempted.length, remainingLines: [], exemptedLines: exempted, exemptedBy, tap }
1514
+ }
1515
+ const sample = remaining.slice(0, 5).map(l => String(l).trim().slice(0, 120))
1516
+ return { status: 'failed', reason: `${baseReason || ''}(TAP ${tap.total} 用例 ${notOkLines.length} 失败,未豁免 ${remaining.length}):${sample.join(' | ')}`.trim(), exemptedCount: exempted.length, remainingLines: remaining, exemptedLines: exempted, exemptedBy, tap }
1517
+ }
1518
+
1269
1519
  /**
1270
1520
  * 跑单个模块的 test 命令(串行调用方逐个调用)。
1271
1521
  * @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
1272
1522
  */
1273
- function runOneModule(name, testCommand, cwd, knownFailures = []) {
1523
+ function runOneModule(name, testCommand, cwd, knownFailures = [], opts = {}) {
1274
1524
  const startedAt = Date.now()
1275
1525
  let exitCode = 0
1276
1526
  let output = ''
1277
1527
  let reason = null
1278
1528
  warnPortRaceBeforeRun(testCommand)
1529
+ // 嵌套测试环境清洗(2026-09-28-tap-judge 实证发现):父级 node:test 进程带 NODE_TEST_CONTEXT
1530
+ // env,本模块 execSync 全量继承——门在测试进程内被调用时(e2e 用例 spawn 内层 CLI 跑门),
1531
+ // 内层 node --test 误入 child 模式 stdout 全空(⑮ 门内必挂/单独跑必过的根因家族)。
1532
+ // 剥离该键:门内测试进程与普通进程行为一致。
1533
+ const runEnv = stripNestedTestEnv()
1279
1534
  try {
1280
1535
  output = decodeShellOutput(execSync(testCommand, {
1281
1536
  cwd,
@@ -1284,6 +1539,7 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1284
1539
  maxBuffer: 32 * 1024 * 1024,
1285
1540
  stdio: ['ignore', 'pipe', 'pipe'],
1286
1541
  windowsHide: true,
1542
+ env: runEnv,
1287
1543
  }))
1288
1544
  } catch (e) {
1289
1545
  exitCode = typeof e.status === 'number' ? e.status : 1
@@ -1298,7 +1554,10 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1298
1554
  }
1299
1555
  const durationMs = Date.now() - startedAt
1300
1556
  const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
1301
- const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
1557
+ // TAP 优先判账(2026-09-28-tap-judge):tap 批次 stdout 为纯 TAP——用例粒度结构化判账;
1558
+ // 输出非 TAP(旧 node/异常形态)自动回退 legacy 正则判账,零行为断裂。
1559
+ const judged = (opts.tap ? judgeTapOutput(exitCode, output, reason, knownFailures) : null)
1560
+ || judgeWithKnownFailures(exitCode, output, reason, knownFailures)
1302
1561
  // 环境缺件降档(坑 quick-test-gate-frontend-lint-tempdir-no-nodemodules,2026-09-23 实证):
1303
1562
  // 模块命令在缺失二进制处中止——CNF 是环境信号非代码失败,降档 skipped(aggregateStatus
1304
1563
  // 认识 skipped 单元)。护栏:判账行集全为 wrapper 噪声才降(真失败行在场维持硬拦)。
@@ -1328,6 +1587,9 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1328
1587
  exitCode,
1329
1588
  durationMs,
1330
1589
  outputTail,
1590
+ // 全量输出(内存态,不落盘):增量重跑的失败文件归因源(TAP 顶层 Subtest 段 / pytest
1591
+ // FAILED 行)——outputTail 截断后归因面残缺(2026-10-07-test-incremental-rerun)
1592
+ outputFull: output,
1331
1593
  reason: judged.reason,
1332
1594
  exemptedCount: judged.exemptedCount,
1333
1595
  failureRemaining: judged.remainingLines,
@@ -1335,6 +1597,44 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
1335
1597
  }
1336
1598
  }
1337
1599
 
1600
+ /**
1601
+ * 失败批 → 失败测试文件归因(2026-10-07-test-incremental-rerun):node --test 文件作参数时
1602
+ * TAP 顶层条目名是测试标题非文件名,但每个 `not ok` 块的 YAML 诊断(location/stack)带绝对
1603
+ * 路径——按块内路径归一后 includes 匹配批内相对路径;非 TAP 输出(pytest 等)回退
1604
+ * FAILED/ERROR 行带路径匹配;归因不出保守全记批文件(增量退化不误报)。
1605
+ */
1606
+ function attributeFailedFiles(result) {
1607
+ const files = result.files || []
1608
+ if (files.length === 0) return []
1609
+ const text = String(result.outputFull || result.outputTail || '')
1610
+ if (!text) return files
1611
+ const norm = (p) => String(p).replace(/\\/g, '/')
1612
+ // 按 TAP 顶层行切 not ok 块(块体 = 该行到下一顶层行之间的缩进诊断)
1613
+ const blocks = []
1614
+ const lines = text.split(/\r?\n/)
1615
+ let cur = null
1616
+ for (const line of lines) {
1617
+ if (/^not ok \d+/.test(line)) { cur = [line]; blocks.push(cur); continue }
1618
+ if (/^(?:ok \d+|not ok \d+|#\s|1\.\.\d+|TAP version)/.test(line)) { cur = null; continue }
1619
+ if (cur) cur.push(line)
1620
+ }
1621
+ const failing = new Set()
1622
+ for (const block of blocks) {
1623
+ const seg = norm(block.join('\n'))
1624
+ for (const f of files) {
1625
+ if (seg.includes(norm(f))) failing.add(f)
1626
+ }
1627
+ }
1628
+ let hits = files.filter(f => failing.has(f))
1629
+ if (hits.length === 0 && /FAILED|ERROR/.test(text)) {
1630
+ hits = files.filter(f => {
1631
+ const esc = norm(f).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1632
+ return new RegExp(`(?:FAILED|ERROR)[^\\n]{0,200}${esc}`).test(text)
1633
+ })
1634
+ }
1635
+ return hits.length > 0 ? hits : files
1636
+ }
1637
+
1338
1638
  // refSpec 来源 meta.json(agent 可写),仅放行 git ref/range 安全字符(含 HEAD~1..HEAD 类区间),
1339
1639
  // 防经 shell 的命令/参数注入。剥空白与引号/分号/反引号/$ 等 shell 元字符。
1340
1640
  // 注:允许 ".."/"../.."(相对路径 ref 语法上合法)不影响安全——execFileSync 数组形式下它只是
@@ -1705,13 +2005,27 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1705
2005
  const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
1706
2006
 
1707
2007
  const rawStrategy = extractTestStrategy(yamlText)
1708
- const knownFailures = extractKnownFailures(yamlText)
2008
+ // 分层豁免清单(2026-09-28-known-failures-hardening,P5):入库的 .sillyspec/known-failures.yaml
2009
+ // 承载工具债类模式(先例 redlines.yaml——跨机器可审计);local.yaml 留机器特有类。两源同用
2010
+ // extractKnownFailures 解析(同一 known_failures: 键),合并去重,入库文件在前(溯源优先)。
2011
+ const versionedKfPath = join(specBase, 'known-failures.yaml')
2012
+ const versionedKfText = existsSync(versionedKfPath) ? readFileSync(versionedKfPath, 'utf8') : null
2013
+ const knownFailures = [...new Set([
2014
+ ...extractKnownFailures(versionedKfText),
2015
+ ...extractKnownFailures(yamlText),
2016
+ ])]
1709
2017
  if (knownFailures.length > 0) {
1710
2018
  // 豁免清单加载可见性(坑 verify-known-failures-block-fragile-chain 的观测面):条数即时报出
1711
2019
  // ——清单被块内残迹截断/误删段时「N 条」与预期不符一眼可见,不必等假红再反推。
1712
2020
  // 走 stderr(console.warn):本模块运行期叙述统一不碰 stdout(machine-interface 调用时
1713
2021
  // stdout 留给机器可读输出)
1714
- console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(local.yaml)`)
2022
+ const vCount = extractKnownFailures(versionedKfText).length
2023
+ console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(入库 known-failures.yaml ${vCount} 条 + local.yaml ${knownFailures.length - vCount} 条)`)
2024
+ // 泛用裸模式停用可见性(M1):停用是静默安全行为,但要让维护者知道清单里有死条目
2025
+ const refused = knownFailures.filter(p => !/^[^]*[$]$/.test(String(p)) && !(String(p).startsWith('^') || String(p).endsWith('$')) && GENERIC_BARE_PATTERN_RE.test(String(p).replace(ANSI_RE, '').trim()))
2026
+ if (refused.length > 0) {
2027
+ console.warn(`⚠️ ${refused.length} 条泛用裸模式已停用(整条即失败词——须改锚定式 ^…):${refused.slice(0, 3).map(p => `"${p}"`).join('、')}${refused.length > 3 ? ' 等' : ''}`)
2028
+ }
1715
2029
  }
1716
2030
 
1717
2031
  // ── local.yaml 测试配置退役(2026-09-26-dynamic-test-inference)──
@@ -1847,9 +2161,46 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1847
2161
  // —— 动态子集(2026-09-26-dynamic-test-inference 缺省路径)——
1848
2162
  // 三源并集:本变更测试 ∪ FR 关联回归 ∪ import 依赖;runner 自项目结构推断;
1849
2163
  // 全量语义留 CI / 显式 test_strategy: full。
2164
+ // 增量重跑(2026-10-07-test-incremental-rerun):前轮失败 + 本轮有修复 delta → 输入面
2165
+ // 收窄为「失败批文件 ∪ 基线以来变更」,未触碰绿面复用——返工轮不再整轮全跑。
2166
+ let subsetInput = lastChangedFiles
2167
+ let incremental = null
2168
+ if (readTestRerunConfig(specBase) === 'incremental') {
2169
+ const ledger = readTestRerunLedger({ specBase, changeName })
2170
+ if (ledger) {
2171
+ const filesSince = listFilesSince({ cwd, sinceHead: ledger.head })
2172
+ incremental = computeIncrementalFace({ ledger, filesSince, fullFace: lastChangedFiles })
2173
+ }
2174
+ }
1850
2175
  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 })
2176
+ if (incremental) {
2177
+ subsetInput = incremental.files
2178
+ console.log(`⚡ ${incremental.note}`)
2179
+ console.log(`ℹ️ 动态测试子集(增量重跑档):修复面三源重算(本变更测试 ∪ FR 关联回归 ∪ import 依赖),runner 自项目结构推断。恒全子集:local.yaml verify: test_rerun: full`)
2180
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: subsetInput, frPre: null })
2181
+ if (mainResult && typeof mainResult === 'object') {
2182
+ mainResult.mode = 'incremental-rerun'
2183
+ mainResult.reason = mainResult.reason
2184
+ ? `${mainResult.reason}(增量重跑:${incremental.note})`
2185
+ : `增量重跑通过:${incremental.note}`
2186
+ }
2187
+ } else {
2188
+ console.log(`ℹ️ 动态测试子集(缺省):本变更测试 ∪ FR 关联回归 ∪ import 依赖 = ${scopeFiles.length} 个(并集去重后的总数;分量 deps ${depsAutoFiles.length}${frCount ? ` + FR 绑定 ${frCount}` : ''},重叠只计一次);runner 自项目结构推断。显式 test_strategy: full 恢复全量`)
2189
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre })
2190
+ }
2191
+ // ledger 记账(无论增/全):head=当前 HEAD(git 失败 null → 下轮自动回全子集);失败批文件
2192
+ // 落账供下轮增量;passed 后 failedFiles 清空 → 下轮自然回全子集基线
2193
+ try {
2194
+ writeTestRerunLedger({
2195
+ specBase, changeName,
2196
+ head: currentGitHead(cwd),
2197
+ inputFiles: subsetInput,
2198
+ failedFiles: (mainResult && Array.isArray(mainResult.failedFiles)) ? mainResult.failedFiles : [],
2199
+ scopeFiles: (mainResult && Array.isArray(mainResult.scopeFiles)) ? mainResult.scopeFiles : [],
2200
+ mode: incremental ? 'incremental-rerun' : 'dynamic-subset',
2201
+ status: mainResult && mainResult.status,
2202
+ })
2203
+ } catch { /* 记账失败不阻断(下轮退化全子集) */ }
1853
2204
  } else {
1854
2205
  // —— dynamic-empty:变更与测试面零关系——
1855
2206
  // 无自身测试、无 import 依赖测试、无 FR 关联绑定 → 不硬跑全量(防超时/预存失败面,
@@ -2072,6 +2423,7 @@ function runCrossRepoFullTest(entry) {
2072
2423
  maxBuffer: 32 * 1024 * 1024,
2073
2424
  stdio: ['ignore', 'pipe', 'pipe'],
2074
2425
  windowsHide: true,
2426
+ env: stripNestedTestEnv(),
2075
2427
  })
2076
2428
  } catch (e) {
2077
2429
  exitCode = typeof e.status === 'number' ? e.status : 1
@@ -2253,6 +2605,7 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
2253
2605
  maxBuffer: 32 * 1024 * 1024,
2254
2606
  stdio: ['ignore', 'pipe', 'pipe'],
2255
2607
  windowsHide: true,
2608
+ env: stripNestedTestEnv(),
2256
2609
  }))
2257
2610
  } catch (e) {
2258
2611
  exitCode = typeof e.status === 'number' ? e.status : 1
@@ -2406,6 +2759,11 @@ function inferFullTestCommand(projectRoot) {
2406
2759
  * 修法:按扩展名分组(.py 一组 / 其余 node --test 一组),组内「本次变更的测试文件优先」排序,
2407
2760
  * 每组按比例分 30 帽(保底 5);.py 组运行器从命中模块命令推断(含 pytest 的命令取其 pytest
2408
2761
  * 前缀——如 `cd backend && uv run pytest` 的 `uv run pytest`;无命中模块兜底 python -m pytest)。
2762
+ * 优先面豁免帽(2026-10-06-fr-regress-cap-drop,flow-status-title 收口实测缺陷):变更自身
2763
+ * 测试 ∪ priorityFiles(FR 钦定回归)是本变更验收面本体——旧 slice(0, cap) 只保证优先面
2764
+ * 「先入序」,优先面自身超帽时照样被字母序挤出(实测 63 绑定文件实跑 30、静默弃 33 含最
2765
+ * 相关回归)。组卷改配额制:每组优先面整跑,普通 import 依赖只填剩余席位(可为 0)——帽
2766
+ * 只界普通依赖尾。批尺寸上界护栏由 TEST_TIMEOUT_MS 兜底(超时=失败 fail-closed 不变)。
2409
2767
  */
2410
2768
  function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, priorityFiles = [] }) {
2411
2769
  const norm = (p2) => String(p2).replace(/\\/g, '/')
@@ -2418,8 +2776,18 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2418
2776
  const CAP = 30
2419
2777
  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
2778
  const jsCap = CAP - pyCap
2421
- const pyRun = py.slice(0, pyCap)
2422
- const jsRun = js.slice(0, jsCap)
2779
+ // 配额制组卷(2026-10-06-fr-regress-cap-drop):优先面整跑豁免帽,普通依赖填剩余席位;
2780
+ // dropped 只计普通依赖弃置(优先面零弃置是构造保证),组内序保持「优先前缀+字母序」。
2781
+ const quotaFill = (group, quota) => {
2782
+ const prioInGroup = group.filter(f => prio(f) === 0)
2783
+ const ordinary = group.filter(f => prio(f) !== 0)
2784
+ const ordinaryRun = ordinary.slice(0, Math.max(0, quota - prioInGroup.length))
2785
+ return { run: [...prioInGroup, ...ordinaryRun], prioCount: prioInGroup.length, dropped: ordinary.length - ordinaryRun.length, prioDropped: 0 }
2786
+ }
2787
+ const pyQ = quotaFill(py, pyCap)
2788
+ const jsQ = quotaFill(js, jsCap)
2789
+ const pyRun = pyQ.run
2790
+ const jsRun = jsQ.run
2423
2791
  // .py 运行器推断双源(2026-09-26-dynamic-test-inference):① 命中模块命令串(旧路径,兼容期);
2424
2792
  // ② 无命中命令时自项目结构——首个 .py 文件最近 pyproject/uv.lock 祖先(uv 优先)。
2425
2793
  // cd 前缀保留(2026-09-25-deps-cwd-prefix,R15/R16 实证):剥前缀后从 worktree 根跑 uv,根上无
@@ -2462,22 +2830,33 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2462
2830
  const jsNative = jsRun.filter(
2463
2831
  (f) => !NEEDS_PROJECT_RE.test(_norm(f)) && !needsProjectByContent(f),
2464
2832
  )
2833
+ // 端到端目录过滤(坑 dynamic-deps-e2e-vitest-exclude-false-red,2026-09-30 multi-agent-platform
2834
+ // 实证,用户授权修复):项目运行器(vitest/jest)收集面普遍 exclude e2e/**、cypress/**
2835
+ // (Playwright/Cypress 用例不归单测运行器收)——显式点名传入必「No test files found」
2836
+ // exit 1 恒假红(且无失败行,known_failures 豁免救不了)。与运行器收集面取交集:端到端
2837
+ // 目录文件不进项目运行器批(jsNative 批照旧——node 原生可跑的形态不受影响)。
2838
+ const E2E_DIR_RE = /(^|\/)(e2e|cypress)\//
2839
+ const jsProjectRun = jsProject.filter(f => !E2E_DIR_RE.test(_norm(f)))
2840
+ const e2eDroppedFiles = jsProject.filter(f => E2E_DIR_RE.test(_norm(f)))
2465
2841
  let jsxRunner = null
2466
2842
  for (const h of hits || []) {
2467
2843
  const cmd = String(h.test || '')
2468
2844
  const m = /^((?:cd\s+[^&|;]+&&\s*)*[^&|;]*?(?:vitest|jest))(?=\s|$)/.exec(cmd) || /(?:^|&&|;|\|\|)\s*([^&|;]*(?:vitest|jest)(?:\s|$))/.exec(cmd)
2469
2845
  if (m) { jsxRunner = m[1].trim(); break }
2470
2846
  }
2471
- if (!jsxRunner && cwd && jsProject.length > 0) {
2472
- const inferred = inferJsxRunner(cwd, jsProject[0])
2847
+ if (!jsxRunner && cwd && jsProjectRun.length > 0) {
2848
+ const inferred = inferJsxRunner(cwd, jsProjectRun[0])
2473
2849
  if (inferred) { jsxRunner = inferred.command }
2474
2850
  }
2475
2851
  const cdDir = /(?:^|\s)cd\s+(\S+)\s*&&/.exec(pyRunner)?.[1]
2476
2852
  const rebase = (f) => (cdDir && f.startsWith(cdDir + '/')) ? f.slice(cdDir.length + 1) : f
2477
2853
  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) {
2854
+ // 批计数分列(2026-10-06-fr-regress-cap-drop):count=实跑总数、prioCount=实跑中优先面数、
2855
+ // dropped 只计普通依赖弃置、prioDropped=防御路径计数(构造上恒 0——quotaFill 保证优先面零弃置,
2856
+ // >0 即组卷逻辑被破坏,消费面 loud 披露)。js 批经 jsNative/jsProject 内容分流后计数随分流面。
2857
+ if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, prioCount: pyQ.prioCount, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, files: pyRun })
2858
+ if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: js.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2859
+ if (jsProjectRun.length > 0) {
2481
2860
  if (jsxRunner) {
2482
2861
  const isVitest = /vitest/.test(jsxRunner)
2483
2862
  // jsx 批的 cd 重定基(评审 MEDIUM 清偿):cdDir 只源 pyRunner——jsxRunner 自带 cd 前缀
@@ -2488,15 +2867,34 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2488
2867
  // 括号/方括号/空格的路径(src/app/(dashboard)/workspaces/[id]/...)不加引号会以
2489
2868
  // 「此时不应有 <。」语法错误炸整批——文件参数含特殊字符时逐个 wrap 双引号。
2490
2869
  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 })
2870
+ batches.push({ name: 'deps(auto-jsx)', short: 'jsx', command: `${jsxRunner} ${isVitest ? 'run ' : ''}${jsProjectRun.map(rebaseJsx).map(quoteWinPath).join(' ')}`, count: jsProjectRun.length, dropped: 0, files: jsProjectRun })
2492
2871
  } 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 连败的坑)` })
2872
+ batches.push({ name: 'deps(auto-jsx-skip)', short: 'jsx-skip', command: null, count: jsProjectRun.length, dropped: 0, skip: true, files: jsProjectRun, reason: `JSX 测试文件(tsx/jsx,${jsProjectRun.length} 个)node 原生不可跑且项目结构无 vitest/jest 运行器可推断——转项目运行器执行并如实披露,不制造恒败段(R18-SF-full 残差段 11 连败的坑)` })
2494
2873
  }
2495
2874
  }
2875
+ if (e2eDroppedFiles.length > 0) {
2876
+ // 交集为空也要留痕(skip 批不拦门、点名可见):端到端用例该走 playwright/cypress
2877
+ // 运行器(test:e2e 类脚本),不归单测依赖批。
2878
+ batches.push({ name: 'deps(auto-jsx-e2e-skip)', short: 'jsx-e2e-skip', command: null, count: e2eDroppedFiles.length, dropped: 0, skip: true, files: e2eDroppedFiles, reason: `端到端目录(e2e/cypress)测试 ${e2eDroppedFiles.length} 个不进项目运行器批——vitest/jest 收集面普遍 exclude 这些目录,点名传入必「No test files found」exit 1 假红(2026-09-30 multi-agent-platform 实证);如需执行请走对应端到端运行器(playwright/cypress,如 pnpm test:e2e)` })
2879
+ }
2496
2880
  return batches
2497
2881
  }
2498
2882
 
2499
- export { buildDepsBatches }
2883
+ /**
2884
+ * deps 批披露行(2026-10-06-fr-regress-cap-drop):runModuleSubset 批日志的单源格式化——
2885
+ * 计数分列(实跑总数 / 优先面豁免计数 / 普通依赖弃置),「超帽弃」只对普通依赖成立。
2886
+ * 抽为纯函数供直测钉格式(runModuleSubset 需真跑测试,不宜为格式断言起进程)。
2887
+ */
2888
+ function formatDepsBatchLine(b) {
2889
+ const runnerNote = b.name === 'deps(auto-py)'
2890
+ ? 'pytest 运行器按最近 pyproject/uv 祖先推断'
2891
+ : b.short === 'jsx' ? 'vitest/jest 按最近 package.json 推断' : 'node --test'
2892
+ const prio = Number(b.prioCount) > 0 ? `(优先面 ${b.prioCount}:变更/FR 回归豁免帽)` : ''
2893
+ const drop = Number(b.dropped) > 0 ? `(超帽弃 ${b.dropped} 普通依赖)` : ''
2894
+ return `ℹ️ 动态子集批 ${b.name}:${b.count} 个${prio}${drop}(${runnerNote}——治 node 跑 .py 伪败与字母序偏科)`
2895
+ }
2896
+
2897
+ export { buildDepsBatches, formatDepsBatchLine }
2500
2898
 
2501
2899
  // ── trace residual adapter(2026-09-24-fr-test-readside,fr-test-binding §3.3/§3.6 读侧)──
2502
2900
  // 锚点集=本变更 task 卡 requirement_ids 并集;残差=trace active 行 tests 并集按保守差集
@@ -2561,7 +2959,7 @@ function runTraceResidualInner({ cwd, files, hits, knownFailures }) {
2561
2959
  }
2562
2960
  const isPy = b.short === 'py'
2563
2961
  const segFiles = chunk.filter((f) => (norm(f).endsWith('.py') === isPy))
2564
- const r = runOneModule(b.name, b.command, cwd, knownFailures)
2962
+ const r = runOneModule(b.name, b.command, cwd, knownFailures, { tap: b.tap })
2565
2963
  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
2964
  }
2567
2965
  }
@@ -2631,7 +3029,12 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
2631
3029
  }
2632
3030
  }
2633
3031
 
2634
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null }) {
3032
+ /**
3033
+ * 动态子集执行(module 命令 + deps 批 + FR 关联回归)。2026-10-06-fr-priority-overlap 起
3034
+ * export 供直测(buildDepsBatches 同款先例——fixture 需真跑测试文件,不宜经
3035
+ * runVerifyTestCheck 全链只为断言组卷接线)。
3036
+ */
3037
+ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null }) {
2635
3038
  const subsetStartedAt = Date.now()
2636
3039
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
2637
3040
  // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
@@ -2648,6 +3051,11 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2648
3051
  if (!fr) {
2649
3052
  try { fr = collectFrLinkedTests({ specBase, changeName, changedFiles: changedFiles || [], projectRoot: cwd }) } catch { fr = null }
2650
3053
  }
3054
+ // 优先面全量口径(2026-10-06-fr-priority-overlap):frLinked 只含与 deps 无重叠的「新增」
3055
+ // 绑定文件——重叠者此前拿不到优先权,仍按普通依赖受帽字母序弃置(resume-title 收口实测
3056
+ // 64 绑定弃 14)。绑定面钦定即优先,不论与 import 依赖面是否重叠;fr 缺席回退空数组
3057
+ // (与现状一致)。frLinked 另有职责:并集去重的增量面 + frReport.addedCount 披露口径,保留不动。
3058
+ const frAll = fr && Array.isArray(fr.files) ? fr.files : []
2651
3059
  if (fr) {
2652
3060
  if (fr.files.length > 0) {
2653
3061
  const added = fr.files.filter(f => !deps.includes(f))
@@ -2662,7 +3070,7 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2662
3070
  const depsAll = [...new Set([...deps, ...frLinked])].sort()
2663
3071
  let depsBatches = []
2664
3072
  if (depsAll.length > 0) {
2665
- depsBatches = buildDepsBatches({ deps: depsAll, changedFiles, hits, cwd, priorityFiles: frLinked })
3073
+ depsBatches = buildDepsBatches({ deps: depsAll, changedFiles, hits, cwd, priorityFiles: frAll })
2666
3074
  for (const b of depsBatches) {
2667
3075
  if (b.skip) {
2668
3076
  // JSX 无项目运行器批(residual-runner-parity):skip 不跑不拦,reason 点名让漏测可见
@@ -2670,8 +3078,12 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2670
3078
  perModule.push({ name: b.name, status: 'skipped', reason: b.reason || '转项目运行器', command: null, exitCode: null, durationMs: null, outputTail: null })
2671
3079
  continue
2672
3080
  }
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 伪败与字母序偏科)`)
3081
+ perModule.push(runOneModule(b.name, b.command, cwd, knownFailures, { tap: b.tap }))
3082
+ perModule[perModule.length - 1].files = b.files || [] // 增量重跑面组成(task-B failedFiles 聚合源)
3083
+ console.log(formatDepsBatchLine(b))
3084
+ // 防御披露(2026-10-06-fr-regress-cap-drop):quotaFill 构造保证优先面零弃置——此计数
3085
+ // >0 说明组卷逻辑被后续改动破坏(钦定回归被弃),loud 披露不静默。
3086
+ if (Number(b.prioDropped) > 0) console.warn(`⚠️ ${b.name}:优先面 ${b.prioDropped} 个被弃(组卷构造上不应发生——工具缺陷,请上报)`)
2675
3087
  }
2676
3088
  }
2677
3089
  const status = aggregateStatus(perModule)
@@ -2710,6 +3122,20 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2710
3122
  mode: 'dynamic-subset',
2711
3123
  fallbackReason: null,
2712
3124
  exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
3125
+ // 失败批测试文件集聚合(2026-10-07-test-incremental-rerun):增量重跑面的组成之一——
3126
+ // 前轮失败批的文件下轮必入面(无论修复是否触碰它们)。归因粒度:node --test TAP 顶层
3127
+ // not ok 以测试文件为套件名(not ok N - test/b.test.mjs),用判账行集匹配批内文件名把
3128
+ // 「批失败」收窄到「文件失败」;归因不出(非 TAP/行集缺文件名)保守全记批文件。
3129
+ failedFiles: (() => {
3130
+ const out = new Set()
3131
+ for (const r of perModule.filter(x => x.status === 'failed')) {
3132
+ for (const f of attributeFailedFiles(r)) out.add(f)
3133
+ }
3134
+ return [...out].sort()
3135
+ })(),
3136
+ // 基线实测的测试文件全集(评审 P2 清偿配套):增量守卫的对比基线——输入面(变更 src)与
3137
+ // 实测面(测试文件)是两个集合,级联失败时后者远大于前者
3138
+ scopeFiles: [...new Set(perModule.flatMap(r => r.files || []))].sort(),
2713
3139
  // 判账行集聚合(坑 verify-test-reconcile-tail-blindspot):合并 tail 会二次截断,行集不截——
2714
3140
  // 每模块台账另见 extra.modules[].failure_remaining(带模块归属)
2715
3141
  failureRemaining: perModule.flatMap(r => r.failureRemaining || []),
@@ -2740,6 +3166,50 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [],
2740
3166
  return result
2741
3167
  }
2742
3168
 
3169
+ /**
3170
+ * lint 结果持久化(坑 flowdone-lint-fail-no-output,2026-10-03 实证:runVerifyLintCheck
3171
+ * 全程静默、tally 只记一句话退出码——lint 门红时 agent 拿不到失败原因只能盲猜)。
3172
+ * 优先并入 test 的 test-result.json(modules 并列一节 `lint` 字段,读改写);test 无
3173
+ * 结果文件(skipped/未跑)而 lint 实跑时,用 writeRunResult 形状独立落一份(kind: 'lint'
3174
+ * 标识)。返回落盘路径,失败返回 null(best-effort,不阻断门禁)。
3175
+ */
3176
+ export function persistLintResult({ specBase, changeName, testResultPath, lint }) {
3177
+ if (!lint || (lint.status !== 'passed' && lint.status !== 'failed')) return null
3178
+ if (testResultPath && existsSync(testResultPath)) {
3179
+ try {
3180
+ const j = JSON.parse(readFileSync(testResultPath, 'utf8'))
3181
+ // 并入节自带 resultPath(收编评审 P3:调用方在 persistLintResult 返回后才给 lint.resultPath
3182
+ // 赋值——只变异对象不重写盘,并入节会永远缺 resultPath。这里预先补齐,调用方赋同值幂等)
3183
+ lint.resultPath = testResultPath
3184
+ j.lint = lint
3185
+ writeFileSync(testResultPath, JSON.stringify(j, null, 2) + '\n')
3186
+ return testResultPath
3187
+ } catch { /* 结果文件损坏 → 退独立落盘 */ }
3188
+ }
3189
+ const holder = { resultPath: null }
3190
+ try {
3191
+ const ts = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '')
3192
+ const runDir = join(specBase, '.runtime', 'verify-runs', ts)
3193
+ mkdirSync(runDir, { recursive: true })
3194
+ holder.resultPath = join(runDir, 'test-result.json')
3195
+ writeFileSync(holder.resultPath, JSON.stringify({
3196
+ change: changeName || null,
3197
+ kind: 'lint',
3198
+ command: lint.command,
3199
+ exit_code: lint.exitCode,
3200
+ status: lint.status,
3201
+ duration_ms: lint.durationMs,
3202
+ output_tail: lint.outputTail,
3203
+ reason: lint.reason || null,
3204
+ ran_at: new Date().toISOString(),
3205
+ }, null, 2) + '\n')
3206
+ return holder.resultPath
3207
+ } catch (e) {
3208
+ console.warn(`⚠️ lint 结果落盘失败: ${e.message}`)
3209
+ return null
3210
+ }
3211
+ }
3212
+
2743
3213
  /**
2744
3214
  * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
2745
3215
  * 多模块时 extra.modules 描述各模块明细。export 供 test 验证台账落盘形状。
@@ -3517,7 +3987,9 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
3517
3987
  // 锚定变更分支/审计 tag(非 blanket 20 提交——base 提交文件不得误救)
3518
3988
  const rescueRef = (typeof branchHash === 'string' && branchHash.trim()) ? branch : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
3519
3989
  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 })
3990
+ // -n 形态计数(2026-10-04-log-window-arity:裸 '20' 在 git ≥2.4x 是 ambiguous argument
3991
+ // fatal——fail-open 静默吞后救赎 note 路径从未生效,plan-target-files D7 在纯 HEAD 稳定红)
3992
+ const recent = gitQuiet(cwd, ['log', '-n', '20', '--name-only', '--format=%h', rescueRef], { timeout: 30 * 1000, trim: false })
3521
3993
  if (typeof recent === 'string' && recent.trim()) {
3522
3994
  const files = recent.split('\n').map(l => l.trim()).filter(l => l && !/^[0-9a-f]{7,40}$/.test(l))
3523
3995
  if (files.length > 0) {