sillyspec 3.25.4 → 3.25.6
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-brainstorm/SKILL.md +27 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +56 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +26 -0
- package/.claude/skills/sillyspec-propose/SKILL.md +26 -0
- package/.claude/skills/sillyspec-scan/SKILL.md +12 -7
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -2
- package/package.json +1 -1
- package/src/change-list.js +114 -12
- package/src/change-risk-profile.js +36 -1
- package/src/constants.js +11 -0
- package/src/index.js +4 -1
- package/src/machine-interface.js +5 -1
- package/src/progress/stage-machine.js +17 -14
- package/src/run/command.js +58 -3
- package/src/run/complete-handlers.js +9 -3
- package/src/run/complete.js +130 -33
- package/src/run/gates.js +37 -0
- package/src/run/prompt.js +20 -0
- package/src/run/quick-audit.js +8 -1
- package/src/run/scan-profile.js +29 -10
- package/src/run/shared.js +161 -4
- package/src/run/stage.js +4 -0
- package/src/scan-postcheck.js +25 -11
- package/src/stage-contract.js +39 -8
- package/src/stage-review.js +16 -5
- package/src/stages/archive.js +13 -9
- package/src/stages/brainstorm-auto.js +3 -2
- package/src/stages/brainstorm.js +13 -16
- package/src/stages/execute.js +4 -3
- package/src/stages/plan-postcheck.js +1 -1
- package/src/stages/plan.js +13 -0
- package/src/stages/propose.js +3 -2
- package/src/stages/quick.js +31 -31
- package/src/stages/scan.js +2 -1
- package/src/stages/status.js +2 -2
- package/src/stages/verify.js +19 -8
- package/src/task-review.js +308 -3
- package/src/verify-postcheck.js +353 -38
- package/src/worktree-apply.js +134 -67
- package/src/worktree.js +106 -32
- package/templates/prompts/verify-probes.md +13 -2
package/src/verify-postcheck.js
CHANGED
|
@@ -19,6 +19,7 @@ import { execSync } from 'child_process'
|
|
|
19
19
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
20
20
|
import { join } from 'path'
|
|
21
21
|
import { verifyApiParity } from './contract-matrix.js'
|
|
22
|
+
import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
|
|
22
23
|
|
|
23
24
|
// 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
|
|
24
25
|
const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
|
|
@@ -211,11 +212,126 @@ export function aggregateStatus(results) {
|
|
|
211
212
|
return 'failed'
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/**
|
|
216
|
+
* 从 local.yaml 提取 known_failures 声明(预存失败豁免清单,坑 verify-worktree-... 修复方向 2)。
|
|
217
|
+
* 支持块式与流式两种写法:
|
|
218
|
+
* known_failures:
|
|
219
|
+
* - "tests/test_ppm.py::test_legacy"
|
|
220
|
+
* - app/modules/plan/test_old
|
|
221
|
+
* 或 known_failures: [a, b]
|
|
222
|
+
* 每条作为子串(大小写不敏感)匹配测试输出中的失败行。
|
|
223
|
+
*
|
|
224
|
+
* @param {string} yamlText
|
|
225
|
+
* @returns {string[]} 模式列表;无声明返回 []
|
|
226
|
+
*/
|
|
227
|
+
export function extractKnownFailures(yamlText) {
|
|
228
|
+
if (!yamlText) return []
|
|
229
|
+
const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
|
|
230
|
+
if (inline) {
|
|
231
|
+
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
232
|
+
}
|
|
233
|
+
const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
|
|
234
|
+
if (block) {
|
|
235
|
+
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
236
|
+
.map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
}
|
|
239
|
+
return []
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
|
|
243
|
+
// 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
|
|
244
|
+
// "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
|
|
245
|
+
const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
|
|
246
|
+
// 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
|
|
247
|
+
const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|failed\s+in\s+\d)/i
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
|
|
251
|
+
* @param {string} output
|
|
252
|
+
* @param {string[]} knownFailures 子串模式列表(大小写不敏感)
|
|
253
|
+
* @returns {{ failureLines: string[], exempted: string[], remaining: string[] }}
|
|
254
|
+
*/
|
|
255
|
+
export function partitionFailures(output, knownFailures) {
|
|
256
|
+
const lines = String(output || '').split(/\r?\n/)
|
|
257
|
+
const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
|
|
258
|
+
const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
|
|
259
|
+
const exempted = []
|
|
260
|
+
const remaining = []
|
|
261
|
+
for (const l of failureLines) {
|
|
262
|
+
const ll = l.toLowerCase()
|
|
263
|
+
if (pats.some(p => ll.includes(p))) exempted.push(l)
|
|
264
|
+
else remaining.push(l)
|
|
265
|
+
}
|
|
266
|
+
return { failureLines, exempted, remaining }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 结合 known_failures 判定单次测试运行的状态(fail-safe)。
|
|
271
|
+
* - exitCode 0 → passed
|
|
272
|
+
* - exitCode≠0 且无 known_failures → failed(原行为)
|
|
273
|
+
* - exitCode≠0 且有 known_failures:
|
|
274
|
+
* 检测到失败行且全部命中豁免 → passed(披露:请人工复核清单是否过宽)
|
|
275
|
+
* 有未豁免失败行 / 检测不到失败行(保守)→ failed
|
|
276
|
+
* fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
|
|
277
|
+
*
|
|
278
|
+
* @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
|
|
279
|
+
*/
|
|
280
|
+
export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
|
|
281
|
+
if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
|
|
282
|
+
if (!knownFailures || knownFailures.length === 0) {
|
|
283
|
+
return { status: 'failed', reason: baseReason, exemptedCount: 0 }
|
|
284
|
+
}
|
|
285
|
+
const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
|
|
286
|
+
if (failureLines.length > 0 && remaining.length === 0) {
|
|
287
|
+
return {
|
|
288
|
+
status: 'passed',
|
|
289
|
+
reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
|
|
290
|
+
exemptedCount: exempted.length,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// 剩余未豁免失败行:列出具体行 + 预存债指引(坑 verify-known-failures-stale-list)。
|
|
294
|
+
// known_failures 清单常滞后于新预存测试 → 同源预存债漏入清单,agent 跑完才发现要手动补。
|
|
295
|
+
// 这里点出具体未命中行,让 agent 一眼分辨"补清单 vs 修代码",不替它判断(确定性:列已算出的 remaining)。
|
|
296
|
+
let detail
|
|
297
|
+
if (remaining.length > 0) {
|
|
298
|
+
const sample = remaining.slice(0, 5).map(l => {
|
|
299
|
+
const t = String(l).trim()
|
|
300
|
+
return t.length > 120 ? t.slice(0, 120) + '…' : t
|
|
301
|
+
})
|
|
302
|
+
const more = remaining.length > 5 ? `\n …(其余 ${remaining.length - 5} 行见上方测试输出)` : ''
|
|
303
|
+
detail = `${remaining.length} 个失败行未命中 known_failures 清单:\n - ${sample.join('\n - ')}${more}\n → 若是预存债(变更前就失败),加入 local.yaml 的 known_failures 清单;若是本次变更引入的真实失败,请修复`
|
|
304
|
+
} else {
|
|
305
|
+
detail = '失败输出未检测到可豁免的失败行(保守判 fail)'
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
status: 'failed',
|
|
309
|
+
reason: baseReason ? `${baseReason}(${detail})` : detail,
|
|
310
|
+
exemptedCount: exempted.length,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
|
|
316
|
+
* - module + 命中模块 → 'module-subset'
|
|
317
|
+
* - module + 0 命中 → 'module-zero-hit-skip'(不静默回退注定超时/预存失败的全量)
|
|
318
|
+
* - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
|
|
319
|
+
* @returns {'module-subset'|'module-zero-hit-skip'|'full'}
|
|
320
|
+
*/
|
|
321
|
+
export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
|
|
322
|
+
if (strategy === 'module' && modulesPresent) {
|
|
323
|
+
if (hitCount > 0) return 'module-subset'
|
|
324
|
+
if (hitCount === 0) return 'module-zero-hit-skip'
|
|
325
|
+
return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
|
|
326
|
+
}
|
|
327
|
+
return 'full'
|
|
328
|
+
}
|
|
329
|
+
|
|
214
330
|
/**
|
|
215
331
|
* 跑单个模块的 test 命令(串行调用方逐个调用)。
|
|
216
332
|
* @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
|
|
217
333
|
*/
|
|
218
|
-
function runOneModule(name, testCommand, cwd) {
|
|
334
|
+
function runOneModule(name, testCommand, cwd, knownFailures = []) {
|
|
219
335
|
const startedAt = Date.now()
|
|
220
336
|
let exitCode = 0
|
|
221
337
|
let output = ''
|
|
@@ -237,26 +353,27 @@ function runOneModule(name, testCommand, cwd) {
|
|
|
237
353
|
}
|
|
238
354
|
const durationMs = Date.now() - startedAt
|
|
239
355
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
356
|
+
const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
|
|
240
357
|
return {
|
|
241
358
|
name,
|
|
242
|
-
status:
|
|
359
|
+
status: judged.status,
|
|
243
360
|
command: testCommand,
|
|
244
361
|
exitCode,
|
|
245
362
|
durationMs,
|
|
246
363
|
outputTail,
|
|
247
|
-
reason,
|
|
364
|
+
reason: judged.reason,
|
|
365
|
+
exemptedCount: judged.exemptedCount,
|
|
248
366
|
}
|
|
249
367
|
}
|
|
250
368
|
|
|
251
369
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* 它同时覆盖已暂存与未暂存改动(相对 HEAD),最适合 worktree 内尚未 commit 的场景。
|
|
255
|
-
* git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
|
|
370
|
+
* 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
|
|
371
|
+
* git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
|
|
256
372
|
*/
|
|
257
|
-
function
|
|
373
|
+
function runGitDiffNameOnly(cwd, refSpec) {
|
|
258
374
|
try {
|
|
259
|
-
const
|
|
375
|
+
const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
|
|
376
|
+
const out = execSync(cmd, {
|
|
260
377
|
cwd,
|
|
261
378
|
encoding: 'utf8',
|
|
262
379
|
timeout: 30 * 1000,
|
|
@@ -264,19 +381,78 @@ function gitChangedFiles(cwd) {
|
|
|
264
381
|
})
|
|
265
382
|
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
266
383
|
} catch {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
|
|
390
|
+
* 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
|
|
391
|
+
*/
|
|
392
|
+
function runGitDiffNameStatus(cwd, refSpec) {
|
|
393
|
+
try {
|
|
394
|
+
const cmd = refSpec ? `git diff --name-status ${refSpec}` : 'git diff --name-status'
|
|
395
|
+
return execSync(cmd, {
|
|
396
|
+
cwd,
|
|
397
|
+
encoding: 'utf8',
|
|
398
|
+
timeout: 30 * 1000,
|
|
399
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
400
|
+
})
|
|
401
|
+
} catch {
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
|
|
408
|
+
* `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
|
|
409
|
+
* 最适合 brownfield(apply 后未 commit / in-place 改动)场景。
|
|
410
|
+
* git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
|
|
411
|
+
*
|
|
412
|
+
* 注意:worktree 隔离模式下主仓只剩 .sillyspec/ 文档改动、代码在 worktree,
|
|
413
|
+
* 本函数看不到——runVerifyTestCheck 用 resolveVerifyChangedFiles 统一处理。
|
|
414
|
+
*/
|
|
415
|
+
function gitChangedFiles(cwd) {
|
|
416
|
+
const head = runGitDiffNameOnly(cwd, 'HEAD')
|
|
417
|
+
if (head !== null) return head
|
|
418
|
+
// HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
|
|
419
|
+
return runGitDiffNameOnly(cwd, '')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* 解析 verify 对账用的变更文件集(worktree-aware)。
|
|
424
|
+
*
|
|
425
|
+
* worktree 隔离模式下 execute 的代码改动落在 worktree,主仓工作区只剩
|
|
426
|
+
* .sillyspec/ 文档改动。若仍用主仓 `git diff --name-only HEAD`,hitCount=0
|
|
427
|
+
* → 回退注定超时且含预存失败的全量 commands.test → verify 完成被阻断
|
|
428
|
+
* (坑 verify-worktree-mode-test-reconciliation-fallback-full)。
|
|
429
|
+
*
|
|
430
|
+
* 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
|
|
431
|
+
* 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
|
|
432
|
+
* 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
|
|
433
|
+
* 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
|
|
434
|
+
*
|
|
435
|
+
* @param {string} cwd - 项目根目录(主仓)
|
|
436
|
+
* @param {string|null} changeName
|
|
437
|
+
* @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
|
|
438
|
+
*/
|
|
439
|
+
export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
440
|
+
if (changeName) {
|
|
441
|
+
const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
|
|
442
|
+
if (existsSync(metaPath)) {
|
|
443
|
+
let meta = null
|
|
444
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
|
|
445
|
+
if (meta?.baseHash) {
|
|
446
|
+
const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
|
|
447
|
+
? meta.worktreePath
|
|
448
|
+
: cwd
|
|
449
|
+
const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
|
|
450
|
+
if (files !== null) return files
|
|
451
|
+
// worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
|
|
452
|
+
}
|
|
278
453
|
}
|
|
279
454
|
}
|
|
455
|
+
return gitChangedFiles(cwd)
|
|
280
456
|
}
|
|
281
457
|
|
|
282
458
|
/**
|
|
@@ -301,41 +477,57 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
|
301
477
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
302
478
|
|
|
303
479
|
const strategy = extractTestStrategy(yamlText)
|
|
480
|
+
const knownFailures = extractKnownFailures(yamlText)
|
|
304
481
|
|
|
305
|
-
// —— 模块子集路径(test_strategy: module
|
|
306
|
-
//
|
|
307
|
-
// 命中即走子集。gitChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
|
|
482
|
+
// —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
|
|
483
|
+
// resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
|
|
308
484
|
let modulesPresent = false
|
|
309
485
|
let hitCount = 0
|
|
486
|
+
let hits = []
|
|
310
487
|
if (strategy === 'module') {
|
|
311
488
|
const modules = extractModules(yamlText)
|
|
312
489
|
if (modules) {
|
|
313
490
|
modulesPresent = true
|
|
314
|
-
const changedFiles =
|
|
491
|
+
const changedFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
315
492
|
if (changedFiles === null) {
|
|
316
493
|
hitCount = -1 // git 不可用 / 非仓库
|
|
317
494
|
} else {
|
|
318
|
-
|
|
495
|
+
hits = pickHitModules(changedFiles, modules)
|
|
319
496
|
hitCount = hits.length
|
|
320
|
-
if (hitCount > 0) {
|
|
321
|
-
return runModuleSubset({ cwd, specBase, changeName, hits })
|
|
322
|
-
}
|
|
323
|
-
// 有 modules 配置但无命中 → fallback commands.test(D-002@v1 向后兼容)
|
|
324
497
|
}
|
|
325
498
|
}
|
|
326
|
-
// test_strategy:module 但无 modules 配置 → fallback commands.test(brownfield 友好)
|
|
327
499
|
}
|
|
328
500
|
|
|
329
|
-
|
|
501
|
+
const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
|
|
502
|
+
if (action === 'module-subset') {
|
|
503
|
+
return runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
|
|
504
|
+
}
|
|
505
|
+
if (action === 'module-zero-hit-skip') {
|
|
506
|
+
// module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
|
|
507
|
+
// 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
|
|
508
|
+
return {
|
|
509
|
+
status: 'skipped',
|
|
510
|
+
command: null,
|
|
511
|
+
exitCode: null,
|
|
512
|
+
durationMs: null,
|
|
513
|
+
outputTail: null,
|
|
514
|
+
reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。',
|
|
515
|
+
resultPath: null,
|
|
516
|
+
mode: 'module-zero-hit',
|
|
517
|
+
fallbackReason: null,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// —— 全量路径(full / module 无块 / module git 不可用)——
|
|
330
522
|
// fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
|
|
331
523
|
const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
|
|
332
|
-
return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason })
|
|
524
|
+
return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
|
|
333
525
|
}
|
|
334
526
|
|
|
335
527
|
/**
|
|
336
528
|
* 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
|
|
337
529
|
*/
|
|
338
|
-
function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null }) {
|
|
530
|
+
function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
|
|
339
531
|
const command = extractTestCommand(yamlText)
|
|
340
532
|
|
|
341
533
|
if (!command) {
|
|
@@ -376,16 +568,18 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
376
568
|
const durationMs = Date.now() - startedAt
|
|
377
569
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
378
570
|
|
|
571
|
+
const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
|
|
379
572
|
const result = {
|
|
380
|
-
status:
|
|
573
|
+
status: judged.status,
|
|
381
574
|
command,
|
|
382
575
|
exitCode,
|
|
383
576
|
durationMs,
|
|
384
577
|
outputTail,
|
|
385
|
-
reason,
|
|
578
|
+
reason: judged.reason,
|
|
386
579
|
resultPath: null,
|
|
387
580
|
mode: 'full',
|
|
388
581
|
fallbackReason,
|
|
582
|
+
exemptedCount: judged.exemptedCount,
|
|
389
583
|
}
|
|
390
584
|
|
|
391
585
|
writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
|
|
@@ -396,9 +590,9 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
396
590
|
* 串行跑命中的模块子集,聚合结果。
|
|
397
591
|
* 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
|
|
398
592
|
*/
|
|
399
|
-
function runModuleSubset({ cwd, specBase, changeName, hits }) {
|
|
593
|
+
function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
|
|
400
594
|
const subsetStartedAt = Date.now()
|
|
401
|
-
const perModule = hits.map(h => runOneModule(h.name, h.test, cwd))
|
|
595
|
+
const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
|
|
402
596
|
const status = aggregateStatus(perModule)
|
|
403
597
|
|
|
404
598
|
const command = `module[${hits.map(h => h.name).join(',')}]`
|
|
@@ -423,6 +617,7 @@ function runModuleSubset({ cwd, specBase, changeName, hits }) {
|
|
|
423
617
|
resultPath: null,
|
|
424
618
|
mode: 'module-subset',
|
|
425
619
|
fallbackReason: null,
|
|
620
|
+
exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
|
|
426
621
|
}
|
|
427
622
|
|
|
428
623
|
writeRunResult({
|
|
@@ -479,7 +674,12 @@ export function printVerifyTestCheck(result) {
|
|
|
479
674
|
return
|
|
480
675
|
}
|
|
481
676
|
if (result.status === 'passed') {
|
|
482
|
-
|
|
677
|
+
if (result.exemptedCount > 0) {
|
|
678
|
+
console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
|
|
679
|
+
console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
|
|
680
|
+
} else {
|
|
681
|
+
console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
|
|
682
|
+
}
|
|
483
683
|
} else {
|
|
484
684
|
console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
|
|
485
685
|
if (result.outputTail) {
|
|
@@ -545,3 +745,118 @@ export function printVerifyParityCheck(result) {
|
|
|
545
745
|
if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
|
|
546
746
|
console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
|
|
547
747
|
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* 删除探针(advisory):用 git 事实客观对账本次变更删除的文件 vs design.md 声明的操作。
|
|
751
|
+
*
|
|
752
|
+
* 切斯特顿栅栏护栏——verify 对「agent 静默删除代码」本是盲区:agent 删一段它看不懂的
|
|
753
|
+
* 旧代码,只要路径合规、不碰风险关键词,5 探针 + 风险分级 + 测试对账全都不会响。本探针
|
|
754
|
+
* 不信任 agent 自报告,用 `git diff --name-status HEAD` 客观提取删除文件,对账 design
|
|
755
|
+
* 清单声明的操作(声明「新增/修改」却整文件删除 = 高风险)。warning 不阻断 verify 完成
|
|
756
|
+
* (advisory 起步)——「检测到删除」是确定性事实(做),「该不该删」是意图(只报不拦)。
|
|
757
|
+
*
|
|
758
|
+
* 信号源:apply(git apply --3way)不 commit,verify 时主仓 HEAD 仍是变更前 commit,
|
|
759
|
+
* 删除的文件在工作树消失但仍在 HEAD → `git diff --name-status HEAD` 显示 D。
|
|
760
|
+
*
|
|
761
|
+
* @param {object} opts
|
|
762
|
+
* @param {string} opts.cwd - 项目根(主仓,代码在此)
|
|
763
|
+
* @param {string} opts.specBase - .sillyspec(或平台 specRoot)
|
|
764
|
+
* @param {string|null} [opts.changeName]
|
|
765
|
+
* @returns {{
|
|
766
|
+
* status: 'skipped'|'warning'|'passed',
|
|
767
|
+
* highRisk: Array<{path:string, declaredOp:string, reason:string}>,
|
|
768
|
+
* mediumRisk: Array<{path:string, reason:string}>,
|
|
769
|
+
* compliant: Array<{path:string, declaredOp:string}>,
|
|
770
|
+
* summary: string,
|
|
771
|
+
* reason: string|null,
|
|
772
|
+
* }}
|
|
773
|
+
*/
|
|
774
|
+
export function runVerifyDeletionCheck({ cwd, specBase, changeName = null }) {
|
|
775
|
+
const statusRaw = runGitDiffNameStatus(cwd, 'HEAD')
|
|
776
|
+
if (statusRaw === null) {
|
|
777
|
+
return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
|
|
778
|
+
summary: '', reason: 'git 不可用或非仓库,删除对账跳过' }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 解析 D(删除);R/C 的旧路径等价删除(防御 committed rename;标准 apply 流程
|
|
782
|
+
// rename 表现为纯 D,故本探针不加 -M,D 统一处理)。
|
|
783
|
+
const deletions = []
|
|
784
|
+
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
785
|
+
const parts = line.split('\t')
|
|
786
|
+
const st = parts[0]
|
|
787
|
+
if (!st) continue
|
|
788
|
+
if (st.startsWith('D')) {
|
|
789
|
+
deletions.push({ path: parts[1], kind: 'D' })
|
|
790
|
+
} else if ((st.startsWith('R') || st.startsWith('C')) && parts.length >= 3) {
|
|
791
|
+
// rename/copy 旧路径(parts[1])等价删除,新路径 parts[2]
|
|
792
|
+
deletions.push({ path: parts[1], kind: 'R-old', renamedTo: parts[2] })
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 排除交付物外文件(.sillyspec/ 文档 churn + meta.json),避免污染删除信号。
|
|
797
|
+
// 内联 filterDeliverableFiles 逻辑(一行,不跨模块耦合)。
|
|
798
|
+
const deliverable = deletions.filter(d =>
|
|
799
|
+
!d.path.startsWith('.sillyspec/') && d.path !== 'meta.json')
|
|
800
|
+
|
|
801
|
+
if (deliverable.length === 0) {
|
|
802
|
+
return { status: 'skipped', highRisk: [], mediumRisk: [], compliant: [],
|
|
803
|
+
summary: '', reason: '本次变更无文件删除(或改动已被 commit,主仓 HEAD 已推进,删除对账无锚点)' }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 读 design 声明(operation)。无清单章节 → [],所有删除归「未声明」。
|
|
807
|
+
const designPath = changeName
|
|
808
|
+
? join(specBase, 'changes', changeName, 'design.md') : null
|
|
809
|
+
const designEntries = designPath && existsSync(designPath)
|
|
810
|
+
? parseFileChangeListDetailed(designPath) : []
|
|
811
|
+
|
|
812
|
+
const highRisk = []
|
|
813
|
+
const mediumRisk = []
|
|
814
|
+
const compliant = []
|
|
815
|
+
for (const d of deliverable) {
|
|
816
|
+
const hit = designEntries.find(e => pathMatches(d.path, e.path))
|
|
817
|
+
if (!hit) {
|
|
818
|
+
mediumRisk.push({ path: d.path, reason: d.kind === 'R-old'
|
|
819
|
+
? `重命名源文件未在 design 清单(→ ${d.renamedTo})`
|
|
820
|
+
: 'design 清单未列出该删除文件' })
|
|
821
|
+
continue
|
|
822
|
+
}
|
|
823
|
+
if (hit.operation === '新增' || hit.operation === '修改') {
|
|
824
|
+
highRisk.push({ path: d.path, declaredOp: hit.operation,
|
|
825
|
+
reason: `design 声明「${hit.operation}」但 git 显示整文件删除` })
|
|
826
|
+
} else if (hit.operation === '删除') {
|
|
827
|
+
compliant.push({ path: d.path, declaredOp: '删除' })
|
|
828
|
+
} else {
|
|
829
|
+
// operation=null(未声明操作)或 重命名:删除发生但声明不明确
|
|
830
|
+
mediumRisk.push({ path: d.path,
|
|
831
|
+
reason: `design 列出但操作为「${hit.operation || '未声明'}」,与删除不一致` })
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (highRisk.length > 0 || mediumRisk.length > 0) {
|
|
836
|
+
return { status: 'warning', highRisk, mediumRisk, compliant,
|
|
837
|
+
summary: `${highRisk.length} 个高风险删除 + ${mediumRisk.length} 个未声明删除`, reason: null }
|
|
838
|
+
}
|
|
839
|
+
return { status: 'passed', highRisk: [], mediumRisk: [], compliant,
|
|
840
|
+
summary: `所有 ${compliant.length} 个删除均在 design 声明为「删除」`, reason: null }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** 打印删除对账结果(advisory,不阻断 verify 完成) */
|
|
844
|
+
export function printVerifyDeletionCheck(result) {
|
|
845
|
+
if (result.status === 'skipped') return // 静默:无删除 / git 不可用 / 改动已 commit
|
|
846
|
+
if (result.status === 'passed') {
|
|
847
|
+
console.log(`\n✅ 删除对账通过:${result.summary}`)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
// warning
|
|
851
|
+
if (result.highRisk.length > 0) {
|
|
852
|
+
console.warn(`\n⚠️ 删除对账发现 ${result.highRisk.length} 个高风险删除(design 声明新增/修改却被整文件删除,advisory 不阻断):`)
|
|
853
|
+
for (const m of result.highRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
|
|
854
|
+
if (result.highRisk.length > 20) console.warn(` …还有 ${result.highRisk.length - 20} 个`)
|
|
855
|
+
}
|
|
856
|
+
if (result.mediumRisk.length > 0) {
|
|
857
|
+
console.warn(`⚠️ ${result.mediumRisk.length} 个未声明删除(design 清单未列出 / 操作不一致):`)
|
|
858
|
+
for (const m of result.mediumRisk.slice(0, 20)) console.warn(` - ${m.path} (${m.reason})`)
|
|
859
|
+
if (result.mediumRisk.length > 20) console.warn(` …还有 ${result.mediumRisk.length - 20} 个`)
|
|
860
|
+
}
|
|
861
|
+
console.warn(' 提示:确认删除是否预期。预期删除请在 design.md 清单用「删除」操作显式声明;误删请恢复。')
|
|
862
|
+
}
|