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
package/src/verify-postcheck.js
CHANGED
|
@@ -28,9 +28,10 @@ import { IR_STRICT_SINCE } from './constants.js'
|
|
|
28
28
|
import { gitQuiet } from './git-helper.js'
|
|
29
29
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
30
30
|
import { join } from 'path'
|
|
31
|
-
import { verifyApiParity } from './contract-matrix.js'
|
|
31
|
+
import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
|
|
32
|
+
import { reconcileCrossRepoDeclarations } from './cross-repo-reconcile.js'
|
|
32
33
|
import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
|
|
33
|
-
import { filterDeliverableFiles } from './worktree-apply.js'
|
|
34
|
+
import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
|
|
34
35
|
// target_files 声明侧解析(Wave 1 已落地):依赖链已核实无环——plan-postcheck 不反向依赖本模块,
|
|
35
36
|
// 且本模块已经 worktree-apply.js:21 间接依赖 plan-postcheck,此处改直连不引入新环(task-04)
|
|
36
37
|
import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
|
|
@@ -605,6 +606,46 @@ function parseFlowValue(flowText, key) {
|
|
|
605
606
|
return v.length > 0 ? v : null
|
|
606
607
|
}
|
|
607
608
|
|
|
609
|
+
/**
|
|
610
|
+
* module 子集测试面的依赖测试自动发现(坑 module-test-face-rot,2026-09-16 本会话 5325f55
|
|
611
|
+
* 实证:local.yaml modules 的 test 命令是硬编码文件清单,src/verify-probes.js 命中 cli-core
|
|
612
|
+
* 但其断言测试 verify-probes-facts.test.mjs 不在清单 → quick --done 的 module 收窄实测漏掉
|
|
613
|
+
* 全量断言,回归漏到合并态才暴露)。发现口径:变更 src 文件的**直接 import 测试**(test/
|
|
614
|
+
* 下 *.test.* 内含 `../src/<changed>` 引用——静态/动态 import 通吃,子串匹配只可能多跑不漏)
|
|
615
|
+
* ∪ 变更的 test 文件本身;已被命中模块命令串覆盖的(命令串含该文件名)排除防重复。
|
|
616
|
+
* @param {{cwd: string, changedFiles: string[], coveredCommands?: string[]}} args
|
|
617
|
+
* @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
|
|
618
|
+
*/
|
|
619
|
+
export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
|
|
620
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
|
|
621
|
+
const norm = (p) => String(p).replace(/\\/g, '/')
|
|
622
|
+
const changedSrc = [...new Set(changedFiles.map(norm))]
|
|
623
|
+
.filter(p => p.startsWith('src/') && /\.(js|mjs|cjs|ts)$/.test(p))
|
|
624
|
+
const changedTests = [...new Set(changedFiles.map(norm))]
|
|
625
|
+
.filter(p => /^test\//.test(p) && /\.test\.[cm]?js$/.test(p))
|
|
626
|
+
if (changedSrc.length === 0 && changedTests.length === 0) return []
|
|
627
|
+
const covered = coveredCommands.filter(Boolean).join(' ')
|
|
628
|
+
const found = new Set()
|
|
629
|
+
const testDir = join(cwd, 'test')
|
|
630
|
+
let entries = []
|
|
631
|
+
try { entries = readdirSync(testDir) } catch { entries = [] }
|
|
632
|
+
for (const f of entries) {
|
|
633
|
+
if (!/\.test\.[cm]?js$/.test(f)) continue
|
|
634
|
+
if (covered.includes(f)) continue
|
|
635
|
+
let content = ''
|
|
636
|
+
try { content = readFileSync(join(testDir, f), 'utf8') } catch { continue }
|
|
637
|
+
for (const src of changedSrc) {
|
|
638
|
+
if (content.includes(`../${src}`)) { found.add(`test/${f}`); break }
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const t of changedTests) {
|
|
642
|
+
const fname = t.split('/').pop()
|
|
643
|
+
if (covered.includes(fname)) continue
|
|
644
|
+
if (existsSync(join(cwd, t))) found.add(t)
|
|
645
|
+
}
|
|
646
|
+
return [...found].sort()
|
|
647
|
+
}
|
|
648
|
+
|
|
608
649
|
/**
|
|
609
650
|
* 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
|
|
610
651
|
* 文件路径以 module.path 为前缀(含子目录)即视为命中。
|
|
@@ -1049,20 +1090,20 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
|
|
|
1049
1090
|
|
|
1050
1091
|
// 并入 worktree 未提交改动(坑 module-subset-zero-hit-uncommitted,2026-08-21 实证:
|
|
1051
1092
|
// 子代理默认不 commit,真实改动全在 worktree working-tree——只看 base..HEAD commit diff
|
|
1052
|
-
// 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend
|
|
1053
|
-
//
|
|
1093
|
+
// 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。口径与
|
|
1094
|
+
// collectWorktreeChangedFiles(task-review.js,D-004@v1 单一真相 helper)同源,但本段是
|
|
1095
|
+
// form A 专属变体(滤网保 .sillyspec/docs/** 交付物)——已知残留 R-09,待下次触碰统一。
|
|
1054
1096
|
// (排除 .sillyspec/ 运行时产物)。opt-in(默认关):d drafts 有自己的并入点,避免双并。
|
|
1055
1097
|
if (includeWorkingTree && mainFiles !== null) {
|
|
1056
1098
|
try {
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
: cwd
|
|
1099
|
+
// 双根读取(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证):P1 修复
|
|
1100
|
+
// (2026-09-07)只吃 opts.specBase 单路径——平台模式(specRoot 与 source_root 分离)下
|
|
1101
|
+
// meta 若落项目侧 .sillyspec/.runtime(resolveRuntimeRoot 的 localSpecBase 兜底形)仍读
|
|
1102
|
+
// 不到 → 形态 A 并入失效 → ②类假红。_readWorktreeMeta 双候选(specBase 优先 +
|
|
1103
|
+
// cwd/.sillyspec 兜底,contract-matrix 既有样板)两形全覆盖。
|
|
1104
|
+
const found = changeName ? _readWorktreeMeta(opts.specBase || null, cwd, changeName) : null
|
|
1105
|
+
if (found) {
|
|
1106
|
+
const wtGitDir = found.gitDir
|
|
1066
1107
|
const wtStatus = gitQuiet(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
|
|
1067
1108
|
// P1 修复(2026-09-07):过滤器改 filterDeliverableFiles 口径(worktree-apply.js 同款)——
|
|
1068
1109
|
// 此前 `.sillyspec/` 一刀切把 docs 交付物也滤掉,声明了模块文档的 task 在形态 A 下落②类假红
|
|
@@ -1152,16 +1193,16 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
|
|
|
1152
1193
|
function resolveMainChangedFiles(cwd, changeName, specBase = null) {
|
|
1153
1194
|
const sb = specBase || join(cwd, '.sillyspec')
|
|
1154
1195
|
if (changeName) {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1196
|
+
// 双根读取(坑 platform-dual-root-form-misjudge):specBase(平台模式=specRoot)单路径在
|
|
1197
|
+
// meta 落项目侧 .sillyspec 时静默 miss → 锚点 diff 整链失效落主仓 fallback。双候选
|
|
1198
|
+
// (specBase 优先 + cwd/.sillyspec 兜底)两形全覆盖;sb 仍作他者声明过滤根(原语义不动)。
|
|
1199
|
+
const found = _readWorktreeMeta(specBase || null, cwd, changeName)
|
|
1200
|
+
if (found) {
|
|
1201
|
+
const meta = found.meta
|
|
1159
1202
|
// 优先 baselineCommit/actualBaseHash(baseline checkpoint 之后),回退 baseHash
|
|
1160
1203
|
const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
|
|
1161
1204
|
if (diffBase) {
|
|
1162
|
-
const gitDir =
|
|
1163
|
-
? meta.worktreePath
|
|
1164
|
-
: cwd
|
|
1205
|
+
const gitDir = found.gitDir
|
|
1165
1206
|
const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
|
|
1166
1207
|
// 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
|
|
1167
1208
|
// wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
|
|
@@ -1326,7 +1367,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
|
|
|
1326
1367
|
},
|
|
1327
1368
|
})
|
|
1328
1369
|
} else if (action === 'module-subset') {
|
|
1329
|
-
mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
|
|
1370
|
+
mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
|
|
1330
1371
|
} else if (action === 'module-zero-hit-skip') {
|
|
1331
1372
|
// module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
|
|
1332
1373
|
// 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
|
|
@@ -1657,12 +1698,21 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
1657
1698
|
* 串行跑命中的模块子集,聚合结果。
|
|
1658
1699
|
* 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
|
|
1659
1700
|
*/
|
|
1660
|
-
function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
|
|
1701
|
+
function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [] }) {
|
|
1661
1702
|
const subsetStartedAt = Date.now()
|
|
1662
1703
|
const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
|
|
1704
|
+
// 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
|
|
1705
|
+
// 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
|
|
1706
|
+
// 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
|
|
1707
|
+
const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
|
|
1708
|
+
if (deps.length > 0) {
|
|
1709
|
+
perModule.push(runOneModule('deps(auto)', `node --test ${deps.slice(0, 30).join(' ')}`, cwd, knownFailures))
|
|
1710
|
+
console.log(`ℹ️ module 子集已附加依赖测试伪模块 deps(auto):${deps.length} 个(import 变更 src/变更的 test 本体,未被模块命令串覆盖——治硬编码清单腐烂)`)
|
|
1711
|
+
}
|
|
1663
1712
|
const status = aggregateStatus(perModule)
|
|
1664
1713
|
|
|
1665
|
-
|
|
1714
|
+
let command = `module[${hits.map(h => h.name).join(',')}]`
|
|
1715
|
+
if (deps.length > 0) command += `+deps(${Math.min(deps.length, 30)})`
|
|
1666
1716
|
const exitCode = status === 'passed' ? 0 : 1
|
|
1667
1717
|
const durationMs = Date.now() - subsetStartedAt
|
|
1668
1718
|
|
|
@@ -2094,6 +2144,8 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
|
|
|
2094
2144
|
* 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
|
|
2095
2145
|
* created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
|
|
2096
2146
|
* 不过)——gates 接线(task-03)据此阻断 verify 完成。
|
|
2147
|
+
* 逐文件存在性/mtime 双根取数(坑⑤ / D-005@v1):候选根 [cwd, worktree 根]——apply 前
|
|
2148
|
+
* 新文件只在 worktree,单查主仓必误报「文件不存在」;meta 缺失/in-place 退单根(零回归)。
|
|
2097
2149
|
*/
|
|
2098
2150
|
function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
|
|
2099
2151
|
const warnings = []
|
|
@@ -2121,6 +2173,26 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
|
|
|
2121
2173
|
changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
|
|
2122
2174
|
} catch { changedSet = null }
|
|
2123
2175
|
|
|
2176
|
+
// ── 候选根解析(坑⑤ / D-005@v1,消费侧双根):apply 前新文件只在 worktree,逐文件核验
|
|
2177
|
+
// 单查主仓 join(cwd, vf) 必误报「文件不存在」错误阻断 verify。worktree 根口径与
|
|
2178
|
+
// resolveVerifyChangedFiles(~1061 metaPath 段)同源:meta 在场 && 非 in-place-fallback
|
|
2179
|
+
// && worktreePath 目录存在 → roots = [cwd, worktreePath];任一不满足退 [cwd] 单根
|
|
2180
|
+
// (零回归)。解析异常 fail-open 退单根 + warning(不新增阻断路径)。静默双根——对齐
|
|
2181
|
+
// buildAcceptanceHints(verify-probes.js:223)双根先例,核验明细 root 字段已可辨。──
|
|
2182
|
+
let roots = [cwd]
|
|
2183
|
+
try {
|
|
2184
|
+
const metaPath = join(specBase || join(cwd, '.sillyspec'), '.runtime', 'worktrees', changeName, 'meta.json')
|
|
2185
|
+
if (existsSync(metaPath)) {
|
|
2186
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
2187
|
+
if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
|
|
2188
|
+
roots = [cwd, meta.worktreePath]
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
} catch (e) {
|
|
2192
|
+
roots = [cwd]
|
|
2193
|
+
warnings.push(`worktree meta 解析失败(${e.message}),evidence 双根核验退主仓单根`)
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2124
2196
|
const detailed = []
|
|
2125
2197
|
let blockedCount = 0
|
|
2126
2198
|
for (const item of items) {
|
|
@@ -2143,11 +2215,17 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
|
|
|
2143
2215
|
}
|
|
2144
2216
|
for (const vf of slot.verifiedFiles) {
|
|
2145
2217
|
const pathClass = classifyVerifiedFile(vf)
|
|
2146
|
-
|
|
2147
|
-
|
|
2218
|
+
// 双根取数(坑⑤ / D-005@v1,形态沿 buildAcceptanceHints 双根先例):任一根存在即
|
|
2219
|
+
// filesExist=true;双根都在取 worktree 根(R-05:新改动所在,主仓旧 mtime 不误判
|
|
2220
|
+
// 过期)——倒序找命中根(roots=[cwd] 时即单根现状;roots=[cwd, wt] 时先试 wt 再回退)。
|
|
2221
|
+
let hitRoot = null
|
|
2222
|
+
for (let i = roots.length - 1; i >= 0; i--) {
|
|
2223
|
+
if (existsSync(join(roots[i], vf))) { hitRoot = roots[i]; break }
|
|
2224
|
+
}
|
|
2225
|
+
const filesExist = hitRoot !== null
|
|
2148
2226
|
let mtimeOk = null
|
|
2149
2227
|
if (filesExist && startAt) {
|
|
2150
|
-
try { mtimeOk = statSync(
|
|
2228
|
+
try { mtimeOk = statSync(join(hitRoot, vf)).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
|
|
2151
2229
|
}
|
|
2152
2230
|
let diffHit = null
|
|
2153
2231
|
if (pathClass === 'code') {
|
|
@@ -2157,7 +2235,7 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
|
|
|
2157
2235
|
}
|
|
2158
2236
|
const ok = filesExist && mtimeOk !== false && diffHit !== false
|
|
2159
2237
|
if (!ok) allOk = false
|
|
2160
|
-
perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
|
|
2238
|
+
perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit, root: hitRoot,
|
|
2161
2239
|
reason: !filesExist ? '文件不存在'
|
|
2162
2240
|
: (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
|
|
2163
2241
|
: (diffHit === false ? '不在本变更 git diff 内' : null)) })
|
|
@@ -2245,7 +2323,7 @@ function normalizeReviewChangedFile(p) {
|
|
|
2245
2323
|
* @returns {{ cardCount: number, declarations: Array<{task:string, path:string, isNew:boolean, raw:string, invalid:string|null}>, crossRepoCards: string[], noDeclarationCount: number }}
|
|
2246
2324
|
*/
|
|
2247
2325
|
function collectDeclaredTargetFiles(specBase, changeName) {
|
|
2248
|
-
const out = { cardCount: 0, declarations: [], crossRepoCards: [], noDeclarationCount: 0 }
|
|
2326
|
+
const out = { cardCount: 0, declarations: [], crossRepoCards: [], crossRepoDeclarations: {}, noDeclarationCount: 0 }
|
|
2249
2327
|
const tasksDir = join(specBase, 'changes', changeName, 'tasks')
|
|
2250
2328
|
if (!existsSync(tasksDir)) return out
|
|
2251
2329
|
// task-NN.md 过滤 + 排序:与 plan-postcheck / task-review 的枚举口径一致,输出确定性
|
|
@@ -2257,7 +2335,18 @@ function collectDeclaredTargetFiles(specBase, changeName) {
|
|
|
2257
2335
|
const fm = content.match(/^---\n([\s\S]*?)\n---/)?.[1] || ''
|
|
2258
2336
|
const task = (fm.match(/^id:\s*(.+)/m)?.[1] || '').trim() || file.replace(/\.md$/, '')
|
|
2259
2337
|
const repo = parseRepo(content)
|
|
2260
|
-
if (repo) {
|
|
2338
|
+
if (repo) {
|
|
2339
|
+
out.crossRepoCards.push(`${task}(repo: ${repo})`)
|
|
2340
|
+
// 跨仓声明留存(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期):
|
|
2341
|
+
// 主仓对账仍剔除跨仓卡(主仓 actual 永远对不上跨仓声明),但声明不丢弃——
|
|
2342
|
+
// reconcileCrossRepoDeclarations 到各自仓对账(advisory)
|
|
2343
|
+
const crossEntries = parseTargetFiles(content).entries || []
|
|
2344
|
+
if (crossEntries.length > 0) {
|
|
2345
|
+
;(out.crossRepoDeclarations[repo] = out.crossRepoDeclarations[repo] || [])
|
|
2346
|
+
.push(...crossEntries.map(e => ({ task, path: e.path, isNew: e.isNew })))
|
|
2347
|
+
}
|
|
2348
|
+
continue
|
|
2349
|
+
}
|
|
2261
2350
|
const { entries, missing } = parseTargetFiles(content)
|
|
2262
2351
|
if (missing) { out.noDeclarationCount++; continue }
|
|
2263
2352
|
for (const e of entries) {
|
|
@@ -2324,14 +2413,22 @@ function parsePorcelainFilePaths(raw) {
|
|
|
2324
2413
|
* resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
|
|
2325
2414
|
* merge-base 不可得 null);降级返回恒 null。
|
|
2326
2415
|
*
|
|
2327
|
-
* @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null, baseAnchor: string|null }}
|
|
2416
|
+
* @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, foreignExcludedFiles: string[], degradedReason: string|null, baseAnchor: string|null }}
|
|
2328
2417
|
*/
|
|
2329
2418
|
export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
|
|
2330
|
-
|
|
2331
|
-
|
|
2419
|
+
// 形态判定双根化(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证:平台模式
|
|
2420
|
+
// specBase=specRoot 时项目侧 .sillyspec 的 meta 读不到 → 误判 post-apply → 按主仓 git
|
|
2421
|
+
// 三源对账 → ②类 21 条全量假红阻断 verify 收尾)。_readWorktreeMeta 双候选(specBase
|
|
2422
|
+
// 优先 + cwd/.sillyspec 兜底,contract-matrix 既有样板)为形态判定的唯一权威入口。
|
|
2423
|
+
const wtMeta = _readWorktreeMeta(specBase || null, cwd, changeName)
|
|
2424
|
+
const form = wtMeta ? 'worktree' : 'post-apply'
|
|
2332
2425
|
const sources = []
|
|
2333
2426
|
const union = new Set()
|
|
2334
2427
|
let foreignExcluded = 0
|
|
2428
|
+
// 退栈文件名单(scope-audit-cross-repo-blindness 改进点 3):原只回计数,scope-audit 侧
|
|
2429
|
+
// 无法区分「真未动」与「已写盘但被退栈」——补名单(仅文件名,owners 不重复携带),
|
|
2430
|
+
// 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
|
|
2431
|
+
const foreignExcludedFiles = []
|
|
2335
2432
|
let baseAnchor = null
|
|
2336
2433
|
|
|
2337
2434
|
if (form === 'worktree') {
|
|
@@ -2339,15 +2436,12 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
|
|
|
2339
2436
|
// working-tree 并入、fail-open 均为该函数既有语义,不另造口径)——
|
|
2340
2437
|
const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
|
|
2341
2438
|
if (files === null) {
|
|
2342
|
-
return { ok: false, form, files: [], sources, foreignExcluded,
|
|
2439
|
+
return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
|
|
2343
2440
|
degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
|
|
2344
2441
|
}
|
|
2345
|
-
// baseAnchor
|
|
2346
|
-
// resolveMainChangedFiles
|
|
2347
|
-
|
|
2348
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
2349
|
-
baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
|
|
2350
|
-
} catch {}
|
|
2442
|
+
// baseAnchor:读形态判定命中的同一份 worktree meta.json 锚点(baselineCommit>
|
|
2443
|
+
// actualBaseHash>baseHash,resolveMainChangedFiles 同口径;三锚全缺 → null)
|
|
2444
|
+
baseAnchor = wtMeta?.meta?.baselineCommit || wtMeta?.meta?.actualBaseHash || wtMeta?.meta?.baseHash || null
|
|
2351
2445
|
sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
|
|
2352
2446
|
for (const f of files) union.add(normalizeReconcilePath(f))
|
|
2353
2447
|
} else {
|
|
@@ -2386,7 +2480,10 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
|
|
|
2386
2480
|
let files = parsePorcelainFilePaths(statusRaw)
|
|
2387
2481
|
if (files.length > 0) {
|
|
2388
2482
|
const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, files, { specBase })
|
|
2389
|
-
if (foreign.length > 0)
|
|
2483
|
+
if (foreign.length > 0) {
|
|
2484
|
+
foreignExcluded = foreign.length
|
|
2485
|
+
for (const fo of foreign) foreignExcludedFiles.push(normalizeReconcilePath(fo.file))
|
|
2486
|
+
}
|
|
2390
2487
|
files = own
|
|
2391
2488
|
}
|
|
2392
2489
|
for (const f of files) union.add(normalizeReconcilePath(f))
|
|
@@ -2403,13 +2500,13 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
|
|
|
2403
2500
|
} catch { /* 兜底源失败 → 忽略 */ }
|
|
2404
2501
|
}
|
|
2405
2502
|
if (!diffOk && !statusOk) {
|
|
2406
|
-
return { ok: false, form, files: [], sources, foreignExcluded,
|
|
2503
|
+
return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
|
|
2407
2504
|
degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
|
|
2408
2505
|
}
|
|
2409
2506
|
}
|
|
2410
2507
|
|
|
2411
2508
|
const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
|
|
2412
|
-
return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
|
|
2509
|
+
return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor }
|
|
2413
2510
|
}
|
|
2414
2511
|
|
|
2415
2512
|
/**
|
|
@@ -2419,13 +2516,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
|
|
|
2419
2516
|
* - 归属过滤:run 目录带 change 戳且不等值 → 他变更的 run 跳过(task-NN 跨变更同名,混扫必
|
|
2420
2517
|
* 错归因;无戳旧 run 保留——向后兼容,task-review.js resolveLatestExecuteRunIdWithTasks 同款)。
|
|
2421
2518
|
* - runId 形如 exec-YYYY-MM-DD-HHMMSS:字典序倒序 = 新 run 优先(review 以最新执行为准)。
|
|
2422
|
-
* - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match
|
|
2423
|
-
*
|
|
2519
|
+
* - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、全命中收集(多归属,
|
|
2520
|
+
* 坑④ worktree-tick-guard-commit-only:原「首个命中即止」吞掉同文件多 task 的归属)——
|
|
2521
|
+
* 归因只是③类报告的附注,错比漏代价低;同一 taskId 去重,顺序 = 扫描序(run 倒序新优先 +
|
|
2522
|
+
* task 目录字典序)。
|
|
2424
2523
|
*
|
|
2425
2524
|
* @param {string} runtimeRoot .sillyspec/.runtime
|
|
2426
2525
|
* @param {string} changeName
|
|
2427
2526
|
* @param {string[]} paths 待归因的③类路径(git 口径的干净路径)
|
|
2428
|
-
* @returns {Map<string, string>} normalized(path) → taskId
|
|
2527
|
+
* @returns {Map<string, string[]>} normalized(path) → taskId[](全命中收集,保序去重)
|
|
2429
2528
|
*/
|
|
2430
2529
|
function attributeSuspectTasks(runtimeRoot, changeName, paths) {
|
|
2431
2530
|
const map = new Map()
|
|
@@ -2452,7 +2551,10 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
|
|
|
2452
2551
|
const taskId = (typeof review.task === 'string' && review.task.trim()) || t
|
|
2453
2552
|
for (const cf of Array.isArray(review.changedFiles) ? review.changedFiles : []) {
|
|
2454
2553
|
const n = normalizeReviewChangedFile(cf)
|
|
2455
|
-
if (n
|
|
2554
|
+
if (!n || !wantSet.has(n)) continue
|
|
2555
|
+
// 多归属:同文件被多 task 声明时全收集(D-004@v1,坑④)——旧单归属在此吞掉后续 task
|
|
2556
|
+
const list = map.get(n)
|
|
2557
|
+
if (list) { if (!list.includes(taskId)) list.push(taskId) } else map.set(n, [taskId])
|
|
2456
2558
|
}
|
|
2457
2559
|
}
|
|
2458
2560
|
}
|
|
@@ -2477,7 +2579,7 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
|
|
|
2477
2579
|
* status: 'ok'|'missing_declared'|'undeclared'|'skipped'|'degraded',
|
|
2478
2580
|
* matched: string[], // ①交集(唯一路径排序)
|
|
2479
2581
|
* missing: Array<{task: string, path: string, isNew?: boolean}>, // ②声明没做(isNew 标 NEW: 声明,剥前缀 path)
|
|
2480
|
-
* undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json
|
|
2582
|
+
* undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因,多归属 join('、') 后的 string)
|
|
2481
2583
|
* skipReason: string|null, // skipped/degraded 的原因(其余状态为 null)
|
|
2482
2584
|
* notes: string[], // 跨仓卡剔除 / 并行 WIP 剔除等提示(additive)
|
|
2483
2585
|
* form: 'worktree'|'post-apply'|null, // actual 形态(skipped 无 actual 时为 null;additive)
|
|
@@ -2499,8 +2601,24 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
2499
2601
|
|
|
2500
2602
|
// —— 声明侧 ——
|
|
2501
2603
|
const decl = collectDeclaredTargetFiles(sb, changeName)
|
|
2502
|
-
|
|
2503
|
-
|
|
2604
|
+
// —— 跨仓 per-repo 对账(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期:
|
|
2605
|
+
// 此前 22 个跨仓文件在归档表全标「计划未动」、跨仓全靠人工到对应仓解释)——advisory 不阻断:
|
|
2606
|
+
// 锚点是各仓 HEAD~1..HEAD 最近提交窗口(多 task 同仓只反映最近一笔,锚点脆弱期会产②类假
|
|
2607
|
+
// 信号),只报告不翻转主仓状态。结果经 crossRepo 字段(gates 渲染明细)与 notes(摘要随
|
|
2608
|
+
// 各状态出口流动)双通道出口。
|
|
2609
|
+
let crossRepo = []
|
|
2610
|
+
if (decl.crossRepoCards.length > 0) {
|
|
2611
|
+
try {
|
|
2612
|
+
crossRepo = reconcileCrossRepoDeclarations({ specBase: sb, cwd, declarationsByRepo: decl.crossRepoDeclarations })
|
|
2613
|
+
} catch { crossRepo = [] /* 跨仓对账异常不拖垮主仓链(advisory 定位) */ }
|
|
2614
|
+
for (const label of decl.crossRepoCards) {
|
|
2615
|
+
notes.push(`跨仓 task 卡 ${label} 不进主仓对账(主仓 actual 对不上跨仓声明,D-004)——其声明已转 per-repo 对账(见下方跨仓对账段)`)
|
|
2616
|
+
}
|
|
2617
|
+
for (const r of crossRepo) {
|
|
2618
|
+
notes.push(r.degradedReason
|
|
2619
|
+
? `跨仓 ${r.repo}:${r.degradedReason}`
|
|
2620
|
+
: `跨仓 ${r.repo} 对账:声明 ${r.declaredCount} / 实测 ${r.actualCount} / 对上 ${r.matched.length} / ②缺 ${r.missing.length} / ③多 ${r.undeclared.length}${r.scaffoldCount > 0 ? `(另有 ${r.scaffoldCount} 脚手架已聚合)` : ''}——advisory(锚点=该仓最近提交窗口)`)
|
|
2621
|
+
}
|
|
2504
2622
|
}
|
|
2505
2623
|
if (decl.cardCount === 0) {
|
|
2506
2624
|
return { status: 'skipped', matched, missing, undeclared,
|
|
@@ -2514,7 +2632,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
2514
2632
|
&& decl.noDeclarationCount === decl.cardCount - decl.crossRepoCards.length) {
|
|
2515
2633
|
return { status: 'skipped', matched, missing, undeclared,
|
|
2516
2634
|
skipReason: `严格模式变更(created_at ≥ IR_STRICT_SINCE)主仓 task 卡全部未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount - decl.crossRepoCards.length} 张主仓卡${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})`,
|
|
2517
|
-
notes, form: null, sources: [],
|
|
2635
|
+
notes, form: null, sources: [], crossRepo,
|
|
2518
2636
|
strictViolation: {
|
|
2519
2637
|
code: 'target_files_all_missing_strict',
|
|
2520
2638
|
message: `严格模式要求每个主仓 task 卡声明 target_files(计划改动的文件清单,taskcard 骨架已预置字段)——逐卡 Edit 填入:已存在文件写仓根相对精确路径,新建文件加 NEW: 前缀;填毕重跑 --done(进度不丢)`,
|
|
@@ -2526,7 +2644,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
2526
2644
|
: `所有 task 卡均未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount} 张无声明` +
|
|
2527
2645
|
`${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})` +
|
|
2528
2646
|
`——存量卡可忽略;新卡请补:每条为仓根相对精确文件路径,不存在的文件加 NEW: 前缀`
|
|
2529
|
-
return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [] }
|
|
2647
|
+
return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [], crossRepo }
|
|
2530
2648
|
}
|
|
2531
2649
|
|
|
2532
2650
|
// —— actual 侧(三源并集,两形态)——
|
|
@@ -2538,7 +2656,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
2538
2656
|
// 降级(fail-soft 不误红):git 全部不可用 → actual 不可得,不产生 missing/undeclared
|
|
2539
2657
|
return { status: 'degraded', matched, missing, undeclared,
|
|
2540
2658
|
skipReason: `git 全部不可用,actual 不可得,对账降级跳过——${actual.degradedReason}`,
|
|
2541
|
-
notes, form: actual.form, sources: actual.sources }
|
|
2659
|
+
notes, form: actual.form, sources: actual.sources, crossRepo }
|
|
2542
2660
|
}
|
|
2543
2661
|
|
|
2544
2662
|
// —— 三类差集 ——
|
|
@@ -2567,17 +2685,36 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
|
|
|
2567
2685
|
missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
|
|
2568
2686
|
}
|
|
2569
2687
|
}
|
|
2570
|
-
// ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002
|
|
2688
|
+
// ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)。
|
|
2689
|
+
// 归因多归属(attributeSuspectTasks 返 string[],坑④):组装边界 join('、') 成 string——
|
|
2690
|
+
// gates.js / archive-delta.js 等下游渲染零改动(suspectTask 对外形态保持 string)。
|
|
2571
2691
|
const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredKeySet.has(pathKey(p))))
|
|
2692
|
+
// 脚手架软桶(坑 reconcile-undeclared-scaffold-flood,2026-09-15 复盘实证:worktree baseline
|
|
2693
|
+
// 拷入的 .claude/skills、attachments 等设施文件 58 条逐条进③类、每轮 verify 重复刷屏):
|
|
2694
|
+
// classifyToolScaffold(worktree-apply / scope-audit 同款口径)命中的工具/平台设施文件聚合
|
|
2695
|
+
// 为一行 note——环境设施面非交付物,逐条列③类只会淹没真 scope creep。全为脚手架时③类
|
|
2696
|
+
// 判 ok(脚手架不构成 scope creep 语义)。
|
|
2697
|
+
const scaffoldBuckets = { platform: 0, tool: 0 }
|
|
2698
|
+
const scaffoldSamples = []
|
|
2572
2699
|
for (const path of actual.files) {
|
|
2573
2700
|
if (declaredKeySet.has(pathKey(path))) continue
|
|
2701
|
+
const bucket = classifyToolScaffold(path)
|
|
2702
|
+
if (bucket) {
|
|
2703
|
+
scaffoldBuckets[bucket] = (scaffoldBuckets[bucket] || 0) + 1
|
|
2704
|
+
if (scaffoldSamples.length < 5) scaffoldSamples.push(path)
|
|
2705
|
+
continue
|
|
2706
|
+
}
|
|
2574
2707
|
const s = suspect.get(normalizeReviewChangedFile(path))
|
|
2575
|
-
undeclared.push(s ? { path, suspectTask: s } : { path })
|
|
2708
|
+
undeclared.push(Array.isArray(s) && s.length > 0 ? { path, suspectTask: s.join('、') } : { path })
|
|
2709
|
+
}
|
|
2710
|
+
const scaffoldTotal = scaffoldBuckets.platform + scaffoldBuckets.tool
|
|
2711
|
+
if (scaffoldTotal > 0) {
|
|
2712
|
+
notes.push(`③类另有 ${scaffoldTotal} 个工具/平台脚手架文件(platform ${scaffoldBuckets.platform} / tool ${scaffoldBuckets.tool},如 ${scaffoldSamples.join('、')}${scaffoldTotal > scaffoldSamples.length ? ' 等' : ''})已聚合不逐条列——非交付物(scope-audit 设施桶同口径)`)
|
|
2576
2713
|
}
|
|
2577
2714
|
|
|
2578
2715
|
// 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
|
|
2579
2716
|
const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
|
|
2580
|
-
return { status, matched, missing, undeclared, skipReason: null, notes, form: actual.form, sources: actual.sources }
|
|
2717
|
+
return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }
|
|
2581
2718
|
}
|
|
2582
2719
|
|
|
2583
2720
|
// ─────────────────────────────────────────────────────────────────────────────
|