sillyspec 3.25.5 → 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.
@@ -11,6 +11,10 @@
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
12
  import { join, resolve } from 'path'
13
13
  import { execFileSync } from 'child_process'
14
+ import { pathMatches } from './change-list.js'
15
+ import { parseAllowedPaths } from './stages/plan-postcheck.js'
16
+ import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
+ import { WorktreeManager } from './worktree.js'
14
18
 
15
19
  // ── review.json schema version ──
16
20
  export const REVIEW_SCHEMA_VERSION = 1
@@ -258,6 +262,34 @@ export function readReview(reviewPath) {
258
262
  return { ok: true, review: parsed, errors: [] }
259
263
  }
260
264
 
265
+ /**
266
+ * 校验 plan.md 里所有「已勾选 [x]」task 的 review.json 是否 schema 完整(坑 review-json-field-gap)。
267
+ *
268
+ * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑,单 task --done 不校验 →
269
+ * 子代理勾了 checkbox 却漏写/漏字段 review.json,要到收尾才暴露,用户被迫事后批量补。本函数供
270
+ * enforceReviewJsonGate 在每次 execute --done 调用:已勾 [x] = 声称完成 = 必有完整 review.json;
271
+ * 未勾 task 不校验(还没做)。readReview 已区分 missing/parseError/schemaError,这里只聚合。
272
+ *
273
+ * @param {{ planContent: string, runtimeRoot: string, executeRunId: string }} opts
274
+ * @returns {{ ok: boolean, failures: Array<{ taskId:string, reviewPath:string, kind:string, errors:string[] }> }}
275
+ * kind ∈ missing(review.json 不存在)/ parseError(JSON 坏)/ schemaError(字段缺)
276
+ */
277
+ export function validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId }) {
278
+ const failures = []
279
+ const re = /^\s*[-*]\s*\[x\]\s*task-(\d+)/gim
280
+ let m
281
+ while ((m = re.exec(planContent)) !== null) {
282
+ const taskId = `task-${m[1].padStart(2, '0')}`
283
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
284
+ const r = readReview(reviewPath)
285
+ if (!r.ok) {
286
+ const kind = r.parseError ? 'parseError' : (r.schemaError ? 'schemaError' : 'missing')
287
+ failures.push({ taskId, reviewPath, kind, errors: r.errors })
288
+ }
289
+ }
290
+ return { ok: failures.length === 0, failures }
291
+ }
292
+
261
293
  /**
262
294
  * execute --done 时的 task review 总校验
263
295
  *
@@ -548,6 +580,133 @@ export function generateExecuteRunId() {
548
580
  return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
549
581
  }
550
582
 
583
+ /**
584
+ * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
585
+ * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
586
+ *
587
+ * 根因(坑 worktree-execute-apply-friction 坑2):per-task review.json 无程序化 writer,
588
+ * 全靠子代理手写;主 agent 统一实现强依赖链/机械 task 时不走子代理 review 流程 →
589
+ * review.json 全缺,execute --done 的 Task Review Gate 报「task-XX 缺少 review.json」阻断。
590
+ *
591
+ * 复用(与 Task Review Gate / verify 同源,单一真相):
592
+ * - resolveVerifyChangedFiles(worktree-aware,meta.json 为权威)→ base..head diff 文件集
593
+ * - WorktreeManager.getMeta → baseHash(base)+ worktreePath(决定 head/gitDir,同 gates.js reviewGitDir)
594
+ * - parseAllowedPaths + pathMatches(同 worktree-apply Gate2 口径)→ task 文件归属
595
+ * - exec-id 解析同 gates.js:269 / autoCheckPlanFromReviews:current-execute-run-id-<change> marker,
596
+ * 缺失则 generateExecuteRunId + 落盘(保证草稿与后续 gate 读同一 run 目录)
597
+ *
598
+ * 行为:
599
+ * - 仅 review.json 不存在时写(幂等,已有人工/子代理 verdict 一律跳过,绝不覆盖)
600
+ * - changedFiles 为空的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent)
601
+ * - 草稿 verdict=cannot_verify + 非空 requiredEvidence(过 schema,流转 verify 阶段兑现)
602
+ * - 不属任何 task 的文件累计 unattributed(顺带修复/非源码),不强塞某 task
603
+ *
604
+ * fail-open:任何异常 → 返回统计,仅 console.warn,不阻断 execute 完成(草稿是兜底,
605
+ * 缺了也只是退回原状——gate 报缺 review.json,agent 手补,不比修复前差)。
606
+ *
607
+ * @param {{ changeName: string, cwd: string, platformOpts?: object }} opts
608
+ * @returns {Promise<{ generated: number, skipped: number, unattributed: string[], reason?: string, executeRunId?: string }>}
609
+ */
610
+ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {} }) {
611
+ const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
612
+ const runtimeRoot = platformOpts.runtimeRoot || join(specBase, '.runtime')
613
+ const changeDir = join(specBase, 'changes', changeName)
614
+ const tasksDir = join(changeDir, 'tasks')
615
+
616
+ if (!changeName) {
617
+ return { generated: 0, skipped: 0, unattributed: [], reason: '无 changeName' }
618
+ }
619
+ // 无 task 卡片目录 → 非多 task 变更,无需补草稿
620
+ if (!existsSync(tasksDir)) {
621
+ return { generated: 0, skipped: 0, unattributed: [], reason: '无 tasks/ 目录' }
622
+ }
623
+
624
+ // exec-id:与 Task Review Gate(gates.js:269)/ autoCheckPlanFromReviews 同源
625
+ const runIdFile = join(runtimeRoot, 'current-execute-run-id-' + changeName)
626
+ let executeRunId = ''
627
+ try { if (existsSync(runIdFile)) executeRunId = readFileSync(runIdFile, 'utf8').trim() } catch {}
628
+ if (!executeRunId) {
629
+ executeRunId = generateExecuteRunId()
630
+ try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
631
+ }
632
+
633
+ // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
634
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
635
+ if (!diffFiles || diffFiles.length === 0) {
636
+ return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
637
+ }
638
+
639
+ // base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
640
+ let base = null
641
+ let head = null
642
+ let reviewGitDir = cwd
643
+ try {
644
+ const wm = new WorktreeManager({ cwd })
645
+ const meta = wm.getMeta(changeName)
646
+ base = (meta && (meta.baselineCommit || meta.baseHash)) || null
647
+ if (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
648
+ reviewGitDir = meta.worktreePath
649
+ }
650
+ } catch {}
651
+ if (!base) {
652
+ return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: '无 worktree meta.baseHash(无法定 base/head)' }
653
+ }
654
+ try {
655
+ head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
656
+ } catch {
657
+ return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: 'git rev-parse HEAD 失败(' + reviewGitDir + ')' }
658
+ }
659
+
660
+ const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
661
+ let generated = 0
662
+ let skipped = 0
663
+ const attributed = new Set()
664
+
665
+ for (const tf of taskFiles) {
666
+ const taskId = tf.replace(/\.md$/, '')
667
+ const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
668
+ const reviewPath = join(reviewDir, 'review.json')
669
+
670
+ // 幂等:review.json 已存在(无论合法/解析错/schema 错)一律跳过,绝不覆盖人工/子代理 verdict
671
+ const existing = readReview(reviewPath)
672
+ if (existing.ok || existing.parseError || existing.schemaError) {
673
+ skipped++
674
+ continue
675
+ }
676
+
677
+ const content = readFileSync(join(tasksDir, tf), 'utf8')
678
+ const allowedPaths = parseAllowedPaths(content)
679
+ const changedFiles = allowedPaths.length > 0
680
+ ? diffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
681
+ : []
682
+
683
+ // 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
684
+ if (changedFiles.length === 0) {
685
+ skipped++
686
+ continue
687
+ }
688
+ changedFiles.forEach(f => attributed.add(f))
689
+
690
+ const draft = {
691
+ schemaVersion: REVIEW_SCHEMA_VERSION,
692
+ task: taskId,
693
+ base,
694
+ head,
695
+ changedFiles,
696
+ specVerdict: 'cannot_verify',
697
+ qualityVerdict: 'cannot_verify',
698
+ requiredEvidence: ['auto-generated draft: 待 agent 对照 ' + taskId + ' brief + diff 复核后升级为 pass/fail'],
699
+ reviewerNotes: 'auto-generated draft from git diff ' + base.slice(0, 8) + '..' + head.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
700
+ }
701
+ mkdirSync(reviewDir, { recursive: true })
702
+ writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
703
+ generated++
704
+ }
705
+
706
+ const unattributed = diffFiles.filter(f => !attributed.has(f))
707
+ return { generated, skipped, unattributed, executeRunId }
708
+ }
709
+
551
710
  /**
552
711
  * 获取当前(或最新)execute run id
553
712
  * 从 runtime 目录下查找 execute-runs/ 子目录
@@ -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
@@ -289,9 +290,20 @@ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailur
289
290
  exemptedCount: exempted.length,
290
291
  }
291
292
  }
292
- const detail = remaining.length > 0
293
- ? `${remaining.length} 个失败行未命中 known_failures`
294
- : '失败输出未检测到可豁免的失败行(保守判 fail)'
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
+ }
295
307
  return {
296
308
  status: 'failed',
297
309
  reason: baseReason ? `${baseReason}(${detail})` : detail,
@@ -373,6 +385,24 @@ function runGitDiffNameOnly(cwd, refSpec) {
373
385
  }
374
386
  }
375
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
+
376
406
  /**
377
407
  * 取主工作区 git 变更文件列表(unstaged + staged,相对仓库根)。
378
408
  * `git diff --name-only HEAD` 同时覆盖已暂存与未暂存改动(相对 HEAD),
@@ -715,3 +745,118 @@ export function printVerifyParityCheck(result) {
715
745
  if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
716
746
  console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
717
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
+ }
@@ -18,7 +18,8 @@ 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, pathMatches } from './change-list.js';
21
+ import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
22
+ import { parseAllowedPaths } from './stages/plan-postcheck.js';
22
23
 
23
24
  const CHANGES_REL = '.sillyspec/changes';
24
25
 
@@ -190,7 +191,9 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
190
191
  result.errors.push(
191
192
  `文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${violations.join('\n ')}`
192
193
  );
193
- return result;
194
+ // checkOnly(assess)模式不短路:继续跑 Gate3,收集所有道供一次报全(坑 worktree-execute-apply-friction 坑4)。
195
+ // 真实 apply(checkOnly=false)仍短路,保安全。
196
+ if (!checkOnly) return result;
194
197
  }
195
198
  }
196
199
 
@@ -228,7 +231,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
228
231
  `execute 前 baseline: ${meta.baselineHash}\n` +
229
232
  `当前 baseline: ${currentHash}`
230
233
  );
231
- return result;
234
+ if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
232
235
  }
233
236
  }
234
237
 
@@ -243,7 +246,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
243
246
  result.errors.push(
244
247
  `主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
245
248
  );
246
- return result;
249
+ if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
247
250
  }
248
251
  }
249
252
 
@@ -518,19 +521,18 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
518
521
  // 先跑 --check-only 模式的 applyWorktree 获取变更文件列表
519
522
  const checkResult = applyWorktree(changeName, { cwd: projectRoot, checkOnly: true });
520
523
 
521
- if (checkResult.errors.length > 0) {
522
- return {
523
- decision: 'BLOCKED',
524
- changedFiles: checkResult.changedFiles,
525
- reasons: checkResult.errors,
526
- warnings: [],
527
- stats: { additions: 0, deletions: 0 }
528
- };
529
- }
524
+ // applyWorktree(checkOnly) 已收集所有道(Gate1/3 不短路)——其 errors 纳入 reasons,
525
+ // 继续跑 Gate2/4/6 一次报全(坑 worktree-execute-apply-friction 坑4:原此处提前 return 致逐道挤牙膏)。
526
+ reasons.push(...checkResult.errors);
527
+ if (checkResult.warnings?.length) warnings.push(...checkResult.warnings);
530
528
 
531
529
  const changedFiles = checkResult.changedFiles;
532
530
 
533
531
  if (changedFiles.length === 0) {
532
+ // 无变更:若仍有 Gate 错误(如主区 dirty)则 BLOCKED,否则 SAFE
533
+ if (reasons.length > 0) {
534
+ return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 } };
535
+ }
534
536
  return {
535
537
  decision: 'SAFE',
536
538
  changedFiles: [],
@@ -540,36 +542,39 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
540
542
  };
541
543
  }
542
544
 
543
- // 解析 TaskCard allowed_paths
545
+ // 解析 TaskCard allowed_paths(复用 plan-postcheck.parseAllowedPaths,消除内联重复实现漂移)
544
546
  const wm = new WorktreeManager({ cwd: projectRoot });
545
547
  const meta = wm.getMeta(changeName);
546
548
  const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
547
549
  const allowedPaths = new Set();
548
550
  if (existsSync(tasksDir)) {
549
551
  for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
550
- const content = readFileSync(join(tasksDir, tf), 'utf8');
551
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
552
- if (!fmMatch) continue;
553
- const fm = fmMatch[1];
554
- const inline = fm.match(/allowed_paths:\s*\[([^\]]*)\]/);
555
- if (inline) {
556
- inline[1].split(',').forEach(s => { const v = s.trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
557
- }
558
- const block = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/);
559
- if (block) {
560
- block[1].match(/-\s+(.+)/g)?.forEach(s => { const v = s.replace(/^-\s+/, '').trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
552
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, tf), 'utf8'))) {
553
+ if (p) allowedPaths.add(p);
561
554
  }
562
555
  }
563
556
  }
564
557
 
565
- // 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时)
558
+ // design §6 标记为「顺带修复」的文件(坑 worktree-execute-apply-friction 坑1):合规修预存债,
559
+ // 不属任何 task 边界,assess 豁免 allowed_paths 严格校验(降级 warning),避免被迫 cherry-pick 绕过。
560
+ const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
561
+ const incidentalSet = new Set(
562
+ parseFileChangeListDetailed(designPath).filter(e => e.incidental).map(e => e.path)
563
+ );
564
+
565
+ // 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时);顺带修复文件豁免。
566
+ // 匹配换 pathMatches(与 Gate1/plan-postcheck 同语义容差),消除原字面前缀弱匹配漂移。
566
567
  if (allowedPaths.size > 0) {
567
- const outsidePaths = changedFiles.filter(f => !
568
- [...allowedPaths].some(allowed => f === allowed || f.startsWith(allowed.replace(/\*$/, '')))
569
- );
568
+ const isIncidental = f => [...incidentalSet].some(ap => pathMatches(f, ap));
569
+ const outsideAll = changedFiles.filter(f => ![...allowedPaths].some(allowed => pathMatches(f, allowed)));
570
+ const outsidePaths = outsideAll.filter(f => !isIncidental(f));
571
+ const exempted = outsideAll.filter(f => isIncidental(f));
570
572
  if (outsidePaths.length > 0) {
571
573
  reasons.push(`变更文件超出 allowed_paths:\n ${outsidePaths.join('\n ')}`);
572
574
  }
575
+ if (exempted.length > 0) {
576
+ warnings.push(`顺带修复文件(已豁免 allowed_paths,来源 design §6 标记):${exempted.join(', ')}`);
577
+ }
573
578
  }
574
579
 
575
580
  // 检查 4+5: 高风险文件模式
@@ -1,5 +1,5 @@
1
1
  ### 自动探针(必须先执行)
2
- 在检查前,依次运行以下三个探针,将结果作为验证输入:
2
+ 在检查前,依次运行以下六个探针,将结果作为验证输入:
3
3
 
4
4
  **探针 1:未实现标记扫描(仅变更文件)**
5
5
  只扫描本次变更涉及的文件,不要全项目扫描——历史 TODO 与本次变更无关,徒增噪音与 token。变更文件 = design.md「文件变更清单」列出的源码文件(你已在「加载规范」步骤读取 design.md,glob 路径需展开为具体文件):
@@ -18,7 +18,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
18
18
 
19
19
  **探针 3:验收标准测试覆盖探针**
20
20
  1. 读取变更目录下的 tasks.md,提取所有 checkbox 任务
21
- 2. 对每个 task,检查对应模块目录下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)
21
+ 2. 对每个 task,**递归**检查对应模块目录及其**所有子目录**下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)——包括模块内 co-located 的 tests/ 目录(如 `backend/app/modules/*/tests/`),**不要只看顶层 `tests/`**,否则会把测试就在模块内部的项目误判为“无测试”
22
22
  3. 没有测试文件的 task 标记为 ⚠️ 缺少测试
23
23
 
24
24
  **探针 4:决策追踪覆盖探针(如存在 decisions.md)**
@@ -51,3 +51,14 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
51
51
  | ❌ missing | GET /api/ppm/project-plan/{param}/plan-nodes | — | frontend/src/lib/ppm/plan.ts |
52
52
 
53
53
  如果发现 Missing backend endpoint,必须在验证报告中标记为 ❌ contract gap。
54
+
55
+ **探针 6:代码删除对账(切斯特顿栅栏护栏)**
56
+ 静默删除代码是 verify 的盲区——agent 删一段它看不懂的旧代码,只要路径合规、不碰风险关键词,其他探针都不会响。用 git 事实客观对账,不要凭记忆:
57
+ 1. 运行 `git diff --name-status HEAD`(本次变更已 apply 到主仓、未 commit,删除的文件在工作树消失但仍在 HEAD,显示 `D`)
58
+ 2. 筛出状态以 `D` 开头的行(删除);`R`/`C` 开头的旧路径等价删除
59
+ 3. 对每个删除文件,核对 design.md「文件变更清单」声明的操作:
60
+ - design 声明「删除」→ ✅ 合规(预期删除)
61
+ - design 声明「新增/修改」却整文件删除 → ❌ 高风险(声明与事实矛盾)
62
+ - design 清单未列出 → ⚠️ 未声明删除
63
+ 4. 排除 `.sillyspec/` 与 `meta.json`(文档 churn 不算删除信号)
64
+ 5. verify --done 时 CLI 会用同一 git 事实独立复核,谎报无效——务必如实记录