sillyspec 3.26.1 → 3.26.3

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.
@@ -18,8 +18,9 @@ import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
19
  import { WorktreeManager } from './worktree.js';
20
20
  import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
21
- import { parseAllowedPaths } from './stages/plan-postcheck.js';
21
+ import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
22
22
  import { git, gitQuiet } from './git-helper.js';
23
+ import { resolveLatestExecuteRunId, readReview } from './task-review.js';
23
24
 
24
25
  const CHANGES_REL = '.sillyspec/changes';
25
26
 
@@ -191,36 +192,145 @@ function getBlobHashMap(cwd, treeish, files) {
191
192
  * 两 gate 口径不一致)。union 后两源并集为准;plan 已过 validateDesignFileCoverage 单向校验(design ⊆
192
193
  * plan),union 不会放开 design/plan 之外的任意文件(仍拦完全越界文件)。
193
194
  *
195
+ * **跨仓切片(task-05 / D-009 / design §6 A4)**:返回 `Map<repoKey, Set<path>>`,按 task 卡片
196
+ * `repo:` 字段把 allowed_paths 归属到对应 repo;design §6 清单归属 'main'(清单文件相对主仓根)。
197
+ * 单仓 change(task 卡无 repo:)→ 所有 allowed_paths 归 'main' → `{main: Set}` 退化零回归
198
+ * (GOAL-2)。allowed_paths 基准=各 repo 自身根(跨仓 task 卡片 allowed_paths 相对跨仓仓根,design §7.2)。
199
+ *
200
+ * 主流程(applyWorktree A5 主仓 apply)只消费 main 仓的 Set(主仓 worktree diff 只含主仓文件,
201
+ * 跨仓文件不在主仓 worktree 故永不进 diff);跨仓 task 走 no-op(D-009),其 allowed_paths 仅
202
+ * 作 Map 切片返回,供上游(assess / 跨仓 review 校验)按 repo 取用。
203
+ *
194
204
  * @param {string} projectRoot - 主仓库根
195
205
  * @param {string} changeName - 变更名
196
- * @returns {Set<string>} 并集清单(无 design 清单且无 task 卡片时为空集)
206
+ * @returns {Map<string, Set<string>>} repoKey → 并集清单 Set(无 design 清单且无 task 卡片时为空 Map)
197
207
  */
198
208
  export function resolveApplyAllowSet(projectRoot, changeName) {
199
- const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'), { keepSillyspecDocs: true });
209
+ const repoMap = new Map();
210
+ const getOrCreate = (key) => {
211
+ if (!repoMap.has(key)) repoMap.set(key, new Set());
212
+ return repoMap.get(key);
213
+ };
214
+ // design §6 清单归属 main(清单路径相对主仓根;跨仓 task 的 allowed_paths 由 task 卡片 repo 切片)
215
+ const mainSet = getOrCreate('main');
216
+ parseFileChangeList(
217
+ join(projectRoot, CHANGES_REL, changeName, 'design.md'),
218
+ { keepSillyspecDocs: true }
219
+ ).forEach(p => mainSet.add(p));
200
220
  const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
201
221
  if (existsSync(tasksDir)) {
202
222
  for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
203
- for (const p of parseAllowedPaths(readFileSync(join(tasksDir, tf), 'utf8'))) allowSet.add(p);
223
+ const content = readFileSync(join(tasksDir, tf), 'utf8');
224
+ // task 卡 repo: 字段切片(缺省='main',design §7.2);跨仓 task 的 allowed_paths 相对跨仓仓根。
225
+ const repoKey = parseRepo(content) || 'main';
226
+ const repoSet = getOrCreate(repoKey);
227
+ for (const p of parseAllowedPaths(content)) repoSet.add(p);
228
+ }
229
+ }
230
+ return repoMap;
231
+ }
232
+
233
+ /**
234
+ * 跨仓 task apply = no-op 校验(task-05 / D-009 / design §6 A3 G1)。
235
+ *
236
+ * 跨仓 task 的 commit 已由子代理直接落跨仓仓主干(NG-3),apply 阶段无 patch 可打(跨仓仓无
237
+ * worktree/meta/分支——A5 patch 路径不可复用)。本函数对每个跨仓 repo 的 task review.head
238
+ * 校验是否为该跨仓仓真实 commit(约束①+② 保险,R-05:子代理漏 commit / head 伪造 → 这里拦)。
239
+ *
240
+ * 不调 wm.cleanup(跨仓仓无主仓 worktree 可清——wm.cleanup 只作用主仓 worktree,跨仓仓主干工作区
241
+ * 永不进主仓 worktree 生命周期,无需也无法 cleanup)。这是 D-009 no-op 的关键:跨仓 commit 已落主干,
242
+ * apply 只校验不复用 patch、不 cleanup。
243
+ *
244
+ * review.json 物理在主仓 execute-runs(D-003),含 repo + head 字段(design §7.4)。本函数读主仓
245
+ * execute-runs 取最新 runId 的 reviews,按 review.repo 切片到对应跨仓 entry 校验 head。
246
+ *
247
+ * 无 review.json(execute 未走完 / 单 task --done 中途)→ 记 warning 不阻断(apply 可在 review 落盘前
248
+ * 被调用,如 worktree apply --check-only 预检;真阻断由 Task Review Gate 负责,这里不重复)。
249
+ *
250
+ * @param {object} ctx - MultiRepoContext(design §7.1)
251
+ * @param {string} projectRoot - 主仓根(读 .sillyspec/.runtime/execute-runs)
252
+ * @param {string} changeName
253
+ * @returns {{ errors: string[], warnings: string[], validated: Array<{repo:string, task:string, head:string}> }}
254
+ */
255
+ function validateCrossRepoNoOp(ctx, projectRoot, changeName) {
256
+ const errors = [];
257
+ const warnings = [];
258
+ const validated = [];
259
+ if (!ctx || typeof ctx.hasCrossRepo !== 'function' || !ctx.hasCrossRepo()) {
260
+ return { errors, warnings, validated }; // 单仓退化:无跨仓,no-op 无事可做(零回归)
261
+ }
262
+ // review.json 物理在主仓 .runtime/execute-runs(D-003)。runtimeRoot = <projectRoot>/.sillyspec/.runtime
263
+ const runtimeRoot = join(projectRoot, '.sillyspec', '.runtime');
264
+ const runId = resolveLatestExecuteRunId({ runtimeRoot, changeName });
265
+ if (!runId) {
266
+ // 无 execute run → 无 review 可校验。不阻断(Task Review Gate 负责拦截 review 缺失),
267
+ // 仅 warn 提示跨仓 head 校验被跳过(约束①保险在此场景未触发,但功能不崩)。
268
+ warnings.push(`跨仓 task no-op 校验跳过:未找到 execute runId(${runtimeRoot}),无法读取跨仓 review.head。跨仓 commit 校验交由 Task Review Gate 负责。`);
269
+ return { errors, warnings, validated };
270
+ }
271
+ const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks');
272
+ if (!existsSync(tasksDir)) {
273
+ warnings.push(`跨仓 task no-op 校验跳过:execute run ${runId} 无 tasks/ 目录。`);
274
+ return { errors, warnings, validated };
275
+ }
276
+ // 遍历 ctx.repos,只对跨仓 entry(isMain=false)校验。每 entry 读所有 review.json 取 repo 匹配的 head。
277
+ for (const [repoKey, entry] of ctx.repos) {
278
+ if (entry.isMain) continue; // 主仓走 A5,不在此 no-op
279
+ // 扫 tasks/ 下 review.json,取 repo === repoKey 的 head 校验
280
+ let taskEntries = [];
281
+ try { taskEntries = readdirSync(tasksDir).filter(f => /^task-\d+$/.test(f)); } catch { taskEntries = []; }
282
+ for (const taskId of taskEntries) {
283
+ const reviewPath = join(tasksDir, taskId, 'review.json');
284
+ if (!existsSync(reviewPath)) continue;
285
+ const r = readReview(reviewPath);
286
+ // readReview 走 validateReviewSchema(task-03 扩展 repo 字段后兼容);review 不完整则跳过(不在此拦)
287
+ if (!r.ok || !r.review) continue;
288
+ const reviewRepo = r.review.repo || 'main'; // schemaVersion=1 无 repo 视 main(design §9 兼容)
289
+ if (reviewRepo !== repoKey) continue;
290
+ const head = r.review.head;
291
+ if (!head) continue; // schema 校验已挡缺 head,防御性跳过
292
+ // 校验 head 是该跨仓仓真实 commit:git cat-file -e HEAD~0 / rev-parse 校验存在
293
+ // 用 cat-file -e <head> 校验 commit 存在(rev-parse 对缩写/不存在都非零退出)
294
+ const exists = gitQuiet(entry.gitDir, ['cat-file', '-e', head]);
295
+ if (exists === null) {
296
+ errors.push(
297
+ `跨仓 task no-op 校验失败:task ${taskId}(repo: ${repoKey})的 review.head ${head} ` +
298
+ `不是跨仓仓 ${entry.gitDir} 的真实 commit(git cat-file -e 失败)。` +
299
+ `跨仓 task 的 commit 必须由子代理直接落跨仓仓主干(D-009),apply 为 no-op 不复用 patch,` +
300
+ `故 head 真实性是跨仓改动落地的唯一保险(约束①+②,R-05)。请检查子代理是否漏 commit 或 head 锡点漂移。`
301
+ );
302
+ } else {
303
+ validated.push({ repo: repoKey, task: taskId, head });
304
+ }
204
305
  }
205
306
  }
206
- return allowSet;
307
+ return { errors, warnings, validated };
207
308
  }
208
309
 
209
310
  /**
210
311
  * apply worktree 变更到主工作区
211
312
  *
313
+ * **跨仓 task no-op(task-05 / D-009 / design §6 A3/A5)**:当传 ctx 且 ctx.hasCrossRepo() 时,
314
+ * 跨仓 task 的 commit 已由子代理直接落跨仓仓主干(NG-3 跨仓不经主仓 worktree),apply 阶段对跨仓
315
+ * task = no-op(无 patch 可打,跨仓仓无 worktree/meta/分支——A5 patch 路径不可复用)。只校验跨仓
316
+ * task review.head 是跨仓仓真实 commit(约束①+② 保险,R-05)+ 不调 wm.cleanup(跨仓仓无主仓 worktree
317
+ * 可清,wm.cleanup 只作用于主仓 worktree)。主仓 task 走原 A5 完整 apply 不动(GOAL-2 单仓零回归)。
318
+ *
212
319
  * @param {string} changeName - 变更名
213
- * @param {{ cwd?: string, checkOnly?: boolean }} opts
320
+ * @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object }} opts
321
+ * - ctx:可选 MultiRepoContext(design §7.1)。缺省=单仓退化(仅主仓 apply,零行为变化,GOAL-2)。
322
+ * 提供 ctx 且含跨仓 entry 时,触发跨仓 no-op 校验(校验 review.head 真实 + 不 cleanup 跨仓)。
214
323
  * @returns {{
215
324
  * ok: boolean,
216
325
  * changedFiles: string[],
217
326
  * extraFiles: string[],
218
327
  * hashMismatchFiles: string[],
219
328
  * patchPath: string|null,
220
- * errors: string[]
329
+ * errors: string[],
330
+ * crossRepoValidated?: Array<{ repo: string, head: string }>
221
331
  * }}
222
332
  */
223
- export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false } = {}) {
333
+ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, ctx = null } = {}) {
224
334
  const projectRoot = cwd || process.cwd();
225
335
  const wm = new WorktreeManager({ cwd: projectRoot });
226
336
  const meta = wm.getMeta(changeName);
@@ -234,8 +344,22 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
234
344
  patchPath: null,
235
345
  errors: [],
236
346
  merged: false,
347
+ crossRepoValidated: [],
237
348
  };
238
349
 
350
+ // --- 0. 跨仓 task no-op 校验(task-05 / D-009) ---
351
+ // 跨仓 task 的 commit 已落跨仓仓主干,apply = no-op(无 patch)。仅校验 review.head 是跨仓仓真实
352
+ // commit + 不 cleanup(wm.cleanup 只作用主仓 worktree)。校验失败 → 推 errors 阻断 apply(R-05)。
353
+ // 单仓 ctx 缺省 / 无跨仓 entry → validateCrossRepoNoOp 直接返回空(零回归)。
354
+ if (ctx) {
355
+ const cross = validateCrossRepoNoOp(ctx, projectRoot, changeName);
356
+ for (const e of cross.errors) result.errors.push(e);
357
+ if (cross.warnings.length > 0) result.warnings = (result.warnings || []).concat(cross.warnings);
358
+ result.crossRepoValidated = cross.validated;
359
+ // 跨仓 head 校验失败 = 跨仓改动未真落地,apply 不可继续(即使主仓 patch 能跑,跨仓 task 实际未完成)
360
+ if (cross.errors.length > 0) return result;
361
+ }
362
+
239
363
  // --- 1. 校验 worktree 存在 + meta.json 有效 ---
240
364
  if (!meta) {
241
365
  result.errors.push(`worktree not found: ${changeName}。meta.json 不存在或已损坏。`);
@@ -297,7 +421,11 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
297
421
  }
298
422
 
299
423
  // --- 3. 解析 apply 文件清单(design §6 清单 ∪ plan TaskCard allowed_paths,execute 复盘 c) ---
300
- const allowSet = resolveApplyAllowSet(projectRoot, changeName);
424
+ // resolveApplyAllowSet 返回 Map<repo, Set>(task-05 跨仓切片);主仓 apply 只消费 main 仓 Set
425
+ // (主仓 worktree diff 只含主仓文件,跨仓文件在跨仓仓不进主仓 worktree diff)。跨仓 allowed_paths
426
+ // 仅作 Map 切片返回供上游用,主流程不消费(跨仓 apply=no-op,D-009)。
427
+ const allowMap = resolveApplyAllowSet(projectRoot, changeName);
428
+ const allowSet = allowMap.get('main') || new Set();
301
429
  const hasAllowList = allowSet.size > 0;
302
430
 
303
431
  // --- 3.5 主干已提交推进检测:hashMismatch 前移(Grill P0 修复,design §step 顺序修正)---
@@ -1 +0,0 @@
1
- 2026-08-10T17:08:53.237Z
@@ -1,7 +0,0 @@
1
- {
2
- "specRoot": "C:\\Users\\qinyi\\IdeaProjects\\sillyspec\\.sillyspec",
3
- "runtimeRoot": null,
4
- "workspaceId": null,
5
- "scanRunId": null,
6
- "savedAt": "2026-08-11T04:01:27.160Z"
7
- }