sillyspec 3.27.7 → 3.27.11
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-execute/SKILL.md +1 -1
- package/.npmrc-temp +1 -0
- package/package.json +1 -1
- package/src/commit-suggest.js +22 -0
- package/src/contract-matrix.js +59 -15
- package/src/decision-distill.js +33 -14
- package/src/docs-check.js +12 -2
- package/src/facade-hint.js +170 -0
- package/src/index.js +113 -15
- package/src/progress/stage-machine.js +3 -1
- package/src/run/command.js +81 -15
- package/src/run/complete-handlers.js +34 -0
- package/src/run/complete.js +10 -1
- package/src/run/gates.js +100 -8
- package/src/run/multi-repo-context.js +42 -4
- package/src/run/shared.js +50 -24
- package/src/run/stage.js +49 -3
- package/src/spec-sync.js +88 -1
- package/src/stage-contract.js +20 -7
- package/src/stages/execute.js +37 -16
- package/src/stages/plan.js +8 -0
- package/src/task-review.js +7 -0
- package/src/verify-postcheck.js +47 -6
- package/src/worktree-apply.js +254 -4
- package/src/worktree-cross.js +294 -0
- package/src/worktree-deps.js +156 -58
- package/templates/prompts/verify-probes.md +2 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-cross.js — 跨仓仓 worktree 隔离(坑 cross-repo-no-worktree-isolation,2026-08-27 用户实证)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:跨仓 task 的原设计(multi-repo W1 D-005/D-006/D-009)是子代理直写跨仓仓主工作副本、
|
|
5
|
+
* commit 直落该仓主干,apply 对跨仓 no-op。实战暴露三类事故面:
|
|
6
|
+
* 1. 并行混流:跨仓主工作副本常有用户/其他变更的在途改动,子代理产物与之混在同一工作区,
|
|
7
|
+
* 无法区分归属、无法整体回滚(实证:sub-grid-security 主工作副本 task-05 产物与
|
|
8
|
+
* .gitignore/.webpackrc.js/yarn.lock 并行改动混流,无法核对);
|
|
9
|
+
* 2. 无 base 锚:跨仓仓无 meta/baseHash,base 只能靠 task 卡 base_commit 锡点;
|
|
10
|
+
* 3. 误伤面大:子代理在主工作副本可误改/误删用户未提交文件,无护栏。
|
|
11
|
+
*
|
|
12
|
+
* 本模块把跨仓仓纳入与主仓同构的 worktree 隔离(D-009 就地翻新,向后兼容):
|
|
13
|
+
* - worktree 落位 <主仓 specBase>/.runtime/worktrees/<change>--<repoKey>:主仓侧该目录已被
|
|
14
|
+
* .gitignore 覆盖(主仓 create 已强制检查),不要求跨仓仓自配 .gitignore;git worktree
|
|
15
|
+
* 的注册信息在跨仓 .git/worktrees/,目录本身可在仓外任意路径;
|
|
16
|
+
* - meta.json 与主仓同 schema + repoKey/crossRepoRoot/isCross 标识(唯一写入方 = 本模块,
|
|
17
|
+
* multi-repo-context.js 只读且路径公式与此处 crossWorktreePath 保持同步);
|
|
18
|
+
* - base 锚 meta.baseHash(跨仓仓 HEAD 快照);dirty baseline overlay 沿用主仓同款
|
|
19
|
+
* (跨仓主工作副本的在途改动进 baseline,不算交付 diff,apply 不覆盖它们);
|
|
20
|
+
* - deps 供给 provisionDeps(worktree, 跨仓根, {specBase:null})——sniff worktree 自身项目
|
|
21
|
+
* 类型(package.json→nodejs 链主工作副本 node_modules;不沿用主仓 local.yaml 的
|
|
22
|
+
* project.type,主仓与跨仓类型常不同,如实测 maven 主仓 + nodejs 前端仓);
|
|
23
|
+
* - cleanup:解链 node_modules junction → git worktree remove --force → 保留分支
|
|
24
|
+
* sillyspec/<change> 作 review 锚点(对齐主仓 cleanup 的分支保护哲学)。
|
|
25
|
+
*
|
|
26
|
+
* 兼容:无跨仓 worktree meta 的旧变更(legacy 直写主干模式)各链路继续走原路径,零回归。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { git, gitQuiet } from './git-helper.js';
|
|
32
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
33
|
+
import { WorktreeManager, unlinkNodeModulesLinks, safeRemoveWorktreeDir } from './worktree.js';
|
|
34
|
+
import { provisionDeps } from './worktree-deps.js';
|
|
35
|
+
import { aggregateDeclaredRepos } from './run/shared.js';
|
|
36
|
+
import { parseRepoRegistry } from './stages/plan-postcheck.js';
|
|
37
|
+
|
|
38
|
+
const META_FILE = 'meta.json';
|
|
39
|
+
const BRANCH_PREFIX = 'sillyspec/';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 跨仓 worktree 路径(唯一路径公式;multi-repo-context.js 的 meta 只读逻辑与此同步)。
|
|
43
|
+
* @param {string} specBase 主仓 spec 根(<主仓>/.sillyspec)
|
|
44
|
+
* @param {string} changeName 变更名
|
|
45
|
+
* @param {string} repoKey 跨仓 repo 键(local.yaml repos: 段的 key)
|
|
46
|
+
*/
|
|
47
|
+
export function crossWorktreePath(specBase, changeName, repoKey) {
|
|
48
|
+
return join(specBase, '.runtime', 'worktrees', `${changeName}--${repoKey}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 读跨仓 worktree meta(不存在/损坏返 null = legacy 直写模式)。
|
|
53
|
+
* @returns {object|null}
|
|
54
|
+
*/
|
|
55
|
+
export function getCrossWorktreeMeta(specBase, changeName, repoKey) {
|
|
56
|
+
const metaPath = join(crossWorktreePath(specBase, changeName, repoKey), META_FILE);
|
|
57
|
+
if (!existsSync(metaPath)) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 列出某变更已建的全部跨仓 worktree meta(扫描 worktrees 目录下 `<change>--*` 且 isCross 的 meta)。
|
|
67
|
+
* @returns {Array<{repoKey: string, meta: object}>}
|
|
68
|
+
*/
|
|
69
|
+
export function listCrossWorktreeMetas(specBase, changeName) {
|
|
70
|
+
const base = join(specBase, '.runtime', 'worktrees');
|
|
71
|
+
if (!existsSync(base)) return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const name of readdirSync(base)) {
|
|
74
|
+
if (!name.startsWith(`${changeName}--`)) continue;
|
|
75
|
+
const metaPath = join(base, name, META_FILE);
|
|
76
|
+
if (!existsSync(metaPath)) continue;
|
|
77
|
+
try {
|
|
78
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
79
|
+
if (meta && meta.isCross && meta.repoKey) out.push({ repoKey: meta.repoKey, meta });
|
|
80
|
+
} catch { /* 损坏 meta 跳过(cleanup 阶段仍按目录处理) */ }
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 聚合声明跨仓 key(plan.md 内联卡片 + tasks/ 独立卡片双源,与 shared.js getOrCreateMultiRepoContext 同口径)。
|
|
87
|
+
* @param {string} specBase
|
|
88
|
+
* @param {string} changeName
|
|
89
|
+
* @returns {string[]} 除 'main' 外的跨仓 key(未注册/配置错由 MultiRepoContext fail-closed,此处只聚合)
|
|
90
|
+
*/
|
|
91
|
+
function aggregateCrossRepoKeys(specBase, changeName) {
|
|
92
|
+
const planFile = join(specBase, 'changes', changeName, 'plan.md');
|
|
93
|
+
let planContent = '';
|
|
94
|
+
if (existsSync(planFile)) {
|
|
95
|
+
try { planContent = readFileSync(planFile, 'utf8') } catch { planContent = '' }
|
|
96
|
+
}
|
|
97
|
+
// tasks/ 独立卡片兜底(与 run/shared.js collectTaskCardReposFallback 同口径,该函数未导出)
|
|
98
|
+
const tasksDir = join(specBase, 'changes', changeName, 'tasks');
|
|
99
|
+
if (existsSync(tasksDir)) {
|
|
100
|
+
for (const f of readdirSync(tasksDir)) {
|
|
101
|
+
if (!/^task-\d+\.md$/i.test(f)) continue;
|
|
102
|
+
try { planContent += '\n' + readFileSync(join(tasksDir, f), 'utf8') + '\n' } catch { /* 兜底源不阻断 */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return aggregateDeclaredRepos(planContent).filter(k => k !== 'main');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 读主仓 local.yaml 的 repos: 段(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
|
|
110
|
+
* @returns {Map<string,string>}
|
|
111
|
+
*/
|
|
112
|
+
function readRepoRegistry(cwd) {
|
|
113
|
+
const p = join(cwd, '.sillyspec', 'local.yaml');
|
|
114
|
+
let text = '';
|
|
115
|
+
if (existsSync(p)) {
|
|
116
|
+
try { text = readFileSync(p, 'utf8') } catch { text = '' }
|
|
117
|
+
}
|
|
118
|
+
return parseRepoRegistry(text);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* execute 启动时为每个声明的跨仓仓建 worktree(幂等:meta 在即复用)。
|
|
123
|
+
*
|
|
124
|
+
* 失败语义 fail-closed(对齐主仓 create:隔离建不起来就不该开工):任何仓创建失败 → 抛错,
|
|
125
|
+
* 由调用方(run/stage.js execute 启动)exit(1) 并给修复指引。已建成功的仓保留(重跑幂等复用)。
|
|
126
|
+
*
|
|
127
|
+
* @param {{ cwd: string, changeName: string, specBase?: string }} opts
|
|
128
|
+
* @returns {{ created: Array<{repoKey,worktreePath}>, reused: Array<{repoKey,worktreePath}>, skippedLegacy: string[] }}
|
|
129
|
+
* @throws {Error} 跨仓仓未注册 / git 不可达 / worktree add 失败 / 分支冲突
|
|
130
|
+
*/
|
|
131
|
+
export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
132
|
+
const base = specBase || join(cwd, '.sillyspec');
|
|
133
|
+
const keys = aggregateCrossRepoKeys(base, changeName);
|
|
134
|
+
const registry = readRepoRegistry(cwd);
|
|
135
|
+
const created = [];
|
|
136
|
+
const reused = [];
|
|
137
|
+
const skippedLegacy = [];
|
|
138
|
+
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const repoRoot = registry.get(key);
|
|
141
|
+
if (!repoRoot) {
|
|
142
|
+
// 与 MultiRepoContext 约束②同语义:声明的 repo 必须已注册,配置错不降级
|
|
143
|
+
throw new Error(
|
|
144
|
+
`跨仓 repo "${key}" 未在 local.yaml repos: 段注册。` +
|
|
145
|
+
`一键注册:sillyspec local register-repo ${key} <${key} 仓根路径>(勿手编 YAML),补注册后重跑 execute。`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const existing = getCrossWorktreeMeta(base, changeName, key);
|
|
149
|
+
if (existing && existing.worktreePath && existsSync(existing.worktreePath)) {
|
|
150
|
+
reused.push({ repoKey: key, worktreePath: existing.worktreePath });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// meta 在但目录没了(外部误删):走重建(下方 git worktree add 会因注册残留失败,
|
|
154
|
+
// 先 prune 掉悬空注册再建——目录不在则 prune 无损)
|
|
155
|
+
if (existing) {
|
|
156
|
+
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const worktreePath = crossWorktreePath(base, changeName, key);
|
|
160
|
+
const branch = BRANCH_PREFIX + changeName;
|
|
161
|
+
|
|
162
|
+
// base 快照:跨仓仓当前 HEAD(与主仓 create 的默认 base 语义一致)
|
|
163
|
+
let baseHash;
|
|
164
|
+
try {
|
|
165
|
+
baseHash = git(repoRoot, ['rev-parse', 'HEAD']);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
throw new Error(`跨仓 repo "${key}"(${repoRoot})git 不可达(rev-parse HEAD 失败:${e.message})。请检查 local.yaml repos: 段路径。`);
|
|
168
|
+
}
|
|
169
|
+
const branchExists = !!gitQuiet(repoRoot, ['rev-parse', '--verify', `refs/heads/${branch}`]);
|
|
170
|
+
if (branchExists) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`跨仓仓 ${key} 已有分支 ${branch}(疑似上次 execute 残留)。三选一:\n` +
|
|
173
|
+
` ① 确认残留作废:git -C ${repoRoot} branch -D ${branch} 后重跑;\n` +
|
|
174
|
+
` ② 分支内容已 apply 落地或不再需要:sillyspec worktree cleanup ${changeName} --force(跨仓 worktree 一并清理后重跑);\n` +
|
|
175
|
+
` ③ 换变更名重跑。`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(join(base, '.runtime', 'worktrees'), { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
git(repoRoot, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
throw new Error(`跨仓仓 ${key} worktree 创建失败(${worktreePath}):${e.stderr || e.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const meta = {
|
|
186
|
+
name_zh: 'worktree 元数据',
|
|
187
|
+
changeName,
|
|
188
|
+
repoKey: key,
|
|
189
|
+
crossRepoRoot: repoRoot,
|
|
190
|
+
isCross: true,
|
|
191
|
+
branch,
|
|
192
|
+
baseBranch: gitQuiet(repoRoot, ['symbolic-ref', '--short', 'HEAD']) || 'HEAD',
|
|
193
|
+
baseHash,
|
|
194
|
+
actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
worktreePath,
|
|
197
|
+
mode: 'worktree',
|
|
198
|
+
};
|
|
199
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
200
|
+
|
|
201
|
+
// dirty baseline overlay:跨仓主工作副本在途改动 → worktree baseline(借主仓同款实现,
|
|
202
|
+
// 只用其参数化逻辑,不用实例路径状态)
|
|
203
|
+
try {
|
|
204
|
+
const wmHelper = new WorktreeManager({ cwd: repoRoot });
|
|
205
|
+
const baselineResult = wmHelper._overlayBaseline(repoRoot, worktreePath);
|
|
206
|
+
meta.baselineFiles = baselineResult.files;
|
|
207
|
+
meta.baselineHash = baselineResult.baselineHash;
|
|
208
|
+
if (baselineResult.files.length > 0) {
|
|
209
|
+
meta.baselineCommit = wmHelper._createBaselineCheckpoint(worktreePath, `${changeName}--${key}`, baselineResult.files);
|
|
210
|
+
}
|
|
211
|
+
if (baselineResult.errors && baselineResult.errors.length > 0) {
|
|
212
|
+
console.warn(`⚠️ 跨仓 ${key} baseline overlay 部分失败(${baselineResult.errors.join('; ')})——在途改动未全量进 baseline,apply 前请人工核对跨仓主工作副本`);
|
|
213
|
+
}
|
|
214
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.warn(`⚠️ 跨仓 ${key} baseline overlay 失败(${e.message})——跨仓主工作副本如有在途改动,worktree 不含它们`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
|
|
220
|
+
// 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
|
|
221
|
+
// 打到前端仓上)。失败不阻断(与主仓 create 同语义,doctor --fix 可修)
|
|
222
|
+
try {
|
|
223
|
+
const deps = provisionDeps(worktreePath, repoRoot, { specBase: null }) || {};
|
|
224
|
+
Object.assign(meta, {
|
|
225
|
+
depsStatus: deps.depsStatus,
|
|
226
|
+
depsMethod: deps.depsMethod || null,
|
|
227
|
+
depsSource: deps.depsSource || null,
|
|
228
|
+
depsLockHash: deps.depsLockHash || null,
|
|
229
|
+
depsCheckedAt: deps.depsCheckedAt || null,
|
|
230
|
+
...(deps.depsError ? { depsError: deps.depsError } : {}),
|
|
231
|
+
});
|
|
232
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
233
|
+
} catch (e) {
|
|
234
|
+
meta.depsStatus = 'failed';
|
|
235
|
+
meta.depsError = `provisionDeps crashed: ${e.message}`;
|
|
236
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
created.push({ repoKey: key, worktreePath });
|
|
240
|
+
}
|
|
241
|
+
return { created, reused, skippedLegacy };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 清理某变更的全部跨仓 worktree(幂等)。
|
|
246
|
+
*
|
|
247
|
+
* 顺序对齐主仓 cleanup 的 Windows 防护:先解 node_modules junction(裸删会穿透删跨仓主工作
|
|
248
|
+
* 副本的 node_modules),再 git worktree remove --force,最后**保留分支** sillyspec/<change>
|
|
249
|
+
* 作 review 锚点(分支删除在主仓侧有 review 锚点 + 双保护,跨仓侧 v1 保守不删,残 branch
|
|
250
|
+
* 可人工清理)。目录残留(remove 失败)走 safeRemoveWorktreeDir 兜底 + residual 上报。
|
|
251
|
+
*
|
|
252
|
+
* @param {{ cwd: string, changeName: string, specBase?: string, force?: boolean }} opts
|
|
253
|
+
* @returns {{ results: Array<{repoKey, result: 'cleaned'|'skipped'|'partial', details: string[], residual: string[]}> }}
|
|
254
|
+
*/
|
|
255
|
+
export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false }) {
|
|
256
|
+
const base = specBase || join(cwd, '.sillyspec');
|
|
257
|
+
const results = [];
|
|
258
|
+
for (const { repoKey, meta } of listCrossWorktreeMetas(base, changeName)) {
|
|
259
|
+
const details = [];
|
|
260
|
+
const residual = [];
|
|
261
|
+
const repoRoot = meta.crossRepoRoot;
|
|
262
|
+
const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
|
|
263
|
+
if (!existsSync(wtPath)) {
|
|
264
|
+
try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
265
|
+
results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!force) {
|
|
269
|
+
// fail-closed:worktree 内还有未落地交付(相对 baseHash 有 diff)时拒绝清理(对齐主仓
|
|
270
|
+
// hasUnappliedChanges 哲学;跨仓侧以「分支上有交付 diff 且未 apply」近似)
|
|
271
|
+
try {
|
|
272
|
+
const diff = gitQuiet(wtPath, ['diff', '--name-only', meta.baseHash]) || '';
|
|
273
|
+
const untracked = gitQuiet(wtPath, ['ls-files', '--others', '--exclude-standard']) || '';
|
|
274
|
+
if (diff.trim() || untracked.trim()) {
|
|
275
|
+
results.push({ repoKey, result: 'partial', details: [`blocked: 跨仓 worktree 有未 apply 的交付改动(git diff ${String(meta.baseHash).slice(0, 8)} 非空)——先 apply 或显式 --force`], residual: [wtPath] });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
details.push(`未落地检测失败(${e.message}),按 force 处理`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try { unlinkNodeModulesLinks(wtPath, meta, details) } catch (e) { details.push(`junction 解链失败: ${e.message}`) }
|
|
283
|
+
try {
|
|
284
|
+
git(repoRoot, ['worktree', 'remove', '--force', wtPath], { timeout: 60000 });
|
|
285
|
+
} catch {
|
|
286
|
+
// remove 失败(脏文件/锁):解链后安全删目录 + prune 注册
|
|
287
|
+
try { safeRemoveWorktreeDir(wtPath, meta) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
|
|
288
|
+
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
|
|
289
|
+
}
|
|
290
|
+
details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
|
|
291
|
+
results.push({ repoKey, result: residual.length > 0 ? 'partial' : 'cleaned', details, residual });
|
|
292
|
+
}
|
|
293
|
+
return { results };
|
|
294
|
+
}
|
package/src/worktree-deps.js
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 在 worktree.create() 的 baseline overlay 之后调用,让 worktree 立即可构建/测试。
|
|
5
5
|
* 策略:junction/symlink 快路径(lockfile 一致)+ install 兜底;多语言按 local.yaml
|
|
6
|
-
* project.type +
|
|
6
|
+
* project.type + ECOSYSTEMS 清单表推断 install 命令。供给可失败,但状态写进 meta 供验证硬门读取。
|
|
7
7
|
*
|
|
8
8
|
* 见 change 2026-06-28-worktree-deps-provision / D-005@v1, D-007@v1。
|
|
9
|
+
*
|
|
10
|
+
* 生态中立(坑 deps-ecosystem-hardcode,2026-08-27):本模块所有「按语言判断」一律走下方
|
|
11
|
+
* ECOSYSTEMS 清单表(单一事实源),不再散落 nodejs 特判——此前 lockfileHash 只认 nodejs
|
|
12
|
+
* 清单、missing 只认 node_modules、maven/gradle 无清单基准(pom.xml 改了不触发重供给)、
|
|
13
|
+
* node 校验与 java 校验各说各话。新生态补一行表项即可;表外项目落 generic → n/a 不阻断。
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { existsSync, readFileSync, realpathSync, lstatSync, readdirSync, unlinkSync } from 'fs';
|
|
@@ -18,21 +23,117 @@ const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
|
|
|
18
23
|
const DEFAULT_TIMEOUT_MS = 300 * 1000;
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
* 生态清单表——deps 链路「按语言判断」的单一事实源。每项声明:
|
|
27
|
+
* type: project.type 值(local.yaml 可显式声明覆盖探测)
|
|
28
|
+
* detect: 文件特征探测(表序即优先级;polyglot 目录按先命中者定性,hash 基准与 install
|
|
29
|
+
* 同取该生态——检测与供给/新鲜度始终同生态,不自相矛盾)
|
|
30
|
+
* manifests: 依赖清单文件(按优先级取首个存在者作 freshness 的 hash 基准:清单变 = 依赖集变
|
|
31
|
+
* = stale 触发重供给。nodejs 原以 lockfile/package.json 充当;maven/gradle/python
|
|
32
|
+
* 原先无基准 → pom.xml 改了也不触发,正是「node 校验的和 java 的不一样」的病根)
|
|
33
|
+
* marker: depsStatus=installed/linked 后应存在的 worktree 本地产物(missing 判定)。
|
|
34
|
+
* null = 产物在用户级仓库(maven ~/.m2、gradle ~/.gradle)或安装方式两可
|
|
35
|
+
* (python:uv sync 建 .venv、pip 装进当前环境),无 worktree 内统一标记,
|
|
36
|
+
* freshness 只信 depsStatus + 清单 hash——不重蹈 non-nodejs-missing-misjudge
|
|
37
|
+
* install: 无 commands.install 时的默认命令(null = 无默认,根供给 n/a)
|
|
38
|
+
*/
|
|
39
|
+
const ECOSYSTEMS = [
|
|
40
|
+
{
|
|
41
|
+
type: 'maven',
|
|
42
|
+
manifests: ['pom.xml'],
|
|
43
|
+
marker: null,
|
|
44
|
+
detect: (d) => existsSync(join(d, 'pom.xml')),
|
|
45
|
+
install: () => 'mvn -o test',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'gradle',
|
|
49
|
+
manifests: ['build.gradle.kts', 'build.gradle', 'settings.gradle', 'gradle.lockfile'],
|
|
50
|
+
marker: null,
|
|
51
|
+
detect: (d) => existsSync(join(d, 'build.gradle')) || existsSync(join(d, 'build.gradle.kts')),
|
|
52
|
+
// win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
|
|
53
|
+
install: (d) => (process.platform === 'win32'
|
|
54
|
+
? (existsSync(join(d, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test')
|
|
55
|
+
: './gradlew test'),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: 'nodejs',
|
|
59
|
+
manifests: ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'package.json'],
|
|
60
|
+
marker: 'node_modules',
|
|
61
|
+
// 探测含 LOCKFILES(与 hasNodeMarker 同口径):只有 lockfile 没有 package.json 的目录
|
|
62
|
+
//(子包/残缺 checkout)也是 nodejs——missing 护栏对它们同样生效
|
|
63
|
+
detect: (d) => existsSync(join(d, 'package.json')) || LOCKFILES.some(lf => existsSync(join(d, lf))),
|
|
64
|
+
install: (d) => {
|
|
65
|
+
if (existsSync(join(d, 'pnpm-lock.yaml'))) return 'pnpm install --frozen-lockfile';
|
|
66
|
+
if (existsSync(join(d, 'package-lock.json'))) return 'npm ci';
|
|
67
|
+
if (existsSync(join(d, 'yarn.lock'))) return 'yarn install --frozen-lockfile';
|
|
68
|
+
return 'npm install'; // 无 lockfile 兜底(X-2)
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'python',
|
|
73
|
+
manifests: ['uv.lock', 'poetry.lock', 'requirements.txt', 'pyproject.toml'],
|
|
74
|
+
marker: null,
|
|
75
|
+
detect: (d) => existsSync(join(d, 'pyproject.toml')) || existsSync(join(d, 'requirements.txt')),
|
|
76
|
+
// uv 优先(现代 Python 工具链,pyproject/uv.lock 项目走 uv sync 建 .venv + 装依赖,
|
|
77
|
+
// 与 execute worktree 环境预告一致);纯 requirements.txt(无 pyproject)回退 pip
|
|
78
|
+
install: (d) => (existsSync(join(d, 'uv.lock')) || existsSync(join(d, 'pyproject.toml')) ? 'uv sync' : 'pip install -r requirements.txt'),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: 'go',
|
|
82
|
+
manifests: ['go.mod', 'go.sum'],
|
|
83
|
+
marker: null,
|
|
84
|
+
detect: (d) => existsSync(join(d, 'go.mod')),
|
|
85
|
+
install: () => 'go mod download',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: 'rust',
|
|
89
|
+
manifests: ['Cargo.lock', 'Cargo.toml'],
|
|
90
|
+
marker: null,
|
|
91
|
+
detect: (d) => existsSync(join(d, 'Cargo.toml')),
|
|
92
|
+
install: () => 'cargo fetch --locked',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: 'php',
|
|
96
|
+
manifests: ['composer.lock', 'composer.json'],
|
|
97
|
+
marker: null,
|
|
98
|
+
detect: (d) => existsSync(join(d, 'composer.json')),
|
|
99
|
+
install: () => 'composer install',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'ruby',
|
|
103
|
+
manifests: ['Gemfile.lock', 'Gemfile'],
|
|
104
|
+
marker: null,
|
|
105
|
+
detect: (d) => existsSync(join(d, 'Gemfile')),
|
|
106
|
+
install: () => 'bundle install',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** 目录命中的生态表项(表序优先;无命中 → null = generic)。polyglot 目录取先命中生态。 */
|
|
111
|
+
export function detectEcosystem(dir) {
|
|
112
|
+
if (!dir || !existsSync(dir)) return null;
|
|
113
|
+
for (const eco of ECOSYSTEMS) {
|
|
114
|
+
if (eco.detect(dir)) return eco;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 取目录下首个命中的生态清单文件的 sha256 前 16 位(全生态:pnpm-lock/yarn.lock/pom.xml/
|
|
121
|
+
* go.mod/Cargo.lock/composer.lock/Gemfile.lock…按 ECOSYSTEMS 表序);无任何清单返回 null。
|
|
122
|
+
* 函数名保留 lockfileHash(历史导出名,worktree.js/modules 链接一致性判定等多处消费),
|
|
123
|
+
* 语义自 2026-08-27 起泛化为「依赖清单 hash」——maven 的 pom.xml 等与 nodejs 的 lockfile
|
|
124
|
+
* 同权作 stale/main-drift 基准。polyglot 目录首次泛化后基准可能从 package.json 切到先命中
|
|
125
|
+
* 生态的清单(一次性 stale 重供给,自愈后以新基准续跑)。
|
|
23
126
|
*/
|
|
24
127
|
export function lockfileHash(dir) {
|
|
25
128
|
if (!dir || !existsSync(dir)) return null;
|
|
26
|
-
for (const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
129
|
+
for (const eco of ECOSYSTEMS) {
|
|
130
|
+
for (const mf of eco.manifests) {
|
|
131
|
+
const p = join(dir, mf);
|
|
132
|
+
if (existsSync(p)) {
|
|
133
|
+
return createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 16);
|
|
134
|
+
}
|
|
30
135
|
}
|
|
31
136
|
}
|
|
32
|
-
const pkg = join(dir, 'package.json');
|
|
33
|
-
if (existsSync(pkg)) {
|
|
34
|
-
return createHash('sha256').update(readFileSync(pkg)).digest('hex').slice(0, 16);
|
|
35
|
-
}
|
|
36
137
|
return null;
|
|
37
138
|
}
|
|
38
139
|
|
|
@@ -78,47 +179,24 @@ function extractUserInstall(yamlText) {
|
|
|
78
179
|
return null;
|
|
79
180
|
}
|
|
80
181
|
|
|
81
|
-
/** 从 local.yaml 提取 project.type
|
|
182
|
+
/** 从 local.yaml 提取 project.type;缺失时按 ECOSYSTEMS 表文件特征推断(表序优先) */
|
|
82
183
|
export function detectProjectType(worktreePath, specBase) {
|
|
83
184
|
const yamlText = readLocalYaml(specBase, worktreePath);
|
|
84
185
|
if (yamlText) {
|
|
85
186
|
const m = yamlText.match(/type:\s*(\S+)/);
|
|
86
187
|
if (m && m[1]) return m[1];
|
|
87
188
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (existsSync(join(worktreePath, 'package.json'))) return 'nodejs';
|
|
91
|
-
// Python:pyproject.toml(uv/poetry/pdm)或 requirements.txt(pip)——治 worktree 内 ruff/pre-commit 等
|
|
92
|
-
// 二进制不供给(exec 复盘 3-②:detectProjectType 原无 python 分支,python 项目根被误判 generic → n/a)
|
|
93
|
-
if (existsSync(join(worktreePath, 'pyproject.toml')) || existsSync(join(worktreePath, 'requirements.txt'))) return 'python';
|
|
94
|
-
return 'generic';
|
|
189
|
+
const eco = detectEcosystem(worktreePath);
|
|
190
|
+
return eco ? eco.type : 'generic';
|
|
95
191
|
}
|
|
96
192
|
|
|
97
|
-
/** 按 project.type
|
|
193
|
+
/** 按 project.type(+ 表内 install 探测)推断 install 命令(无 commands.install 时)。
|
|
194
|
+
* 表外 project.type(含 local.yaml 显式声明的自定义值)→ null → 根供给 n/a,
|
|
195
|
+
* 需要执行的装法在 local.yaml commands.install 显式声明(仍过白名单+元字符门)。 */
|
|
98
196
|
export function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
99
197
|
if (userInstall) return userInstall;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (existsSync(join(worktreePath, 'pnpm-lock.yaml'))) return 'pnpm install --frozen-lockfile';
|
|
103
|
-
if (existsSync(join(worktreePath, 'package-lock.json'))) return 'npm ci';
|
|
104
|
-
if (existsSync(join(worktreePath, 'yarn.lock'))) return 'yarn install --frozen-lockfile';
|
|
105
|
-
return 'npm install'; // 无 lockfile 兜底(X-2)
|
|
106
|
-
case 'maven':
|
|
107
|
-
return 'mvn -o test';
|
|
108
|
-
case 'gradle':
|
|
109
|
-
// win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
|
|
110
|
-
if (process.platform === 'win32') {
|
|
111
|
-
return existsSync(join(worktreePath, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test';
|
|
112
|
-
}
|
|
113
|
-
return './gradlew test';
|
|
114
|
-
case 'python':
|
|
115
|
-
// uv 优先(现代 Python 工具链,pyproject.toml/uv.lock 项目走 uv sync 建 .venv + 装依赖,
|
|
116
|
-
// 与 execute worktree 环境预告一致);纯 requirements.txt(无 pyproject)回退 pip
|
|
117
|
-
if (existsSync(join(worktreePath, 'uv.lock')) || existsSync(join(worktreePath, 'pyproject.toml'))) return 'uv sync';
|
|
118
|
-
return 'pip install -r requirements.txt';
|
|
119
|
-
default:
|
|
120
|
-
return null; // generic → n/a
|
|
121
|
-
}
|
|
198
|
+
const eco = ECOSYSTEMS.find(e => e.type === projectType);
|
|
199
|
+
return eco ? eco.install(worktreePath) : null;
|
|
122
200
|
}
|
|
123
201
|
|
|
124
202
|
/** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
|
|
@@ -202,7 +280,7 @@ function tryLink(mainNodeModules, linkPath) {
|
|
|
202
280
|
* 全程 argv 数组执行不经 shell、任一段失败即停(&& 语义)。安全面不变:每个执行段仍是
|
|
203
281
|
* 白名单包管理器命令,`||`/管道/`;`/后台均不支持。
|
|
204
282
|
*/
|
|
205
|
-
const INSTALL_BINARY_WHITELIST = /^(?:\.\/)?(?:pnpm|npm|yarn|bun|mvn|gradle|gradlew|uv|pip|pip3|python|python3|poetry|make)(?:\.cmd|\.bat|\.exe)?(?:\s|$)/;
|
|
283
|
+
const INSTALL_BINARY_WHITELIST = /^(?:\.\/)?(?:pnpm|npm|yarn|bun|mvn|mvnd|gradle|gradlew|uv|pip|pip3|python|python3|poetry|make|go|cargo|composer|bundle|dotnet)(?:\.cmd|\.bat|\.exe)?(?:\s|$)/;
|
|
206
284
|
const SHELL_METACHARS = /[;&|<>$`\n]/;
|
|
207
285
|
const WIN_SHELL_METACHARS = /[;&|<>$`\n%]/;
|
|
208
286
|
const CD_SEGMENT_RE = /^cd\s+([^\s]+)$/;
|
|
@@ -252,8 +330,15 @@ function tryInstall(cmd, cwd, timeout) {
|
|
|
252
330
|
execFileSync(argv[0], argv.slice(1), { cwd: curCwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
253
331
|
}
|
|
254
332
|
} catch (e) {
|
|
255
|
-
const
|
|
256
|
-
|
|
333
|
+
const raw = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
|
|
334
|
+
// 命令找不到 → 追加 PATH 定向指引(坑 deps-tool-not-on-path-failed-loop,2026-08-27 实证):
|
|
335
|
+
// CLI 子进程继承调用方 shell 的 PATH,无 mvn/gradle 等工具链的环境里 install 必败,裸报
|
|
336
|
+
// "'mvn' 不是内部或外部命令" 让人误以为是项目配置问题。指引换环境重跑 doctor --fix。
|
|
337
|
+
const notFoundRe = /is not recognized|不是内部或外部命令|command not found|no such file or directory|ENOENT/i;
|
|
338
|
+
const hint = (!e.killed && notFoundRe.test(raw))
|
|
339
|
+
? `(工具 ${argv[0]} 不在当前 shell 的 PATH——CLI 子进程继承调用方环境。请在含该工具链的环境重跑 sillyspec worktree doctor --fix --change <变更名>,或把工具目录加入 PATH 后重试)`
|
|
340
|
+
: '';
|
|
341
|
+
return { ok: false, error: `${seg} failed (cwd ${curCwd}): ${raw}${hint}` };
|
|
257
342
|
}
|
|
258
343
|
}
|
|
259
344
|
return { ok: true };
|
|
@@ -491,15 +576,21 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
491
576
|
* 统一的 worktree deps 新鲜度判定(H1,change 2026-08-05-tooling-feedback-fixes)。
|
|
492
577
|
* 供 doctor(worktree.js)与 ensureDepsFreshness(run/stage.js)共用,消除两处双写漂移。
|
|
493
578
|
*
|
|
579
|
+
* 生态中立(坑 deps-ecosystem-hardcode,2026-08-27):判定基准全部来自 ECOSYSTEMS 清单表,
|
|
580
|
+
* 各生态同一套语义——不再有「node 一套校验、java 另一套」:
|
|
581
|
+
*
|
|
494
582
|
* 判定优先级:failed → missing → stale → main-drift → fresh
|
|
495
583
|
* - failed: meta.depsStatus==='failed'(上次供给失败,占最高优先级)
|
|
496
|
-
* - missing: depsStatus ∈ {linked, installed}
|
|
497
|
-
*
|
|
498
|
-
*
|
|
584
|
+
* - missing: depsStatus ∈ {linked, installed} 且生态 marker(如 nodejs 的 node_modules)
|
|
585
|
+
* 不存在。仅对声明了 marker 的生态生效(nodejs);maven/gradle/python 等产物在用户级
|
|
586
|
+
* 仓库或安装方式两可(表内 marker=null),只信 depsStatus + 清单 hash
|
|
587
|
+
* - stale: wtHash 与 meta.depsLockHash 不一致(worktree 自身依赖清单在上次供给后变化——
|
|
588
|
+
* nodejs 的 lockfile/package.json、maven 的 pom.xml、go 的 go.mod 等同权)
|
|
589
|
+
* - main-drift: wtHash 与主仓 mainHash 不一致(主仓清单漂移;复用 linkOneDir 的 mismatch 判据)
|
|
499
590
|
* - fresh: 其余情况
|
|
500
591
|
*
|
|
501
|
-
* lockfileHash 返回 null
|
|
502
|
-
*
|
|
592
|
+
* lockfileHash 返回 null(目录内无任何生态清单文件)时相关比较优雅降级——不报 stale /
|
|
593
|
+
* main-drift,避免无清单项目(如纯 generic)误判漂移。
|
|
503
594
|
*
|
|
504
595
|
* @param {object} [meta] - worktree meta(读 depsStatus / depsLockHash / depsError)
|
|
505
596
|
* @param {string} wtPath - worktree 根目录
|
|
@@ -521,12 +612,18 @@ export function checkDepsFreshness(meta, wtPath, mainCwd) {
|
|
|
521
612
|
};
|
|
522
613
|
}
|
|
523
614
|
|
|
524
|
-
// 2. missing:曾 link/install
|
|
525
|
-
|
|
526
|
-
|
|
615
|
+
// 2. missing:曾 link/install 但生态 marker 已丢失(对齐 doctor 914-915 / ensure 403)。
|
|
616
|
+
// marker 来自 ECOSYSTEMS 表:nodejs → node_modules。marker=null 的生态(maven/gradle/python…)
|
|
617
|
+
// 产物在 ~/.m2、~/.gradle 或安装方式两可,worktree 内无统一标记——此前无此门会把这类项目
|
|
618
|
+
// 每次 execute 入口都误判 missing → 重跑 install → 无工具链 PATH 的 shell 里失败 →
|
|
619
|
+
// depsStatus 被打回 failed → deps 门控阻断 --done(坑 non-nodejs-missing-misjudge,2026-08-27
|
|
620
|
+
// maven 项目实证)。
|
|
621
|
+
const eco = detectEcosystem(wtPath);
|
|
622
|
+
const marker = eco ? eco.marker : null;
|
|
623
|
+
if (marker && ['linked', 'installed'].includes(depsStatus) && wtPath && !existsSync(join(wtPath, marker))) {
|
|
527
624
|
return {
|
|
528
625
|
status: 'missing',
|
|
529
|
-
detail: `meta.depsStatus=${depsStatus} 但
|
|
626
|
+
detail: `meta.depsStatus=${depsStatus} 但 ${marker} 缺失(${eco.type} 生态就绪标记)`,
|
|
530
627
|
wtHash, mainHash, metaLockHash,
|
|
531
628
|
};
|
|
532
629
|
}
|
|
@@ -550,20 +647,21 @@ export function checkDepsFreshness(meta, wtPath, mainCwd) {
|
|
|
550
647
|
}
|
|
551
648
|
}
|
|
552
649
|
|
|
553
|
-
// 3. stale:worktree
|
|
650
|
+
// 3. stale:worktree 自身依赖清单与 meta 快照不一致(对齐 doctor 916-917 / ensure 404;
|
|
651
|
+
// 清单=ECOSYSTEMS 表内文件:nodejs lockfile/package.json、maven pom.xml、go go.mod 等同权)
|
|
554
652
|
if (metaLockHash && wtHash && wtHash !== metaLockHash) {
|
|
555
653
|
return {
|
|
556
654
|
status: 'stale',
|
|
557
|
-
detail:
|
|
655
|
+
detail: `依赖清单变化 (${metaLockHash} -> ${wtHash})`,
|
|
558
656
|
wtHash, mainHash, metaLockHash,
|
|
559
657
|
};
|
|
560
658
|
}
|
|
561
659
|
|
|
562
|
-
// 4. main-drift(新增):wtHash 与主仓 mainHash
|
|
660
|
+
// 4. main-drift(新增):wtHash 与主仓 mainHash 不一致——主仓依赖清单更新过、worktree 未跟
|
|
563
661
|
if (wtHash && mainHash && wtHash !== mainHash) {
|
|
564
662
|
return {
|
|
565
663
|
status: 'main-drift',
|
|
566
|
-
detail: `worktree
|
|
664
|
+
detail: `worktree 依赖清单与主仓不一致 (wt=${wtHash} main=${mainHash})`,
|
|
567
665
|
wtHash, mainHash, metaLockHash,
|
|
568
666
|
};
|
|
569
667
|
}
|
|
@@ -26,7 +26,8 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
|
|
|
26
26
|
6. 任意 P0/P1 unresolved/blocking 决策标记为 FAIL blocker
|
|
27
27
|
|
|
28
28
|
**探针 5 补充(CLI 已出对账表,你做诚实判定)**
|
|
29
|
-
CLI 的 missing backend endpoint 是 advisory(不硬阻断归档)——但 contract gap 是真实集成缺陷,应诚实标 FAIL 并回 execute 补端点,勿因 CLI 不拦而放行。后端 router
|
|
29
|
+
CLI 的 missing backend endpoint 是 advisory(不硬阻断归档)——但 contract gap 是真实集成缺陷,应诚实标 FAIL 并回 execute 补端点,勿因 CLI 不拦而放行。后端 router 的端点清单用 `sillyspec endpoints extract --change <变更名> --all-tasks` 聚合提取(逐 task 卡各自落产物,与对账聚合口径一致;勿手扫装饰器、勿只提单 task——单 task 产物对账会放大假 missing)。
|
|
30
|
+
先看对账表 scope 标注:`change-diff`(含 apply-pathspec 兜底)口径可信;若显示 `full-repo`(diff 与 apply 清单都不可得时的兜底),missing 大概率是「全仓调用 × 本变更局部端点」的口径错配噪音——先收窄复跑(`sillyspec verify-probes --change <变更名>`)再判定,不要把口径噪音直接记成 contract gap。
|
|
30
31
|
|
|
31
32
|
**探针 6 补充(CLI 已出三态判定,你做终审)**
|
|
32
33
|
以 git 事实为准(真实 > 声明);CLI 的三态(合规/高风险/未声明)是机械判定,是否构成 FAIL blocker 由你诚实判定,务必如实记录——静默删除代码是 verify 的最大盲区。
|