sillyspec 3.18.3 → 3.18.5

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.
@@ -134,6 +134,31 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
134
134
  5. 任意 D-xxx@vN 无下游覆盖时标记为 ⚠️ 决策未闭环
135
135
  6. 任意 P0/P1 unresolved/blocking 决策标记为 FAIL blocker
136
136
 
137
+ **探针 5:API Contract Parity Check(跨前后端契约对账)**
138
+ 此探针仅在以下条件满足时执行:
139
+ - 存在 \.sillyspec/.runtime/contract-artifacts/ 目录(说明 execute 阶段生成了 endpoint artifact)
140
+ - 或者项目同时有 backend/ 和 frontend/ 目录
141
+
142
+ 执行步骤:
143
+ 1. 收集所有 provider endpoint artifacts:
144
+ - 读取 .sillyspec/.runtime/contract-artifacts/*/endpoints.json
145
+ - 汇总为 backend 端点清单
146
+ 2. 扫描前端 API 调用:
147
+ - 在 frontend/ 目录中搜索 apiFetch/request/axios/fetch 调用
148
+ - 提取所有 API 路径(归一化动态参数为 {param})
149
+ 3. Diff 对账:
150
+ - 前端调用路径在 backend 端点清单中找不到 → **❌ Missing backend endpoint**(FAIL blocker)
151
+ - backend 端点在前端无调用 → ⚠️ Unused backend endpoint(warning,不阻断)
152
+ 4. 输出对账结果表格:
153
+ \
154
+ \
155
+ | 状态 | 前端调用 | 后端端点 | 文件 |
156
+ |---|---|---|---|
157
+ | ❌ missing | GET /api/ppm/project-plan/{param}/plan-nodes | — | frontend/src/lib/ppm/plan.ts |
158
+ \
159
+ \
160
+ 如果发现 Missing backend endpoint,必须在验证报告中标记为 ❌ contract gap。
161
+
137
162
  ### 探针结果处理
138
163
  - 将四个探针的结果汇总为「探针报告」
139
164
  - 如果探针发现问题(未实现标记、关键词缺失、测试缺失、决策未闭环),在最终验证报告中明确标注
@@ -214,7 +214,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
214
214
  const patchFiles = hasAllowList
215
215
  ? [...allowSet].filter(f => changedFiles.includes(f))
216
216
  : changedFiles;
217
- const fileArgs = patchFiles.map(f => `-- ${f}`).join(' ');
217
+ const fileArgs = patchFiles.length > 0 ? `-- ${patchFiles.join(' ')}` : '';
218
218
 
219
219
  // 创建临时文件
220
220
  const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
@@ -236,7 +236,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
236
236
 
237
237
  // tracked 文件:git diff baseHash
238
238
  if (trackedFiles.length > 0) {
239
- const trackedArgs = trackedFiles.map(f => `-- ${f}`).join(' ');
239
+ const trackedArgs = trackedFiles.length > 0 ? `-- ${trackedFiles.join(' ')}` : '';
240
240
  patchContent += execSync(
241
241
  `git diff --binary ${diffBase} ${trackedArgs}`,
242
242
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
@@ -245,11 +245,12 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
245
245
 
246
246
  // untracked 新文件:git add 到 index,git diff --cached,然后 reset
247
247
  if (untrackedPatchFiles.length > 0) {
248
- const addArgs = untrackedPatchFiles.map(f => `-- ${f}`).join(' ');
248
+ const addArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
249
249
  git(worktreePath, `add ${addArgs}`);
250
250
  try {
251
+ const diffCachedArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
251
252
  patchContent += execSync(
252
- `git diff --binary --cached ${untrackedPatchFiles.map(f => `-- ${f}`).join(' ')}`,
253
+ `git diff --binary --cached ${diffCachedArgs}`,
253
254
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
254
255
  );
255
256
  } finally {
package/src/worktree.js CHANGED
@@ -239,6 +239,8 @@ export class WorktreeManager {
239
239
  }
240
240
 
241
241
  // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
242
+ let syncStatus = 'ok';
243
+ let syncError = null;
242
244
  try {
243
245
  // 先 fetch origin
244
246
  gitQuiet(worktreePath, 'fetch origin');
@@ -262,8 +264,10 @@ export class WorktreeManager {
262
264
  }
263
265
  }
264
266
  }
265
- } catch {
266
- // fetch/merge 失败不影响 worktree 创建,只记录警告
267
+ } catch (e) {
268
+ syncStatus = 'failed';
269
+ syncError = e.message || String(e);
270
+ console.warn(`⚠️ worktree 远程同步失败:${syncError}`);
267
271
  }
268
272
 
269
273
  // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
@@ -290,6 +294,8 @@ export class WorktreeManager {
290
294
  baselineFiles,
291
295
  baselineCommit,
292
296
  baselineHash,
297
+ syncStatus,
298
+ ...(syncError ? { syncError } : {}),
293
299
  };
294
300
 
295
301
  const metaPath = join(worktreePath, META_FILE);
@@ -412,18 +418,22 @@ export class WorktreeManager {
412
418
 
413
419
  /**
414
420
  * 清理 worktree(仅限 SillySpec 创建的临时 worktree)
421
+ * 幂等:重复调用不报错。
422
+ * 三重清理:git worktree 注册 + worktree 目录 + meta 目录。
415
423
  * @param {string} changeName
416
- * @param {{ force?: boolean }} opts - force: 跳过 mode 安检(仅用于 worktree 目录本身)
417
- * @throws {Error} worktree 不存在、不允许删除
418
- * @returns {{ result: 'cleaned'|'skipped'|'kept', mode: string }}
424
+ * @param {{ force?: boolean, maxRetries?: number }} opts
425
+ * @returns {{ result: 'cleaned'|'force-cleaned'|'skipped'|'kept', mode: string|null, details: string[] }}
419
426
  */
420
- cleanup(changeName, { force = false } = {}) {
427
+ cleanup(changeName, { force = false, maxRetries = 3 } = {}) {
421
428
  const name = validateChangeName(changeName);
422
429
  const meta = this.getMeta(name);
423
430
  const worktreePath = this.getWorktreePath(name);
431
+ const metaDir = join(this.worktreeBase, name);
432
+ const details = [];
424
433
 
425
- if (!meta && !existsSync(worktreePath)) {
426
- return { result: 'skipped', mode: null };
434
+ // 幂等:什么都不存在 直接跳过
435
+ if (!meta && !existsSync(worktreePath) && !existsSync(metaDir)) {
436
+ return { result: 'skipped', mode: null, details };
427
437
  }
428
438
 
429
439
  const mode = meta?.mode || 'worktree';
@@ -431,50 +441,269 @@ export class WorktreeManager {
431
441
  // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
432
442
  if (!force) {
433
443
  if (mode === 'native-worktree') {
434
- throw new Error(
435
- `当前 worktree 是外部/原生隔离环境(mode: native-worktree),SillySpec 不允许删除。\n` +
436
- `此 worktree 不是由 SillySpec 创建的,请手动管理。\n` +
437
- `如需强制清理,使用 --force 标志。`
438
- );
444
+ return { result: 'kept', mode, details: ['native-worktree: 外部隔离环境,跳过清理'] };
439
445
  }
440
446
  if (mode === 'in-place-fallback') {
441
- return { result: 'skipped', mode };
447
+ return { result: 'skipped', mode, details: ['in-place-fallback: 无隔离目录,跳过清理'] };
442
448
  }
443
449
  }
444
450
 
445
- // 1. 尝试 git worktree remove
446
- let gitRemoveOk = true;
447
- try {
448
- git(this.cwd, `worktree remove ${worktreePath} --force`);
449
- } catch (e) {
450
- gitRemoveOk = false;
451
- }
452
451
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
453
452
 
454
- // 2. 确保目录已删除
455
- try {
456
- if (existsSync(worktreePath)) {
453
+ // 1. git worktree remove(带 retry)
454
+ let gitRemoveOk = false;
455
+ if (existsSync(worktreePath)) {
456
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
457
+ try {
458
+ git(this.cwd, `worktree remove ${worktreePath} --force`);
459
+ gitRemoveOk = true;
460
+ details.push(`git worktree remove succeeded (attempt ${attempt})`);
461
+ break;
462
+ } catch (e) {
463
+ details.push(`git worktree remove attempt ${attempt}/${maxRetries} failed: ${e.message}`);
464
+ if (attempt < maxRetries) {
465
+ // 短暂等待后重试
466
+ execSync('sleep 0.5', { stdio: 'pipe' });
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // 2. fallback: 确保 worktree 目录已删除
473
+ if (existsSync(worktreePath)) {
474
+ try {
457
475
  rmSync(worktreePath, { recursive: true, force: true });
476
+ details.push('worktree directory force-removed (fallback)');
477
+ } catch (e) {
478
+ details.push(`worktree directory force-remove failed: ${e.message}`);
458
479
  }
459
- } catch (e) {
460
- throw new Error(`清理 worktree 目录失败: ${e.message}`);
461
480
  }
462
481
 
463
- // 3. 删除分支(忽略分支不存在的错误)
464
- gitQuiet(this.cwd, `branch -D ${branch}`);
482
+ // 3. git worktree prune(清理 git 内部注册信息)
483
+ try {
484
+ gitQuiet(this.cwd, 'worktree prune');
485
+ } catch {
486
+ // prune 失败不阻断
487
+ }
465
488
 
466
- // 4. 确保目录已删除
467
- if (existsSync(worktreePath)) {
468
- rmSync(worktreePath, { recursive: true, force: true });
489
+ // 4. 删除分支(忽略分支不存在的错误)
490
+ try {
491
+ gitQuiet(this.cwd, `branch -D ${branch}`);
492
+ details.push('branch deleted');
493
+ } catch {
494
+ // 分支可能已被删除,幂等跳过
469
495
  }
470
496
 
471
- // 5. 清除 meta 目录(如果 worktree 目录在 worktreeBase 下)
472
- const metaDir = join(this.worktreeBase, name);
497
+ // 5. 清除 meta 目录
473
498
  if (existsSync(metaDir)) {
474
- rmSync(metaDir, { recursive: true, force: true });
499
+ try {
500
+ rmSync(metaDir, { recursive: true, force: true });
501
+ details.push('meta directory cleaned');
502
+ } catch (e) {
503
+ details.push(`meta directory cleanup failed: ${e.message}`);
504
+ }
505
+ }
506
+
507
+ // 6. 最终验证:确认三重清理完成
508
+ const residual = [];
509
+ if (existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
510
+ if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
511
+ if (gitQuiet(this.cwd, `worktree list`)?.includes(worktreePath)) {
512
+ residual.push('git worktree list still references this worktree');
513
+ }
514
+ if (residual.length > 0) {
515
+ details.push(`⚠️ 残留: ${residual.join('; ')}`);
516
+ }
517
+
518
+ return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode, details };
519
+ }
520
+
521
+ /**
522
+ * worktree 健康检查 + 可选修复
523
+ * 检查项:
524
+ * - git worktree list 中的孤儿条目(目录不存在)
525
+ * - worktree 目录存在但 git 不认识
526
+ * - meta 存在但 worktree 目录不存在
527
+ * - worktree 目录存在但 meta 不存在(幽灵目录)
528
+ * - SillySpec 分支残留(sillyspec/* 但无对应 meta)
529
+ * - 超过指定小时的过期 worktree
530
+ *
531
+ * @param {{ fix?: boolean, staleHours?: number }} opts
532
+ * @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
533
+ */
534
+ doctor({ fix = false, staleHours = 24 } = {}) {
535
+ const issues = [];
536
+ const fixed = [];
537
+ const unfixable = [];
538
+
539
+ // 1. 列出 git worktree list 中的条目
540
+ let gitWorktreeList = [];
541
+ try {
542
+ const raw = execSync(`git worktree list --porcelain`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
543
+ const entries = raw.split(/\n\n/).filter(Boolean);
544
+ for (const entry of entries) {
545
+ const lines = entry.split('\n');
546
+ const wtPath = lines.find(l => l.startsWith('worktree '))?.replace('worktree ', '');
547
+ if (wtPath && wtPath !== this.cwd) { // 排除主工作区
548
+ gitWorktreeList.push({ path: wtPath, raw: entry });
549
+ }
550
+ }
551
+ } catch {
552
+ // git worktree 不可用,跳过
553
+ }
554
+
555
+ // 2. 列出 SillySpec meta 条目
556
+ const metaEntries = this.list();
557
+ const metaNames = new Set(metaEntries.map(m => m.changeName));
558
+
559
+ // 3. 检查 git worktree list 中的孤儿条目
560
+ for (const wt of gitWorktreeList) {
561
+ if (!existsSync(wt.path)) {
562
+ const name = this._pathToChangeName(wt.path);
563
+ issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 引用存在但目录不存在: ${wt.path}`, fixable: true });
564
+ if (fix) {
565
+ try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
566
+ }
567
+ }
568
+ }
569
+
570
+ // 4. 扫描 worktreeBase 目录,检查幽灵目录和孤儿 meta
571
+ if (existsSync(this.worktreeBase)) {
572
+ const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
573
+ for (const entry of entries) {
574
+ if (!entry.isDirectory()) continue;
575
+ const name = entry.name;
576
+ const dirPath = join(this.worktreeBase, name);
577
+ const hasMeta = existsSync(join(dirPath, META_FILE));
578
+ const meta = hasMeta ? this.getMeta(name) : null;
579
+
580
+ // meta 存在但 worktree 目录不存在
581
+ if (meta && meta.worktreePath && !existsSync(meta.worktreePath)) {
582
+ issues.push({ type: 'meta-no-dir', name, detail: `meta 存在但 worktree 目录不存在: ${meta.worktreePath}`, fixable: true });
583
+ if (fix) {
584
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`cleaned orphan meta: ${name}`); } catch { unfixable.push(`cleanup failed for: ${name}`); }
585
+ }
586
+ }
587
+
588
+ // worktree 目录存在但 meta 不存在(幽灵目录)
589
+ if (!hasMeta && existsSync(dirPath)) {
590
+ // 可能是 in-place 模式的 meta-only 目录,或者真正的幽灵
591
+ const files = readdirSync(dirPath);
592
+ if (files.length === 0 || (files.length === 1 && files[0] === META_FILE)) {
593
+ issues.push({ type: 'ghost-dir', name, detail: `空目录/幽灵目录: ${dirPath}`, fixable: true });
594
+ if (fix) {
595
+ try { rmSync(dirPath, { recursive: true, force: true }); fixed.push(`removed ghost dir: ${name}`); } catch { unfixable.push(`remove failed for: ${name}`); }
596
+ }
597
+ } else {
598
+ issues.push({ type: 'ghost-dir-with-files', name, detail: `目录存在但无 meta.json: ${dirPath} (含 ${files.length} 文件)`, fixable: false });
599
+ }
600
+ }
601
+
602
+ // 检查过期 worktree
603
+ if (meta && meta.createdAt) {
604
+ const ageMs = Date.now() - new Date(meta.createdAt).getTime();
605
+ const ageHours = ageMs / (1000 * 60 * 60);
606
+ if (ageHours > staleHours) {
607
+ issues.push({ type: 'stale', name, detail: `worktree 已存在 ${Math.round(ageHours)} 小时(超过 ${staleHours}h 阈值)`, fixable: true });
608
+ if (fix && meta.mode !== 'native-worktree') {
609
+ try {
610
+ const result = this.cleanup(name);
611
+ if (result.result === 'cleaned' || result.result === 'force-cleaned') {
612
+ fixed.push(`cleaned stale: ${name}`);
613
+ } else {
614
+ unfixable.push(`cleanup skipped: ${name}`);
615
+ }
616
+ } catch { unfixable.push(`cleanup failed: ${name}`); }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ // 5. 检查 SillySpec 分支残留
624
+ try {
625
+ const branches = execSync(`git branch --list '${BRANCH_PREFIX}*'`, { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
626
+ if (branches) {
627
+ for (const line of branches.split('\n').filter(Boolean)) {
628
+ const branch = line.replace(/^\*?\s+/, '').trim();
629
+ const name = branch.replace(BRANCH_PREFIX, '');
630
+ if (!metaNames.has(name)) {
631
+ issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
632
+ if (fix) {
633
+ try { gitQuiet(this.cwd, `branch -D ${branch}`); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
634
+ }
635
+ }
636
+ }
637
+ }
638
+ } catch {}
639
+
640
+ return { issues, fixed, unfixable };
641
+ }
642
+
643
+ /**
644
+ * 检查 worktree 是否有未 apply 到主工作区的变更
645
+ * @param {string} changeName
646
+ * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
647
+ */
648
+ hasUnappliedChanges(changeName) {
649
+ const name = validateChangeName(changeName);
650
+ const meta = this.getMeta(name);
651
+ if (!meta) return { hasChanges: false, changedFiles: [], reason: 'no meta' };
652
+
653
+ const worktreePath = meta.worktreePath;
654
+ if (!worktreePath || !existsSync(worktreePath)) {
655
+ return { hasChanges: false, changedFiles: [], reason: 'worktree dir not found' };
656
+ }
657
+
658
+ // in-place 模式没有隔离目录,不算有未 apply 的变更
659
+ if (meta.mode === 'in-place-fallback') {
660
+ return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
661
+ }
662
+
663
+ const diffBase = meta.baselineCommit || meta.baseHash;
664
+ if (!diffBase) {
665
+ return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
666
+ }
667
+
668
+ try {
669
+ // tracked 文件变更
670
+ const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
671
+ const statusFiles = new Set();
672
+ if (statusRaw) {
673
+ for (const line of statusRaw.split('\n').filter(Boolean)) {
674
+ const parts = line.split('\t');
675
+ if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
676
+ if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
677
+ }
678
+ }
679
+
680
+ // untracked 文件
681
+ const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
682
+ const untrackedFiles = untrackedRaw
683
+ ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
684
+ : [];
685
+
686
+ const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
687
+ return { hasChanges: changedFiles.length > 0, changedFiles };
688
+ } catch (e) {
689
+ // 检测失败时保守处理:视为有变更
690
+ return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
475
691
  }
692
+ }
476
693
 
477
- return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode };
694
+ /**
695
+ * 从 worktree 路径反推 changeName
696
+ * @private
697
+ */
698
+ _pathToChangeName(wtPath) {
699
+ try {
700
+ const resolved = resolve(wtPath);
701
+ const baseResolved = resolve(this.worktreeBase);
702
+ if (resolved.startsWith(baseResolved + '/')) {
703
+ return resolved.slice(baseResolved.length + 1);
704
+ }
705
+ } catch {}
706
+ return null;
478
707
  }
479
708
 
480
709
  /**