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.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
package/src/verify-postcheck.js
CHANGED
|
@@ -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;优先级 显式参 >
|
|
233
|
-
|
|
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: '
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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('#'))
|
|
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
|
|
675
|
-
|
|
676
|
-
const
|
|
677
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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(
|
|
689
|
-
|
|
690
|
-
|
|
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 '
|
|
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,
|
|
1259
|
+
export function decideVerifyTestAction({ strategy, scopeCount = 0, gitUnavailable = false }) {
|
|
1086
1260
|
if (strategy === 'skip') return 'skip'
|
|
1087
|
-
if (strategy === '
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
return '
|
|
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: '
|
|
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
|
-
//
|
|
1517
|
-
// modules
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
let strategy =
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
//
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
1977
|
-
|
|
2213
|
+
let command = extractTestCommand(yamlText)
|
|
2214
|
+
let inferredNote = null
|
|
1978
2215
|
if (!command) {
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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: '
|
|
2003
|
-
timeout:
|
|
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
|
-
|
|
2011
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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 (
|
|
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
|
-
:
|
|
2071
|
-
//
|
|
2072
|
-
//
|
|
2073
|
-
|
|
2074
|
-
|
|
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: '
|
|
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
|
-
|
|
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)
|