sillyspec 3.25.3 → 3.25.5
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 +26 -0
- 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-quick/SKILL.md +2 -1
- package/package.json +1 -1
- package/src/change-list.js +14 -0
- package/src/change-risk-profile.js +36 -1
- package/src/index.js +3 -0
- package/src/machine-interface.js +5 -1
- package/src/progress/stage-machine.js +17 -14
- package/src/run/command.js +1 -1
- package/src/run/complete-handlers.js +4 -2
- package/src/run/complete.js +110 -32
- package/src/run/prompt.js +20 -0
- package/src/stage-contract.js +33 -7
- package/src/stage-review.js +16 -6
- package/src/stages/archive.js +13 -9
- package/src/stages/brainstorm-auto.js +3 -2
- package/src/stages/brainstorm.js +4 -2
- package/src/stages/execute.js +4 -3
- package/src/stages/plan-postcheck.js +3 -2
- package/src/stages/plan.js +7 -0
- package/src/stages/propose.js +3 -2
- package/src/stages/quick.js +18 -4
- package/src/stages/status.js +2 -2
- package/src/stages/verify.js +13 -5
- package/src/sync.js +10 -3
- package/src/task-review.js +149 -3
- package/src/verify-postcheck.js +208 -38
- package/src/worktree-apply.js +101 -39
- package/src/worktree.js +106 -32
package/src/verify-postcheck.js
CHANGED
|
@@ -211,11 +211,115 @@ export function aggregateStatus(results) {
|
|
|
211
211
|
return 'failed'
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 从 local.yaml 提取 known_failures 声明(预存失败豁免清单,坑 verify-worktree-... 修复方向 2)。
|
|
216
|
+
* 支持块式与流式两种写法:
|
|
217
|
+
* known_failures:
|
|
218
|
+
* - "tests/test_ppm.py::test_legacy"
|
|
219
|
+
* - app/modules/plan/test_old
|
|
220
|
+
* 或 known_failures: [a, b]
|
|
221
|
+
* 每条作为子串(大小写不敏感)匹配测试输出中的失败行。
|
|
222
|
+
*
|
|
223
|
+
* @param {string} yamlText
|
|
224
|
+
* @returns {string[]} 模式列表;无声明返回 []
|
|
225
|
+
*/
|
|
226
|
+
export function extractKnownFailures(yamlText) {
|
|
227
|
+
if (!yamlText) return []
|
|
228
|
+
const inline = yamlText.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
|
|
229
|
+
if (inline) {
|
|
230
|
+
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
231
|
+
}
|
|
232
|
+
const block = yamlText.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
|
|
233
|
+
if (block) {
|
|
234
|
+
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
235
|
+
.map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
}
|
|
238
|
+
return []
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
|
|
242
|
+
// 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
|
|
243
|
+
// "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
|
|
244
|
+
const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
|
|
245
|
+
// 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
|
|
246
|
+
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
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
|
|
250
|
+
* @param {string} output
|
|
251
|
+
* @param {string[]} knownFailures 子串模式列表(大小写不敏感)
|
|
252
|
+
* @returns {{ failureLines: string[], exempted: string[], remaining: string[] }}
|
|
253
|
+
*/
|
|
254
|
+
export function partitionFailures(output, knownFailures) {
|
|
255
|
+
const lines = String(output || '').split(/\r?\n/)
|
|
256
|
+
const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
|
|
257
|
+
const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
|
|
258
|
+
const exempted = []
|
|
259
|
+
const remaining = []
|
|
260
|
+
for (const l of failureLines) {
|
|
261
|
+
const ll = l.toLowerCase()
|
|
262
|
+
if (pats.some(p => ll.includes(p))) exempted.push(l)
|
|
263
|
+
else remaining.push(l)
|
|
264
|
+
}
|
|
265
|
+
return { failureLines, exempted, remaining }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 结合 known_failures 判定单次测试运行的状态(fail-safe)。
|
|
270
|
+
* - exitCode 0 → passed
|
|
271
|
+
* - exitCode≠0 且无 known_failures → failed(原行为)
|
|
272
|
+
* - exitCode≠0 且有 known_failures:
|
|
273
|
+
* 检测到失败行且全部命中豁免 → passed(披露:请人工复核清单是否过宽)
|
|
274
|
+
* 有未豁免失败行 / 检测不到失败行(保守)→ failed
|
|
275
|
+
* fail-safe:检测不到失败行绝不自动 pass(避免解析盲区导致假 PASS)。
|
|
276
|
+
*
|
|
277
|
+
* @returns {{ status: 'passed'|'failed', reason: string|null, exemptedCount: number }}
|
|
278
|
+
*/
|
|
279
|
+
export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailures) {
|
|
280
|
+
if (exitCode === 0) return { status: 'passed', reason: baseReason, exemptedCount: 0 }
|
|
281
|
+
if (!knownFailures || knownFailures.length === 0) {
|
|
282
|
+
return { status: 'failed', reason: baseReason, exemptedCount: 0 }
|
|
283
|
+
}
|
|
284
|
+
const { failureLines, exempted, remaining } = partitionFailures(output, knownFailures)
|
|
285
|
+
if (failureLines.length > 0 && remaining.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
status: 'passed',
|
|
288
|
+
reason: `全部 ${failureLines.length} 个失败行命中 known_failures 已豁免(${exempted.length} 条)— 请人工复核豁免清单是否过宽`,
|
|
289
|
+
exemptedCount: exempted.length,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const detail = remaining.length > 0
|
|
293
|
+
? `${remaining.length} 个失败行未命中 known_failures`
|
|
294
|
+
: '失败输出未检测到可豁免的失败行(保守判 fail)'
|
|
295
|
+
return {
|
|
296
|
+
status: 'failed',
|
|
297
|
+
reason: baseReason ? `${baseReason}(${detail})` : detail,
|
|
298
|
+
exemptedCount: exempted.length,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
|
|
304
|
+
* - module + 命中模块 → 'module-subset'
|
|
305
|
+
* - module + 0 命中 → 'module-zero-hit-skip'(不静默回退注定超时/预存失败的全量)
|
|
306
|
+
* - 其余(full / module 无块 / module git 不可用 hitCount=-1)→ 'full'
|
|
307
|
+
* @returns {'module-subset'|'module-zero-hit-skip'|'full'}
|
|
308
|
+
*/
|
|
309
|
+
export function decideVerifyTestAction({ strategy, modulesPresent, hitCount }) {
|
|
310
|
+
if (strategy === 'module' && modulesPresent) {
|
|
311
|
+
if (hitCount > 0) return 'module-subset'
|
|
312
|
+
if (hitCount === 0) return 'module-zero-hit-skip'
|
|
313
|
+
return 'full' // hitCount === -1(git 不可用)→ 落全量兜底
|
|
314
|
+
}
|
|
315
|
+
return 'full'
|
|
316
|
+
}
|
|
317
|
+
|
|
214
318
|
/**
|
|
215
319
|
* 跑单个模块的 test 命令(串行调用方逐个调用)。
|
|
216
320
|
* @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
|
|
217
321
|
*/
|
|
218
|
-
function runOneModule(name, testCommand, cwd) {
|
|
322
|
+
function runOneModule(name, testCommand, cwd, knownFailures = []) {
|
|
219
323
|
const startedAt = Date.now()
|
|
220
324
|
let exitCode = 0
|
|
221
325
|
let output = ''
|
|
@@ -237,26 +341,27 @@ function runOneModule(name, testCommand, cwd) {
|
|
|
237
341
|
}
|
|
238
342
|
const durationMs = Date.now() - startedAt
|
|
239
343
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
344
|
+
const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
|
|
240
345
|
return {
|
|
241
346
|
name,
|
|
242
|
-
status:
|
|
347
|
+
status: judged.status,
|
|
243
348
|
command: testCommand,
|
|
244
349
|
exitCode,
|
|
245
350
|
durationMs,
|
|
246
351
|
outputTail,
|
|
247
|
-
reason,
|
|
352
|
+
reason: judged.reason,
|
|
353
|
+
exemptedCount: judged.exemptedCount,
|
|
248
354
|
}
|
|
249
355
|
}
|
|
250
356
|
|
|
251
357
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* 它同时覆盖已暂存与未暂存改动(相对 HEAD),最适合 worktree 内尚未 commit 的场景。
|
|
255
|
-
* git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
|
|
358
|
+
* 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
|
|
359
|
+
* git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
|
|
256
360
|
*/
|
|
257
|
-
function
|
|
361
|
+
function runGitDiffNameOnly(cwd, refSpec) {
|
|
258
362
|
try {
|
|
259
|
-
const
|
|
363
|
+
const cmd = refSpec ? `git diff --name-only ${refSpec}` : 'git diff --name-only'
|
|
364
|
+
const out = execSync(cmd, {
|
|
260
365
|
cwd,
|
|
261
366
|
encoding: 'utf8',
|
|
262
367
|
timeout: 30 * 1000,
|
|
@@ -264,19 +369,60 @@ function gitChangedFiles(cwd) {
|
|
|
264
369
|
})
|
|
265
370
|
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
266
371
|
} catch {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
|
|
378
|
+
* `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
|
|
379
|
+
* 最适合 brownfield(apply 后未 commit / in-place 改动)场景。
|
|
380
|
+
* git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
|
|
381
|
+
*
|
|
382
|
+
* 注意:worktree 隔离模式下主仓只剩 .sillyspec/ 文档改动、代码在 worktree,
|
|
383
|
+
* 本函数看不到——runVerifyTestCheck 用 resolveVerifyChangedFiles 统一处理。
|
|
384
|
+
*/
|
|
385
|
+
function gitChangedFiles(cwd) {
|
|
386
|
+
const head = runGitDiffNameOnly(cwd, 'HEAD')
|
|
387
|
+
if (head !== null) return head
|
|
388
|
+
// HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
|
|
389
|
+
return runGitDiffNameOnly(cwd, '')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 解析 verify 对账用的变更文件集(worktree-aware)。
|
|
394
|
+
*
|
|
395
|
+
* worktree 隔离模式下 execute 的代码改动落在 worktree,主仓工作区只剩
|
|
396
|
+
* .sillyspec/ 文档改动。若仍用主仓 `git diff --name-only HEAD`,hitCount=0
|
|
397
|
+
* → 回退注定超时且含预存失败的全量 commands.test → verify 完成被阻断
|
|
398
|
+
* (坑 verify-worktree-mode-test-reconciliation-fallback-full)。
|
|
399
|
+
*
|
|
400
|
+
* 判定(与 checkExecuteCodeEvidence / task-review 同源,meta.json 为权威):
|
|
401
|
+
* 1. change 有 worktree meta 且 baseHash 存在 → 在 worktree(或 in-place 的 cwd)
|
|
402
|
+
* 跑 `git diff --name-only <baseHash>..HEAD` 取真实代码改动集
|
|
403
|
+
* 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
|
|
404
|
+
*
|
|
405
|
+
* @param {string} cwd - 项目根目录(主仓)
|
|
406
|
+
* @param {string|null} changeName
|
|
407
|
+
* @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
|
|
408
|
+
*/
|
|
409
|
+
export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
410
|
+
if (changeName) {
|
|
411
|
+
const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
|
|
412
|
+
if (existsSync(metaPath)) {
|
|
413
|
+
let meta = null
|
|
414
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
|
|
415
|
+
if (meta?.baseHash) {
|
|
416
|
+
const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
|
|
417
|
+
? meta.worktreePath
|
|
418
|
+
: cwd
|
|
419
|
+
const files = runGitDiffNameOnly(gitDir, `${meta.baseHash}..HEAD`)
|
|
420
|
+
if (files !== null) return files
|
|
421
|
+
// worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
|
|
422
|
+
}
|
|
278
423
|
}
|
|
279
424
|
}
|
|
425
|
+
return gitChangedFiles(cwd)
|
|
280
426
|
}
|
|
281
427
|
|
|
282
428
|
/**
|
|
@@ -301,41 +447,57 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
|
301
447
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
302
448
|
|
|
303
449
|
const strategy = extractTestStrategy(yamlText)
|
|
450
|
+
const knownFailures = extractKnownFailures(yamlText)
|
|
304
451
|
|
|
305
|
-
// —— 模块子集路径(test_strategy: module
|
|
306
|
-
//
|
|
307
|
-
// 命中即走子集。gitChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
|
|
452
|
+
// —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
|
|
453
|
+
// resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
|
|
308
454
|
let modulesPresent = false
|
|
309
455
|
let hitCount = 0
|
|
456
|
+
let hits = []
|
|
310
457
|
if (strategy === 'module') {
|
|
311
458
|
const modules = extractModules(yamlText)
|
|
312
459
|
if (modules) {
|
|
313
460
|
modulesPresent = true
|
|
314
|
-
const changedFiles =
|
|
461
|
+
const changedFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
315
462
|
if (changedFiles === null) {
|
|
316
463
|
hitCount = -1 // git 不可用 / 非仓库
|
|
317
464
|
} else {
|
|
318
|
-
|
|
465
|
+
hits = pickHitModules(changedFiles, modules)
|
|
319
466
|
hitCount = hits.length
|
|
320
|
-
if (hitCount > 0) {
|
|
321
|
-
return runModuleSubset({ cwd, specBase, changeName, hits })
|
|
322
|
-
}
|
|
323
|
-
// 有 modules 配置但无命中 → fallback commands.test(D-002@v1 向后兼容)
|
|
324
467
|
}
|
|
325
468
|
}
|
|
326
|
-
// test_strategy:module 但无 modules 配置 → fallback commands.test(brownfield 友好)
|
|
327
469
|
}
|
|
328
470
|
|
|
329
|
-
|
|
471
|
+
const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
|
|
472
|
+
if (action === 'module-subset') {
|
|
473
|
+
return runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
|
|
474
|
+
}
|
|
475
|
+
if (action === 'module-zero-hit-skip') {
|
|
476
|
+
// module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
|
|
477
|
+
// 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
|
|
478
|
+
return {
|
|
479
|
+
status: 'skipped',
|
|
480
|
+
command: null,
|
|
481
|
+
exitCode: null,
|
|
482
|
+
durationMs: null,
|
|
483
|
+
outputTail: null,
|
|
484
|
+
reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。',
|
|
485
|
+
resultPath: null,
|
|
486
|
+
mode: 'module-zero-hit',
|
|
487
|
+
fallbackReason: null,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// —— 全量路径(full / module 无块 / module git 不可用)——
|
|
330
492
|
// fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
|
|
331
493
|
const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
|
|
332
|
-
return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason })
|
|
494
|
+
return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
|
|
333
495
|
}
|
|
334
496
|
|
|
335
497
|
/**
|
|
336
498
|
* 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
|
|
337
499
|
*/
|
|
338
|
-
function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null }) {
|
|
500
|
+
function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
|
|
339
501
|
const command = extractTestCommand(yamlText)
|
|
340
502
|
|
|
341
503
|
if (!command) {
|
|
@@ -376,16 +538,18 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
376
538
|
const durationMs = Date.now() - startedAt
|
|
377
539
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
378
540
|
|
|
541
|
+
const judged = judgeWithKnownFailures(exitCode, output, reason, knownFailures)
|
|
379
542
|
const result = {
|
|
380
|
-
status:
|
|
543
|
+
status: judged.status,
|
|
381
544
|
command,
|
|
382
545
|
exitCode,
|
|
383
546
|
durationMs,
|
|
384
547
|
outputTail,
|
|
385
|
-
reason,
|
|
548
|
+
reason: judged.reason,
|
|
386
549
|
resultPath: null,
|
|
387
550
|
mode: 'full',
|
|
388
551
|
fallbackReason,
|
|
552
|
+
exemptedCount: judged.exemptedCount,
|
|
389
553
|
}
|
|
390
554
|
|
|
391
555
|
writeRunResult({ specBase, changeName, result, extra: fallbackReason ? { fallback_reason: fallbackReason } : {} })
|
|
@@ -396,9 +560,9 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
396
560
|
* 串行跑命中的模块子集,聚合结果。
|
|
397
561
|
* 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
|
|
398
562
|
*/
|
|
399
|
-
function runModuleSubset({ cwd, specBase, changeName, hits }) {
|
|
563
|
+
function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
|
|
400
564
|
const subsetStartedAt = Date.now()
|
|
401
|
-
const perModule = hits.map(h => runOneModule(h.name, h.test, cwd))
|
|
565
|
+
const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
|
|
402
566
|
const status = aggregateStatus(perModule)
|
|
403
567
|
|
|
404
568
|
const command = `module[${hits.map(h => h.name).join(',')}]`
|
|
@@ -423,6 +587,7 @@ function runModuleSubset({ cwd, specBase, changeName, hits }) {
|
|
|
423
587
|
resultPath: null,
|
|
424
588
|
mode: 'module-subset',
|
|
425
589
|
fallbackReason: null,
|
|
590
|
+
exemptedCount: perModule.reduce((n, r) => n + (r.exemptedCount || 0), 0),
|
|
426
591
|
}
|
|
427
592
|
|
|
428
593
|
writeRunResult({
|
|
@@ -479,7 +644,12 @@ export function printVerifyTestCheck(result) {
|
|
|
479
644
|
return
|
|
480
645
|
}
|
|
481
646
|
if (result.status === 'passed') {
|
|
482
|
-
|
|
647
|
+
if (result.exemptedCount > 0) {
|
|
648
|
+
console.log(`\n✅ Verify 实测通过(含 ${result.exemptedCount} 个 known_failures 豁免):\`${result.command}\` — ${result.reason}`)
|
|
649
|
+
console.warn(' ⚠️ 本次 PASS 依赖 known_failures 豁免清单——请人工复核清单是否过宽(避免误豁免本变更引入的真实失败)。')
|
|
650
|
+
} else {
|
|
651
|
+
console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
|
|
652
|
+
}
|
|
483
653
|
} else {
|
|
484
654
|
console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
|
|
485
655
|
if (result.outputTail) {
|
package/src/worktree-apply.js
CHANGED
|
@@ -18,7 +18,7 @@ import { join, resolve } from 'path';
|
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
20
20
|
import { WorktreeManager } from './worktree.js';
|
|
21
|
-
import { parseFileChangeList } from './change-list.js';
|
|
21
|
+
import { parseFileChangeList, pathMatches } from './change-list.js';
|
|
22
22
|
|
|
23
23
|
const CHANGES_REL = '.sillyspec/changes';
|
|
24
24
|
|
|
@@ -48,6 +48,20 @@ export function filterDeliverableFiles(files) {
|
|
|
48
48
|
return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 校验变更文件是否都在 design.md 清单内——容差匹配(与 plan-postcheck validateDesignFileCoverage
|
|
53
|
+
* 同语义):design 清单写 glob(双星通配)或目录前缀(如 src/ 子树)也能覆盖 git diff 出的具体路径,
|
|
54
|
+
* 避免「plan 阶段放过、apply 阶段卡死」逼用户回去补字面文件名。
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} changedFiles git diff 出的具体变更路径
|
|
57
|
+
* @param {Set<string>} allowSet design.md 清单项(可能含 glob / 目录前缀)
|
|
58
|
+
* @returns {string[]} 不被任何清单项覆盖的文件(违规项,apply 将据此 BLOCK)
|
|
59
|
+
*/
|
|
60
|
+
export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
61
|
+
const allow = [...allowSet];
|
|
62
|
+
return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
/**
|
|
52
66
|
* 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
|
|
53
67
|
*
|
|
@@ -170,29 +184,30 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
170
184
|
|
|
171
185
|
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
172
186
|
if (hasAllowList) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (result.extraFiles.length > 0) {
|
|
187
|
+
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
188
|
+
if (violations.length > 0) {
|
|
189
|
+
result.extraFiles.push(...violations);
|
|
179
190
|
result.errors.push(
|
|
180
|
-
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${
|
|
191
|
+
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${violations.join('\n ')}`
|
|
181
192
|
);
|
|
182
193
|
return result;
|
|
183
194
|
}
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
// --- 4.
|
|
187
|
-
//
|
|
197
|
+
// --- 4.6 显式 --merge:用户显式选择 git merge 兜底(主干已提交推进重叠时的三方合并) ---
|
|
198
|
+
// 触发点从「4.5 baseline 漂移自动降级」改为「用户显式 --merge flag」(D-001 保留,触发方式变化)。
|
|
199
|
+
// 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
|
|
200
|
+
if (merge && !checkOnly) {
|
|
201
|
+
return applyByMerge(result, changeName, projectRoot, wm);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
|
|
205
|
+
// 分工:4.5(整树 baselineHash)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
|
|
206
|
+
// 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
|
|
207
|
+
// 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
|
|
188
208
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
189
209
|
// 否则别人改 CLAUDE.md/docs/.claude → 整树 hash 变 → apply 误阻断(多操作者仓库高频踩坑)。
|
|
190
|
-
//
|
|
191
|
-
// 做精确 per-file 校验,不读这里的 exclude。
|
|
192
|
-
// - .sillyspec/:plan/design 蓝图 + runtime 产物
|
|
193
|
-
// - .claude/:agent 配置/skills/CLAUDE.md
|
|
194
|
-
// - docs/:文档(deliverable 文档冲突由 step5a/5b 兜底)
|
|
195
|
-
// - CLAUDE.md:根 agent 指引
|
|
210
|
+
// 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
|
|
196
211
|
if (meta.baselineHash) {
|
|
197
212
|
const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
|
|
198
213
|
const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
|
|
@@ -201,13 +216,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
201
216
|
const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
|
|
202
217
|
const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
203
218
|
if (currentHash !== meta.baselineHash) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
219
|
+
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
220
|
+
const dirtyFiles = [...new Set(
|
|
221
|
+
((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
|
|
222
|
+
.concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
|
|
223
|
+
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
208
224
|
result.errors.push(
|
|
209
|
-
|
|
210
|
-
|
|
225
|
+
`主工作区有未提交的改动(execute 前后 baseline 不一致),git apply 无法安全应用。\n` +
|
|
226
|
+
(dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
|
|
227
|
+
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
|
|
211
228
|
`execute 前 baseline: ${meta.baselineHash}\n` +
|
|
212
229
|
`当前 baseline: ${currentHash}`
|
|
213
230
|
);
|
|
@@ -233,6 +250,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
233
250
|
// 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
|
|
234
251
|
// 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
|
|
235
252
|
// 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
|
|
253
|
+
// 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
|
|
254
|
+
// 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
|
|
255
|
+
// 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
|
|
256
|
+
// 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
|
|
236
257
|
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
237
258
|
const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
|
|
238
259
|
const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
|
|
@@ -244,17 +265,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
244
265
|
if (wtBlob === null && mainBlob === null) continue;
|
|
245
266
|
// 两者一致 → OK
|
|
246
267
|
if (wtBlob === mainBlob) continue;
|
|
247
|
-
// 不一致 →
|
|
268
|
+
// 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
|
|
248
269
|
result.hashMismatchFiles.push(f);
|
|
249
270
|
}
|
|
250
271
|
|
|
251
|
-
if (result.hashMismatchFiles.length > 0) {
|
|
252
|
-
result.errors.push(
|
|
253
|
-
`base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
|
|
254
|
-
);
|
|
255
|
-
return result;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
272
|
// --- 6. checkOnly 模式:到此返回 ---
|
|
259
273
|
if (checkOnly) {
|
|
260
274
|
result.ok = true;
|
|
@@ -323,19 +337,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
323
337
|
|
|
324
338
|
writeFileSync(patchPath, patchContent);
|
|
325
339
|
|
|
326
|
-
// apply --check
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
340
|
+
// apply --check 预检失败不再拦截(--check 只测 clean apply,--3way 能处理 clean apply 失败的
|
|
341
|
+
// 三路合并场景——主干已提交推进时 --check 恒失败但 --3way 可合)。故跳过 --check,直接试 --3way。
|
|
342
|
+
|
|
343
|
+
// 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
|
|
344
|
+
// tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
|
|
345
|
+
// 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
|
|
346
|
+
const trackedPatchFiles = patchFiles.filter(f => {
|
|
347
|
+
// 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
|
|
348
|
+
return gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null ? false : true;
|
|
349
|
+
});
|
|
350
|
+
const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
|
|
333
351
|
|
|
334
|
-
// apply --3way
|
|
352
|
+
// apply --3way 正式应用(主干已提交推进时自动三路合并)
|
|
335
353
|
try {
|
|
336
354
|
git(projectRoot, `apply --3way ${patchPath}`);
|
|
337
355
|
} catch (e) {
|
|
338
|
-
|
|
356
|
+
// --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
|
|
357
|
+
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
|
|
358
|
+
result.errors.push(
|
|
359
|
+
`apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
|
|
360
|
+
` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
|
|
361
|
+
`已回滚工作区到 apply 前状态(无半成品冲突标记)。\n` +
|
|
362
|
+
`可选:用 --merge 自动三方合并兜底(git merge sillyspec/${changeName},会引入合并提交):\n` +
|
|
363
|
+
` sillyspec worktree apply ${changeName} --merge\n` +
|
|
364
|
+
`或手动解决后重试。`
|
|
365
|
+
);
|
|
366
|
+
if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
|
|
339
367
|
return result;
|
|
340
368
|
}
|
|
341
369
|
|
|
@@ -360,6 +388,40 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
360
388
|
return result;
|
|
361
389
|
}
|
|
362
390
|
|
|
391
|
+
/**
|
|
392
|
+
* 回滚 --3way 冲突后的工作区到 apply 前状态(不留半成品冲突标记)。
|
|
393
|
+
* tracked 文件:git checkout -- <f> 还原 HEAD 版(清除冲突标记)。
|
|
394
|
+
* 新文件(apply 前不存在):删除(--3way 可能部分创建)。
|
|
395
|
+
* @param {string} projectRoot
|
|
396
|
+
* @param {string[]} trackedFiles 主仓库 HEAD 已存在、patch 触及的文件
|
|
397
|
+
* @param {string[]} newFiles apply 前不存在、patch 可能新建的文件
|
|
398
|
+
* @returns {{ conflicts: string[], error: string|null }} conflicts=冲突文件列表
|
|
399
|
+
*/
|
|
400
|
+
function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
401
|
+
let error = null;
|
|
402
|
+
// 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
|
|
403
|
+
let conflicts = [];
|
|
404
|
+
try {
|
|
405
|
+
const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
|
|
406
|
+
conflicts = unmerged.split('\n').filter(Boolean);
|
|
407
|
+
} catch {}
|
|
408
|
+
// 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
|
|
409
|
+
// `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
|
|
410
|
+
for (const f of trackedFiles) {
|
|
411
|
+
try { gitQuiet(projectRoot, `checkout HEAD -- ${f}`); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
|
|
412
|
+
}
|
|
413
|
+
// 删除 --3way 可能新建的文件(apply 前不存在)
|
|
414
|
+
for (const f of newFiles) {
|
|
415
|
+
try {
|
|
416
|
+
const p = join(projectRoot, f);
|
|
417
|
+
if (existsSync(p)) unlinkSync(p);
|
|
418
|
+
} catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
|
|
419
|
+
}
|
|
420
|
+
// 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
|
|
421
|
+
try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
|
|
422
|
+
return { conflicts, error };
|
|
423
|
+
}
|
|
424
|
+
|
|
363
425
|
/**
|
|
364
426
|
* baseline 漂移时的 merge 降级路径(D-001)。
|
|
365
427
|
*
|