sillyspec 3.28.9 → 3.28.10
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/full.json +1 -0
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/config-schema.js +30 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/design-facts.js +56 -17
- package/src/index.js +18 -1
- package/src/knowledge-classify.js +20 -1
- package/src/probe7-anchor-check.js +69 -0
- package/src/quicklog.js +9 -4
- package/src/run/command.js +25 -2
- package/src/run/complete.js +70 -8
- package/src/run/gate-snapshot.js +110 -2
- package/src/run/gates.js +91 -7
- package/src/run/prompt.js +87 -6
- package/src/run/shared.js +71 -1
- package/src/scope-audit.js +101 -31
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/knowledge.js +24 -3
- package/src/stages/plan-postcheck.js +32 -5
- package/src/sync-noise.js +57 -1
- package/src/sync.js +8 -4
- package/src/task-review.js +75 -26
- package/src/verify-postcheck.js +192 -55
- package/src/verify-probes.js +390 -15
- package/src/worktree-apply.js +148 -5
- package/src/worktree-deps.js +24 -1
- package/src/worktree.js +274 -10
|
@@ -316,9 +316,28 @@ export async function cmdKnowledgeClassify(dir, args, opts = {}) {
|
|
|
316
316
|
const base = opts.specDir || join(dir, '.sillyspec')
|
|
317
317
|
const knowledgeDir = join(base, 'knowledge')
|
|
318
318
|
|
|
319
|
+
// flag-as-value 防护(坑 knowledge-flag-as-value,2026-09-15 实证):朴素 indexOf 取值会把
|
|
320
|
+
// 紧随的 flag 当本参数值(--keywords --file known-issues.md → 关键词写成字面量 '--file'
|
|
321
|
+
// 落进 INDEX 路由行,a49e7a5 批量迁移产生 9 条垃圾路由且 validate 放行)。值以 '--' 开头
|
|
322
|
+
// 一律视为漏传,由下方统一报错拦截——不静默降级成标题分词(会掩盖调用方参数错误)。
|
|
319
323
|
const pick = (name) => {
|
|
320
324
|
const i = args.indexOf(name)
|
|
321
|
-
|
|
325
|
+
const v = i >= 0 ? args[i + 1] : ''
|
|
326
|
+
return v && !v.startsWith('--') ? v : ''
|
|
327
|
+
}
|
|
328
|
+
const flagAsValue = ['--ql', '--title', '--file', '--section', '--keywords']
|
|
329
|
+
.find(name => {
|
|
330
|
+
const i = args.indexOf(name)
|
|
331
|
+
return i >= 0 && args[i + 1] !== undefined && String(args[i + 1]).startsWith('--')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
if (flagAsValue) {
|
|
335
|
+
output(false, {}, {
|
|
336
|
+
code: 'flag_as_value',
|
|
337
|
+
flag: flagAsValue,
|
|
338
|
+
message: `${flagAsValue} 的值缺失(后随 token 是 flag,不能作值)——请补参数值后重跑`,
|
|
339
|
+
})
|
|
340
|
+
return
|
|
322
341
|
}
|
|
323
342
|
const qlId = pick('--ql')
|
|
324
343
|
const targetFile = pick('--file')
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probe7-anchor-check.js — verify-result.md 探针7 矩阵 covered 行证据锚点校验(零环新模块)。
|
|
3
|
+
*
|
|
4
|
+
* 坑 probe7-covered-anchor-missing(2026-09-15 复盘实证:探针7 矩阵第一轮 7 行 covered 判定的
|
|
5
|
+
* 证据列缺 file:line 锚点被审查打回——预填说明写着「证据列给首命中 file:line 锚点」但模板要求
|
|
6
|
+
* 没细读就交,人工往返一轮)。本模块把该口径机器化:verify --done 时点解析正文探针7 段,
|
|
7
|
+
* 判定列=covered 的行必须含 `path:line` 形态锚点,缺则 advisory 提示回补。
|
|
8
|
+
*
|
|
9
|
+
* 定位与边界:
|
|
10
|
+
* - **advisory 不阻断**:covered 行缺锚点只 warn(证据可以是合法的人工核验形态,但 covered=
|
|
11
|
+
* 有归属测试命中,锚点理应存在——缺锚点大概率是 agent 改写预填时丢了,回补成本低)。
|
|
12
|
+
* - 只查 covered:partial/uncovered/non-testable 的证据形态多样(人工核验提示/理由),不做
|
|
13
|
+
* 锚点要求(误报面大于收益)。
|
|
14
|
+
* - 独立模块而非长在 verify-probes.js:后者是多会话高频冲突面(探针3/5 家族改动多发),
|
|
15
|
+
* 本校验只消费 verify-result.md 文本,零依赖单文件最稳。
|
|
16
|
+
* - 表格列序与 renderProbe7Lines 骨架同源锚定:| acceptance | 归属测试 | 关键词命中 | 判定 | 证据 |
|
|
17
|
+
* → split('|') 后 cells[4]=判定、cells[5]=证据(首尾空串偏移)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// file:line 锚点:冒号后跟数字(`test/x.test.mjs:42`、`src/a.py:12-18` 均命中)
|
|
21
|
+
const ANCHOR_RE = /:\d+\b/
|
|
22
|
+
|
|
23
|
+
// 判定枚举纯值(骨架四枚举;容忍反引号包裹——门禁层 extractAcceptanceMatrixSlots 另有纯值校验)
|
|
24
|
+
function normalizeVerdict(cell) {
|
|
25
|
+
return String(cell || '').trim().replace(/^`|`$/g, '').toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 校验探针7 矩阵 covered 行的证据锚点。
|
|
30
|
+
* @param {string} reportText verify-result.md 全文
|
|
31
|
+
* @returns {{applicable:boolean, rowsChecked:number, coveredRows:number,
|
|
32
|
+
* missingAnchors:Array<{task:string, acceptance:string, evidence:string}>}}
|
|
33
|
+
* applicable=false:正文无「#### 探针 7」段(quick/存量报告)——调用方零输出。
|
|
34
|
+
*/
|
|
35
|
+
export function checkProbe7AnchorCoverage(reportText) {
|
|
36
|
+
const out = { applicable: false, rowsChecked: 0, coveredRows: 0, missingAnchors: [] }
|
|
37
|
+
if (!reportText) return out
|
|
38
|
+
const lines = String(reportText).replace(/\r\n/g, '\n').split('\n')
|
|
39
|
+
const start = lines.findIndex(l => /^####\s*探针\s*7/.test(l))
|
|
40
|
+
if (start === -1) return out
|
|
41
|
+
let end = lines.length
|
|
42
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
43
|
+
if (/^####\s/.test(lines[i])) { end = i; break }
|
|
44
|
+
}
|
|
45
|
+
out.applicable = true
|
|
46
|
+
let task = null
|
|
47
|
+
for (let i = start + 1; i < end; i++) {
|
|
48
|
+
const line = lines[i]
|
|
49
|
+
const tm = line.match(/^\*\*(.+?)\*\*\s*$/)
|
|
50
|
+
if (tm) { task = tm[1].trim(); continue }
|
|
51
|
+
if (!line.startsWith('|')) continue
|
|
52
|
+
const cells = line.split('|').map(c => c.trim())
|
|
53
|
+
if (cells.length < 6) continue // 表头/分隔行之外的畸形行不参与(表头 6 列含首尾空串)
|
|
54
|
+
const verdict = normalizeVerdict(cells[4])
|
|
55
|
+
if (!['covered', 'partial', 'uncovered', 'non-testable'].includes(verdict)) continue
|
|
56
|
+
out.rowsChecked++
|
|
57
|
+
if (verdict !== 'covered') continue
|
|
58
|
+
out.coveredRows++
|
|
59
|
+
const evidence = cells[5] || ''
|
|
60
|
+
if (!ANCHOR_RE.test(evidence)) {
|
|
61
|
+
out.missingAnchors.push({
|
|
62
|
+
task: task || '(未知 task)',
|
|
63
|
+
acceptance: (cells[1] || '').slice(0, 80),
|
|
64
|
+
evidence: evidence.slice(0, 80),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
package/src/quicklog.js
CHANGED
|
@@ -1081,10 +1081,15 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
|
|
|
1081
1081
|
let quicklogFile = null
|
|
1082
1082
|
let flipped = false
|
|
1083
1083
|
await withFileLock(lockPath, async () => {
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1084
|
+
// 坑 quick-cancel-blind-after-quicklog-rotation(2026-09-16 实证):主文件存在时旧逻辑
|
|
1085
|
+
// 只扫主文件——轮转发生后历史条目在归档文件(QUICKLOG-<user>-<日期>.md)里永久不可达,
|
|
1086
|
+
// 「提示清理 → 清理必失败」死循环。恒扫全部 QUICKLOG 文件(主文件优先,命中即停),
|
|
1087
|
+
// 用户文件缺失的 CI user 漂移场景语义不变。
|
|
1088
|
+
const allFiles = listQuicklogFiles(quicklogDir)
|
|
1089
|
+
const mainFile = `QUICKLOG-${user}.md`
|
|
1090
|
+
const files = existsSync(join(quicklogDir, mainFile))
|
|
1091
|
+
? [mainFile, ...allFiles.filter(f => f !== mainFile)]
|
|
1092
|
+
: allFiles
|
|
1088
1093
|
for (const f of files) {
|
|
1089
1094
|
const p = join(quicklogDir, f)
|
|
1090
1095
|
const content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
package/src/run/command.js
CHANGED
|
@@ -330,6 +330,10 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
330
330
|
process.exit(2)
|
|
331
331
|
}
|
|
332
332
|
const fromStepValue = getFlagValue('--from-step')
|
|
333
|
+
// 意图断言(坑 execute-concurrent-done-skips-next-wave 终解,2026-09-08 实证):--done --step
|
|
334
|
+
// <名|序号> 显式声明本次要完成的步骤——CLI 校验与当前待完成步一致才放行。并发 --done 落到
|
|
335
|
+
// 错误 Wave(后到会话用旧摘要完成未实现的下一步)只有显式声明能硬拦(CLI 短进程无法读心)。
|
|
336
|
+
const stepAssertValue = getFlagValue('--step')
|
|
333
337
|
const isConfirm = flags.includes('--confirm')
|
|
334
338
|
const isSkipApproval = flags.includes('--skip-approval')
|
|
335
339
|
const isWait = flags.includes('--wait')
|
|
@@ -1381,7 +1385,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
1381
1385
|
} catch { /* guard 损坏 → 下面按找不到报错 */ }
|
|
1382
1386
|
}
|
|
1383
1387
|
if (!qlId) {
|
|
1384
|
-
|
|
1388
|
+
// 坑 quick-cancel-blind-after-quicklog-rotation ②③(2026-09-16 实证):空壳会话
|
|
1389
|
+
//(启动即被拒/未写任何条目)无 ql-ID 可传,「显式传 --ql」指引逻辑上不可执行。
|
|
1390
|
+
// 两层出口:① guard 缺失但进度库会话行零步骤完成 → 空壳直清(进度注销+目录清理即
|
|
1391
|
+
// 成功,条目定位对无条目会话本无意义);② 仍有疑虑时 --force 跳过条目清理强清。
|
|
1392
|
+
const forceCancel = flags.includes('--force')
|
|
1393
|
+
let emptyShell = false
|
|
1394
|
+
try {
|
|
1395
|
+
const sessProgress = pm.read(cwd, cancelSessionRaw)
|
|
1396
|
+
const qSteps = sessProgress?.stages?.quick?.steps
|
|
1397
|
+
const doneCount = Array.isArray(qSteps) ? qSteps.filter(s => s?.status === 'completed' || s?.status === 'skipped').length : 0
|
|
1398
|
+
emptyShell = doneCount === 0
|
|
1399
|
+
} catch { /* 读不到进度 → 按非空壳走原报错 */ }
|
|
1400
|
+
if (forceCancel || emptyShell) {
|
|
1401
|
+
try { pm.unregisterChange(cwd, cancelSessionRaw) } catch { /* 无行可注销不算失败 */ }
|
|
1402
|
+
try { rmSync(join(resolveQuickSessionsDir(platformOpts, specBase), cancelSessionRaw), { recursive: true, force: true }) } catch { /* 目录不存在即无事 */ }
|
|
1403
|
+
console.log(`🗑️ 已${emptyShell ? '清空壳会话(零步骤完成,无 QUICKLOG 条目可清)' : '强制取消(--force,跳过条目清理)'}: ${cancelSessionRaw}`)
|
|
1404
|
+
console.log(' 进度行注销 + 会话目录清理完成')
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
console.error(`❌ 无法定位会话 ${cancelSessionRaw} 的 qlId(guard.json 缺失/损坏)——显式传 --ql <ql-xxx>(QUICKLOG 条目头);确认该会话确无条目可清时可用 --force 跳过条目清理`)
|
|
1385
1408
|
process.exit(1)
|
|
1386
1409
|
}
|
|
1387
1410
|
let gitUser = 'unknown'
|
|
@@ -1526,7 +1549,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
1526
1549
|
const doneAnswer = getFlagValue('--answer')
|
|
1527
1550
|
// isSkipApply/sessionFlag(task-03):--skip-apply 归档收口跳过 + --session 所有权会话标识,
|
|
1528
1551
|
// 随 completeStep options 透传(isNoDocs 同款链路)——消费点 archive/quick 收尾 handler。
|
|
1529
|
-
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, doneAnswer, isForceBaseline, isAllowNew, isAllowDelete, isNoDocs, isSkipApply, sessionFlag: getFlagValue('--session'), quickFiles })
|
|
1552
|
+
return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, doneAnswer, isForceBaseline, isAllowNew, isAllowDelete, isNoDocs, isSkipApply, sessionFlag: getFlagValue('--session'), quickFiles, stepAssert: stepAssertValue })
|
|
1530
1553
|
}
|
|
1531
1554
|
|
|
1532
1555
|
// 默认:输出当前步骤
|
package/src/run/complete.js
CHANGED
|
@@ -22,8 +22,8 @@ import { createHash } from 'node:crypto'
|
|
|
22
22
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
23
23
|
import { gitQuiet } from '../git-helper.js'
|
|
24
24
|
import { withFileLock } from '../quicklog.js'
|
|
25
|
-
import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir } from './shared.js'
|
|
26
|
-
import { isExplicitReviewWrite } from '../task-review.js'
|
|
25
|
+
import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer } from './shared.js'
|
|
26
|
+
import { isExplicitReviewWrite, collectWorktreeChangedFiles } from '../task-review.js'
|
|
27
27
|
|
|
28
28
|
// auto 模式 brainstorm 步骤表感知解析(2026-09-08 E2E 实证 bug:--done/--wait 推进主模式 8 步表,
|
|
29
29
|
// 与 runAutoMode getAutoSteps 的 4 步 auto 表双轨互踩——ensureAutoStage 判非 auto 表重种清零,
|
|
@@ -181,6 +181,29 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
181
181
|
console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// ── 意图断言(坑 execute-concurrent-done-skips-next-wave 终解,2026-09-08 实证)──
|
|
185
|
+
// --step <名|序号> 显式声明本次要完成的步骤;与当前待完成步不一致 → fail-closed 拒绝。
|
|
186
|
+
// 防并发 --done 主形态:会话 B 先推进 Wave N,会话 A 随后的 --done 落到 Wave N+1 且用
|
|
187
|
+
// Wave N 的旧摘要静默完成未实现步骤——CLI 短进程无法读心,只有显式声明能硬拦。
|
|
188
|
+
// 序号 1-based;名称精确或前缀匹配(Wave 名常带「Wave 3 — xxx」长尾,前缀够用且防呆)。
|
|
189
|
+
if (options.stepAssert) {
|
|
190
|
+
const declared = String(options.stepAssert).trim()
|
|
191
|
+
const currentName = steps[currentIdx]?.name || ''
|
|
192
|
+
let match = false
|
|
193
|
+
if (/^\d+$/.test(declared)) {
|
|
194
|
+
match = currentIdx === parseInt(declared, 10) - 1
|
|
195
|
+
} else {
|
|
196
|
+
match = currentName === declared || currentName.startsWith(declared)
|
|
197
|
+
}
|
|
198
|
+
if (!match) {
|
|
199
|
+
console.error(`❌ --step 意图断言不匹配:声明完成「${declared}」,当前待完成步骤是「${currentName}」(第 ${currentIdx + 1}/${steps.length} 步)。`)
|
|
200
|
+
console.error(` 不一致的最常见原因:并行会话刚推进了本变更的步骤(你读的是旧进度)。`)
|
|
201
|
+
console.error(` 核对:sillyspec progress show${changeName ? ` --change ${changeName}` : ''};确认要做的是当前步则去掉 --step 重跑,确属误判自己想做的步则修正 --step 值。`)
|
|
202
|
+
process.exit(1)
|
|
203
|
+
}
|
|
204
|
+
console.log(`🛡️ 意图断言通过:--step「${declared}」= 当前步骤「${currentName}」`)
|
|
205
|
+
}
|
|
206
|
+
|
|
184
207
|
// ── P0-2(noai-ir-roadmap §3):--output 省略 → CLI 按事实合成步骤摘要 ──
|
|
185
208
|
// agent 手打摘要是每步命中的幻觉源;事实面(步骤名 + 变更窗口 + 门禁以 CLI 校验为准)
|
|
186
209
|
// CLI 本就持有,纯事实合成零编造(判断词留给 gate)。语义性说明(方案取舍/用户反馈)仍应
|
|
@@ -666,6 +689,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
666
689
|
const total = steps.length
|
|
667
690
|
console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
|
|
668
691
|
|
|
692
|
+
// 平台模式产物落点指针(platform-docs-dual-location,2026-09-15 EHS 实证):完成时点在
|
|
693
|
+
// 工作树 .sillyspec/ 刷新人类可读指针(本地模式零行为,fail-soft 内部全兜)。
|
|
694
|
+
writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
|
|
695
|
+
|
|
669
696
|
// task-04(2026-09-10-change-scope-audit):execute 完成打范围对账全表+落 .runtime 快照 /
|
|
670
697
|
// verify 完成出一行漂移确认。advisory fail-soft(D-006)——helper 内部全兜,绝不阻断完成。
|
|
671
698
|
await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
|
|
@@ -931,7 +958,11 @@ function readTaskCardText(changeDir, taskNum) {
|
|
|
931
958
|
* 用于 autoCheckPlanFromReviews(勾选层)和 detectExecuteBatchFinish(批量层)。
|
|
932
959
|
* 失败返回 null(调用方降级:勾选层不勾、批量层不过滤——向后兼容)。
|
|
933
960
|
*
|
|
934
|
-
* @
|
|
961
|
+
* 2026-09-15-worktree-dual-truth-gates task-04(坑④/D-004@v1):ctx 增透传 cwd/changeName/meta/
|
|
962
|
+
* baselineFiles——prefetchDiffFileSet 消费 collectWorktreeChangedFiles(与草稿归因同口径)并剔
|
|
963
|
+
* baseline 夹带。扩展字段对 shouldAutoCheckTask 只读 gitDir/base/head/diffFileSet 零影响。
|
|
964
|
+
*
|
|
965
|
+
* @returns {Promise<{gitDir:string, base:string, head:string, cwd:string, changeName:string, meta:object|null, baselineFiles:string[]}|null>}
|
|
935
966
|
*/
|
|
936
967
|
async function buildDraftContext(cwd, changeName) {
|
|
937
968
|
try {
|
|
@@ -952,7 +983,13 @@ async function buildDraftContext(cwd, changeName) {
|
|
|
952
983
|
}
|
|
953
984
|
}
|
|
954
985
|
if (base && head && gitDir) {
|
|
955
|
-
return {
|
|
986
|
+
return {
|
|
987
|
+
gitDir, base, head,
|
|
988
|
+
cwd, changeName,
|
|
989
|
+
meta: meta || null,
|
|
990
|
+
// baselineFiles 缺失/非数组按 [] 容错(prefetchDiffFileSet 剔除夹带用)
|
|
991
|
+
baselineFiles: Array.isArray(meta?.baselineFiles) ? meta.baselineFiles : [],
|
|
992
|
+
}
|
|
956
993
|
}
|
|
957
994
|
return null
|
|
958
995
|
} catch (e) {
|
|
@@ -962,16 +999,39 @@ async function buildDraftContext(cwd, changeName) {
|
|
|
962
999
|
}
|
|
963
1000
|
|
|
964
1001
|
/**
|
|
965
|
-
*
|
|
1002
|
+
* 预取草稿零 diff 守卫用的全量 diff 文件集,供 shouldAutoCheckTask 按 task 内存归属判定。
|
|
966
1003
|
* 此前每草稿 task 一次 `git diff --name-only base..head -- <files>`(同一对 base..head 查 N 次,
|
|
967
|
-
* 8 task ≈ 8-16 次串行 spawn)。失败返回
|
|
1004
|
+
* 8 task ≈ 8-16 次串行 spawn)。失败返回 ctx 原样 → 调用方回退逐 task 实测路径。
|
|
1005
|
+
*
|
|
1006
|
+
* 2026-09-15-worktree-dual-truth-gates task-04(坑④ worktree-tick-guard-commit-only / D-004@v1):
|
|
1007
|
+
* 单算 base..head(已提交)在子代理默认不 commit 时恒空/缺文件 → 守卫全跳过勾选。现并入
|
|
1008
|
+
* collectWorktreeChangedFiles(task-review.js 单一真相:porcelain 未提交 ∪ committed
|
|
1009
|
+
* merge-base 补齐,与草稿归因同口径),再剔 meta.baselineFiles(R-07 防伪底线:merge-base..
|
|
1010
|
+
* wtHEAD 含 baseline checkpoint 提交,夹带文件=非本变更改动,不剔则「声明未做恰被夹带」的
|
|
1011
|
+
* task 误勾)。全空时仍不设 diffFileSet(退回逐 task 实测路径,原语义)。
|
|
968
1012
|
*/
|
|
969
1013
|
function prefetchDiffFileSet(ctx) {
|
|
970
1014
|
if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
|
|
971
1015
|
try {
|
|
972
1016
|
const out = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`], { trim: true })
|
|
973
|
-
|
|
974
|
-
|
|
1017
|
+
const diffFileSet = new Set(out ? out.split('\n').filter(Boolean) : [])
|
|
1018
|
+
// D-004@v1 并入:porcelain 未提交 ∪ committed 补齐(helper 自身 fail-open,git 失败返 []
|
|
1019
|
+
// → 退回 base..head 现状,不放大勾选面);独立 try 防 helper 意外拖垮既有 base..head 集
|
|
1020
|
+
try {
|
|
1021
|
+
if (ctx.cwd && ctx.changeName) {
|
|
1022
|
+
for (const f of collectWorktreeChangedFiles(ctx.cwd, ctx.changeName, ctx.meta)) {
|
|
1023
|
+
if (f) diffFileSet.add(f)
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} catch { /* helper 异常 → 退回 base..head 现状(fail-open) */ }
|
|
1027
|
+
// 剔 baseline 夹带:按正斜杠归一 exact 匹配(两侧同 git 口径路径);baselineFiles 缺失按 [] 容错
|
|
1028
|
+
const baselineFiles = Array.isArray(ctx.baselineFiles) ? ctx.baselineFiles : []
|
|
1029
|
+
for (const b of baselineFiles) {
|
|
1030
|
+
const n = String(b).replace(/\\/g, '/').trim()
|
|
1031
|
+
if (n) diffFileSet.delete(n)
|
|
1032
|
+
}
|
|
1033
|
+
if (diffFileSet.size === 0) return ctx
|
|
1034
|
+
return { ...ctx, diffFileSet }
|
|
975
1035
|
} catch {
|
|
976
1036
|
return ctx
|
|
977
1037
|
}
|
|
@@ -1546,6 +1606,8 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
|
|
|
1546
1606
|
// gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
|
|
1547
1607
|
pm._write(cwd, progress, changeName)
|
|
1548
1608
|
console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
|
|
1609
|
+
// 平台模式产物落点指针:与 completeStep 完成分支同款(本路径不漏)。
|
|
1610
|
+
writePlatformDocsPointer(cwd, changeName, platformOpts, stageName)
|
|
1549
1611
|
// task-04:wait 解除完成路径与 completeStep 完成分支同源注入(Grill G-2:本路径不漏,
|
|
1550
1612
|
// 否则 execute 快照缺失 → verify 假报「无快照可对比」)。
|
|
1551
1613
|
await printStageCompletionScopeAudit({ stageName, cwd, changeName, specBase, platformOpts })
|
package/src/run/gate-snapshot.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留由 git worktree prune /
|
|
15
15
|
* 临时目录自然回收(OS tmp 清理),不进主仓 .runtime。
|
|
16
16
|
*/
|
|
17
|
-
import { execFileSync } from 'node:child_process'
|
|
17
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
18
18
|
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
|
|
19
19
|
import { join, dirname, relative, resolve } from 'node:path'
|
|
20
20
|
import { tmpdir } from 'node:os'
|
|
@@ -23,12 +23,100 @@ function git(cwd, args) {
|
|
|
23
23
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 }).trim()
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* overlay import 冒烟多轮收敛(坑 verify-sandbox-overlay-partial-state-importerror):
|
|
28
|
+
* 单轮回退有两个盲点——①坏的是依赖文件(缺名/缺模块),报错的是 importer,回退 importer
|
|
29
|
+
* 不解决问题;②一次回退后依赖关系变化。策略:最多 3 轮,每轮对未回退的 overlay .py 冒烟;
|
|
30
|
+
* 失败时解析报错点名的模块名 → 若能映射到**另一个 overlay .py 文件**则回退那个依赖文件
|
|
31
|
+
*(事故形态:crud.py 半成品缺名,importer 报错点名 crud → 回退 crud 的 HEAD 健版),
|
|
32
|
+
* 否则回退报错文件自身(语法坏/自身缺依赖);HEAD 无版的新文件回退不了 → warn 保留。
|
|
33
|
+
*/
|
|
34
|
+
function runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidFiles) {
|
|
35
|
+
const reverted = new Set()
|
|
36
|
+
const byModule = new Map() // 末段模块名 → 文件(映射报错点名)
|
|
37
|
+
for (const f of overlaidFiles) byModule.set(f.split('/').pop().replace(/\.py$/, ''), f)
|
|
38
|
+
const revertToHead = (f, why) => {
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(join(snapshotRoot, f), git(snapshotRoot, ['show', `HEAD:${f}`]))
|
|
41
|
+
reverted.add(f)
|
|
42
|
+
console.warn(`⚠️ 快照 overlay 冒烟:${f} ${why}——已回退 HEAD 版(并行会话半成品部分态不进沙箱)`)
|
|
43
|
+
return true
|
|
44
|
+
} catch {
|
|
45
|
+
console.warn(`⚠️ 快照 overlay 冒烟:${f} ${why}且 HEAD 无该文件(新增半成品)——保留原样,模块子集实测若红先查此文件`)
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (let round = 0; round < 3; round++) {
|
|
50
|
+
let acted = false
|
|
51
|
+
for (const f of overlaidFiles) {
|
|
52
|
+
if (reverted.has(f)) continue
|
|
53
|
+
const bad = smokeImportPython(pythonBin, snapshotRoot, f)
|
|
54
|
+
if (!bad) continue
|
|
55
|
+
acted = true
|
|
56
|
+
// 依赖归因:报错点名另一 overlay 文件 → 回退依赖( importer 自身往往无恙)
|
|
57
|
+
const named = bad.match(/(?:No module named|from)\s+['"]?([A-Za-z_][\w.]*)/i)
|
|
58
|
+
const depFile = named ? byModule.get(named[1].split('.').pop()) : null
|
|
59
|
+
if (depFile && depFile !== f && !reverted.has(depFile)) {
|
|
60
|
+
if (revertToHead(depFile, `被 ${f} 的 import 报错点名(${bad.slice(0, 60)})`)) continue
|
|
61
|
+
}
|
|
62
|
+
revertToHead(f, `import 失败(${bad.slice(0, 60)})`)
|
|
63
|
+
}
|
|
64
|
+
if (!acted) return
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 快照内寻找 python 解释器(overlay 冒烟用):环境目录发现集里的 venv 族
|
|
70
|
+
*(.venv/venv/env)在快照内已 junction——按平台拼 Scripts/bin 下的 python 路径。
|
|
71
|
+
* @returns {string|null} 解释器绝对路径;找不到返回 null
|
|
72
|
+
*/
|
|
73
|
+
function findSnapshotPython(envRelDirs, snapshotRoot) {
|
|
74
|
+
for (const rel of envRelDirs) {
|
|
75
|
+
if (!/(^|\/)(\.?venv|env)(\/|$)/.test(rel)) continue
|
|
76
|
+
const bin = process.platform === 'win32'
|
|
77
|
+
? join(snapshotRoot, rel, 'Scripts', 'python.exe')
|
|
78
|
+
: join(snapshotRoot, rel, 'bin', 'python')
|
|
79
|
+
if (existsSync(bin)) return bin
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 单文件 import 冒烟:`python -c "import <mod>"`,cwd 取该文件所在包根(模块路径按
|
|
86
|
+
* 目录结构推导:剥 .py、/→.、丢 __init__;相对包根 import)。非 0 退出且 stderr 命中
|
|
87
|
+
* ImportError/ModuleNotFoundError/SyntaxError 才判坏(其余失败如缺第三方依赖是环境问题,
|
|
88
|
+
* 不回退——回退语义只针对「部分态坏文件」)。超时 10s 防挂。
|
|
89
|
+
* @returns {string|null} 失败原因(判坏时);通过/环境性失败返回 null
|
|
90
|
+
*/
|
|
91
|
+
function smokeImportPython(pythonBin, snapshotRoot, relFile) {
|
|
92
|
+
const noExt = relFile.replace(/\.py$/, '')
|
|
93
|
+
const parts = noExt.split('/')
|
|
94
|
+
while (parts.length > 0 && parts[parts.length - 1] === '__init__') parts.pop()
|
|
95
|
+
if (parts.length === 0) return null
|
|
96
|
+
// 包根推导:从深到浅尝试(backend/app/x/y.py → 先 app.x.y(cwd=backend)再 x.y(cwd=backend/app))
|
|
97
|
+
for (let cut = 0; cut < parts.length; cut++) {
|
|
98
|
+
const mod = parts.slice(cut).join('.')
|
|
99
|
+
if (!mod || !/^[A-Za-z_][\w.]*$/.test(mod)) continue
|
|
100
|
+
const pkgRoot = join(snapshotRoot, ...parts.slice(0, cut))
|
|
101
|
+
const r = spawnSync(pythonBin, ['-c', `import ${mod}`], {
|
|
102
|
+
cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000,
|
|
103
|
+
})
|
|
104
|
+
if (r.status === 0) return null
|
|
105
|
+
const err = String((r.stderr || '') + (r.stdout || ''))
|
|
106
|
+
if (/ImportError|ModuleNotFoundError|SyntaxError|cannot import name/i.test(err)) {
|
|
107
|
+
return (err.match(/(?:ImportError|ModuleNotFoundError|SyntaxError|cannot import name)[^\n]*/) || ['import 失败'])[0].slice(0, 120)
|
|
108
|
+
}
|
|
109
|
+
// 环境性失败(如缺第三方依赖、非模块入口冲突)→ 不判坏,继续试更浅包根
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
26
114
|
/**
|
|
27
115
|
* 创建隔离快照:HEAD worktree + 会话文件 overlay + node_modules junction + local.yaml。
|
|
28
116
|
* @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
|
|
29
117
|
* @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
|
|
30
118
|
*/
|
|
31
|
-
export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
|
|
119
|
+
export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false }) {
|
|
32
120
|
let snapshotRoot = null
|
|
33
121
|
try {
|
|
34
122
|
// 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
|
|
@@ -112,6 +200,26 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null }) {
|
|
|
112
200
|
}
|
|
113
201
|
}
|
|
114
202
|
|
|
203
|
+
// ── overlay import 闭合冒烟(坑 verify-sandbox-overlay-partial-state-importerror,
|
|
204
|
+
// 2026-09-13 实证:in-place 变更的文件集含主仓未提交并行 WIP——「改了引用方没改被引用方」
|
|
205
|
+
// 的部分态进快照,模块子集测试 197 ERROR 只能人工三步排查)──
|
|
206
|
+
// 对 overlay 进快照的 .py 文件逐个 `python -c import`(用快照内链接的 venv 解释器);
|
|
207
|
+
// ImportError/SyntaxError → 该文件回退 HEAD 版(git show)+ warn——半成品坏文件不进沙箱,
|
|
208
|
+
// HEAD 健版保证 import 图闭合。新增文件(HEAD 无版)无法回退 → 醒目 warn 保留。
|
|
209
|
+
// 全链 fail-open:找不到解释器/基建异常只 warn 不作废快照(主场景是 Python 项目,
|
|
210
|
+
// JS/TS 的 vitest load 冒烟成本高留后续)。
|
|
211
|
+
const overlaidPyFiles = files.filter(f => typeof f === 'string' && f.endsWith('.py')
|
|
212
|
+
&& !f.startsWith('.sillyspec/')
|
|
213
|
+
&& existsSync(join(snapshotRoot, f)))
|
|
214
|
+
if (overlaidPyFiles.length > 0 && !skipImportSmoke) {
|
|
215
|
+
const pythonBin = findSnapshotPython(envRelDirs, snapshotRoot)
|
|
216
|
+
if (pythonBin) {
|
|
217
|
+
runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidPyFiles)
|
|
218
|
+
} else {
|
|
219
|
+
console.warn(`⚠️ 快照 overlay 冒烟跳过:快照内未找到 python 解释器(venv 族未链接)——并行半成品部分态可能引发 ImportError 假红,实测红先 SNAPSHOT_OFF 对照`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
115
223
|
const cleanup = () => {
|
|
116
224
|
try { git(cwd, ['worktree', 'remove', '--force', '--quiet', snapshotRoot]) } catch {
|
|
117
225
|
try { rmSync(snapshotRoot, { recursive: true, force: true }) } catch { /* 残留交 OS tmp 清理 */ }
|
package/src/run/gates.js
CHANGED
|
@@ -322,12 +322,14 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
|
|
|
322
322
|
// 下游已有物理目录存在性判定兜底(G2/R3),这里 warn 留下根因线索,避免静默误诊。
|
|
323
323
|
console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
|
|
324
324
|
}
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
325
|
+
const depsOk = (s) => ['linked', 'installed', 'n/a'].includes(s)
|
|
326
|
+
const changeDir = changeName ? join(specBase, 'changes', changeName) : null
|
|
327
|
+
|
|
328
|
+
// main 侧就绪后的跨仓 worktree deps 校验(坑 cross-repo-no-worktree-isolation):
|
|
329
|
+
// 跨仓仓供给失败同样无构建/测试能力(前端仓无 node_modules 建不了 build)。只校验已建
|
|
330
|
+
// worktree 的跨仓仓(legacy 直写模式无 meta,维持原语义,下游 Task Review 兜底)。
|
|
331
|
+
// 放行返回 true;跨仓未就绪置 blocked + exit(1)。
|
|
332
|
+
const crossCheckOrExit = async () => {
|
|
331
333
|
let crossFailed = []
|
|
332
334
|
try {
|
|
333
335
|
const crossSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
@@ -351,8 +353,39 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
|
|
|
351
353
|
if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
|
|
352
354
|
process.exit(1)
|
|
353
355
|
}
|
|
354
|
-
|
|
356
|
+
|
|
357
|
+
if (depsOk(meta?.depsStatus)) return await crossCheckOrExit()
|
|
358
|
+
|
|
359
|
+
// wave 级 opt-out(先于重供给:无需 deps 能力的 wave 不花 install 成本)
|
|
355
360
|
if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
|
|
361
|
+
|
|
362
|
+
// ── --done 重供给重试(坑 deps-gate-manual-retry-ineffective,2026-09-15 wp 会话实证)──
|
|
363
|
+
// 阻断提示承诺「在 worktree 内手动安装依赖后重试」,但 manual install 不改 meta.depsStatus,
|
|
364
|
+
// failed 态直读 meta 永不翻绿 → 重试 --done 必撞同一堵墙,唯一出路 doctor --fix(wp 会话
|
|
365
|
+
// 正是手动编译成功后重试仍被拦,被迫多跑一轮 doctor)。对齐 run execute 入口
|
|
366
|
+
// ensureDepsFreshness 的同款供给:阻断前重供给一次并写回 meta——PATH 已修好的 shell 里
|
|
367
|
+
// 重跑 --done 即等效 doctor --fix;marker 型生态(nodejs)手动安装补齐 node_modules 后
|
|
368
|
+
// 重供给的 install 也能自然通过。仍失败则维持阻断,不无限重试。
|
|
369
|
+
let depsStatus = meta?.depsStatus
|
|
370
|
+
if (wm && changeName) {
|
|
371
|
+
let wtPath = null
|
|
372
|
+
try { wtPath = wm.getWorktreePath(changeName) } catch { /* meta 缺失走物理目录判定 */ }
|
|
373
|
+
if (wtPath && existsSync(wtPath)) {
|
|
374
|
+
try {
|
|
375
|
+
const { provisionDeps } = await import('../worktree-deps.js')
|
|
376
|
+
console.log(`🔄 deps 门控:depsStatus=${depsStatus || 'unknown'},阻断前按 --done 重试路径重供给一次(与 doctor --fix 同款)...`)
|
|
377
|
+
const deps = provisionDeps(wtPath, cwd, { specBase }) || {}
|
|
378
|
+
meta = { ...meta, ...deps }
|
|
379
|
+
writeAtomicSync(join(wtPath, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
380
|
+
depsStatus = meta.depsStatus
|
|
381
|
+
console.log(` 重供给完成:depsStatus=${depsStatus || 'unknown'}${meta.depsError ? `(${meta.depsError})` : ''}`)
|
|
382
|
+
if (depsOk(depsStatus)) return await crossCheckOrExit()
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.warn(`⚠️ deps 门控重供给失败(维持阻断判定):${e.message}`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
356
389
|
if (steps && steps[currentIdx]) {
|
|
357
390
|
steps[currentIdx].status = 'blocked'
|
|
358
391
|
// 阻断原因持久化(坑 deps-gate-blocked-invisible):--status/诊断时可见,不依赖阻断当刻的
|
|
@@ -845,6 +878,11 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
845
878
|
})
|
|
846
879
|
const reconcileEnvelope = buildReconcileTargetFilesEnvelope(reconcileCheck)
|
|
847
880
|
const reconcileBlocked = printReconcileTargetFilesCheck(reconcileCheck, reconcileEnvelope)
|
|
881
|
+
// 跨仓 per-repo 对账明细渲染(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004
|
|
882
|
+
// 分期):reconcileTargetFiles 的 crossRepo 字段(advisory 不阻断)——各仓 对上/②缺/③多
|
|
883
|
+
// 逐仓一行 + ②缺明细(跨仓声明落空是该去对应仓干活的信号,但不翻主仓状态——锚点窗口
|
|
884
|
+
// 脆弱期会假信号)。无跨仓卡时 crossRepo 为空数组零输出。
|
|
885
|
+
printCrossRepoReconcile(reconcileCheck.crossRepo)
|
|
848
886
|
// 结果落盘(design Wave 2 承诺:对齐 .runtime/verify-runs/<ts>/ 先例)——五状态全落(含
|
|
849
887
|
// 放行态),fail-soft 不影响 gate 判定;②类阻断回执同样留档供平台/审计消费
|
|
850
888
|
writeReconcileRunResult({ runtimeRoot: reconcileRuntimeRoot, changeName, envelope: reconcileEnvelope, result: reconcileCheck })
|
|
@@ -872,6 +910,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
872
910
|
})
|
|
873
911
|
const probeEnvelope = buildProbeConsistencyEnvelope(probeCheck)
|
|
874
912
|
const probeBlocked = printProbeConsistencyCheck(probeCheck, probeEnvelope)
|
|
913
|
+
// 探针7 covered 证据锚点 advisory(坑 probe7-covered-anchor-missing,2026-09-15 复盘:7 行
|
|
914
|
+
// covered 证据缺 file:line 被审查打回——预填说明已载明口径,机器在 --done 时点再拦一道,
|
|
915
|
+
// 省一轮人工往返)。advisory 不阻断;独立模块零环(verify-probes 是多会话冲突面,不碰)。
|
|
916
|
+
try {
|
|
917
|
+
const { checkProbe7AnchorCoverage } = await import('../probe7-anchor-check.js')
|
|
918
|
+
const p7ReportPath = join(specBase, 'changes', changeName, 'verify-result.md')
|
|
919
|
+
if (existsSync(p7ReportPath)) {
|
|
920
|
+
const p7Anchor = checkProbe7AnchorCoverage(readFileSync(p7ReportPath, 'utf8'))
|
|
921
|
+
if (p7Anchor.applicable && p7Anchor.missingAnchors.length > 0) {
|
|
922
|
+
console.warn(`\n⚠️ 探针7 锚点校验:${p7Anchor.missingAnchors.length}/${p7Anchor.coveredRows} 行判定为 covered 但证据列缺 file:line 锚点(advisory 不阻断,审查会要求回补):`)
|
|
923
|
+
for (const m of p7Anchor.missingAnchors.slice(0, 10)) {
|
|
924
|
+
console.warn(` - ${m.task}:${m.acceptance || '(空 acceptance)'}——证据当前为「${m.evidence || '(空)'}」,应给 \`测试文件路径:行号\` 首命中锚点`)
|
|
925
|
+
}
|
|
926
|
+
if (p7Anchor.missingAnchors.length > 10) console.warn(` …还有 ${p7Anchor.missingAnchors.length - 10} 行`)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
} catch { /* 锚点校验异常 fail-soft 不影响 gate */ }
|
|
875
930
|
// 结果落盘(对齐 writeReconcileRunResult 先例):独立文件 probe-consistency-result.json——
|
|
876
931
|
// 与 reconcile-result.json 同 gate 运行同目录组织(verify-runs/<ts>/),但不并入后者:
|
|
877
932
|
// reconcile-result.json 的 schema 已被 P3a 消费方锁定,塞探针字段(读改写/扩参)只会引入
|
|
@@ -1554,6 +1609,35 @@ export function writeReconcileRunResult({ runtimeRoot, changeName, envelope, res
|
|
|
1554
1609
|
* @param {object} [envelope] 预构造信封(缺省现算;显式传入便于调用方先断言再打印)
|
|
1555
1610
|
* @returns {boolean} true = 阻断(status='missing_declared')
|
|
1556
1611
|
*/
|
|
1612
|
+
/**
|
|
1613
|
+
* 打印跨仓 per-repo 对账结果(cross-repo-reconcile.js 产出的 advisory 明细,verify 块接线)。
|
|
1614
|
+
* 恒不阻断(返回 void):跨仓 diff 锚点=各仓最近提交窗口,锚点脆弱期(已 commit 多笔/全在
|
|
1615
|
+
* working-tree)会产②类假信号——明细供人工裁决,不翻主仓对账状态。
|
|
1616
|
+
* @param {Array<{repo:string,repoPath:string|null,declaredCount:number,actualCount:number,
|
|
1617
|
+
* matched:string[],missing:Array<{task:string,path:string,isNew?:boolean}>,
|
|
1618
|
+
* undeclared:string[],scaffoldCount:number,degradedReason:string|null}>|undefined} crossRepo
|
|
1619
|
+
*/
|
|
1620
|
+
function printCrossRepoReconcile(crossRepo) {
|
|
1621
|
+
if (!Array.isArray(crossRepo) || crossRepo.length === 0) return
|
|
1622
|
+
console.log(`\n🔀 跨仓 per-repo 对账(advisory 不阻断,锚点=各仓最近提交窗口):`)
|
|
1623
|
+
for (const r of crossRepo) {
|
|
1624
|
+
if (r.degradedReason) {
|
|
1625
|
+
console.warn(` ⚠️ ${r.repo}:${r.degradedReason}`)
|
|
1626
|
+
continue
|
|
1627
|
+
}
|
|
1628
|
+
const flag = (r.missing.length > 0 || r.undeclared.length > 0) ? '⚠️ ' : '✅ '
|
|
1629
|
+
console.log(` ${flag}${r.repo}(${r.repoPath}):声明 ${r.declaredCount} / 实测 ${r.actualCount} / 对上 ${r.matched.length} / ②缺 ${r.missing.length} / ③多 ${r.undeclared.length}${r.scaffoldCount > 0 ? ` / 脚手架聚合 ${r.scaffoldCount}` : ''}`)
|
|
1630
|
+
for (const m of r.missing.slice(0, 10)) {
|
|
1631
|
+
console.warn(` ②缺 ${m.task}: ${m.path}${m.isNew ? '(NEW: 声明新建,该仓未见)' : ''}——锚点窗口外已提交或多笔时可能假信号,到该仓 git log 核实`)
|
|
1632
|
+
}
|
|
1633
|
+
if (r.missing.length > 10) console.warn(` …还有 ${r.missing.length - 10} 条②缺`)
|
|
1634
|
+
for (const u of r.undeclared.slice(0, 10)) {
|
|
1635
|
+
console.warn(` ③多 ${u}(该仓最近窗口实际改动,未在任何跨仓卡声明)`)
|
|
1636
|
+
}
|
|
1637
|
+
if (r.undeclared.length > 10) console.warn(` …还有 ${r.undeclared.length - 10} 条③多`)
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1557
1641
|
export function printReconcileTargetFilesCheck(r, envelope = buildReconcileTargetFilesEnvelope(r)) {
|
|
1558
1642
|
// ②类(声明未做=计划落空):ERROR 阻断
|
|
1559
1643
|
if (r.status === 'missing_declared') {
|