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