sillyspec 3.18.2 → 3.18.4

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.
Files changed (40) hide show
  1. package/docs/brainstorm-plan-contract.md +64 -0
  2. package/docs/plan-execute-contract.md +123 -0
  3. package/docs/revision-mode.md +115 -0
  4. package/docs/sillyspec/file-lifecycle.md +13 -4
  5. package/docs/workflow-contract-regression.md +106 -0
  6. package/package.json +1 -1
  7. package/packages/dashboard/dist/assets/{index-DpLHK4jv.js → index-Bq_Z2hne.js} +568 -568
  8. package/packages/dashboard/dist/assets/{index-BcM2J-hv.css → index-O2W5RV4z.css} +1 -1
  9. package/packages/dashboard/dist/index.html +16 -16
  10. package/packages/dashboard/src/components/PipelineStage.vue +22 -2
  11. package/packages/dashboard/src/components/PipelineView.vue +10 -2
  12. package/packages/dashboard/src/components/StageBadge.vue +17 -3
  13. package/packages/dashboard/src/components/StepCard.vue +7 -2
  14. package/src/change-risk-profile.js +167 -0
  15. package/src/contract-matrix.js +278 -0
  16. package/src/db.js +6 -0
  17. package/src/endpoint-extractor.js +315 -0
  18. package/src/index.js +53 -6
  19. package/src/init.js +31 -4
  20. package/src/knowledge-match.js +130 -0
  21. package/src/progress.js +464 -11
  22. package/src/run.js +287 -7
  23. package/src/scan-postcheck.js +34 -2
  24. package/src/stage-contract.js +86 -6
  25. package/src/stages/brainstorm.js +23 -0
  26. package/src/stages/execute.js +158 -4
  27. package/src/stages/plan.js +82 -0
  28. package/src/stages/scan.js +40 -0
  29. package/src/stages/verify.js +63 -2
  30. package/src/worktree.js +264 -35
  31. package/test/brainstorm-plan-contract.test.mjs +273 -0
  32. package/test/contract-artifacts.test.mjs +323 -0
  33. package/test/knowledge-match.test.mjs +231 -0
  34. package/test/plan-execute-contract.test.mjs +330 -0
  35. package/test/platform-failure-samples.test.mjs +4 -0
  36. package/test/revision-v1.test.mjs +1145 -0
  37. package/test/scan-knowledge.test.mjs +175 -0
  38. package/test/scan-postcheck.test.mjs +3 -0
  39. package/test/spec-dir.test.mjs +8 -3
  40. package/test/stage-definitions.test.mjs +1 -1
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
  /**
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Brainstorm → Plan Contract v1 测试
3
+ *
4
+ * 验证 design.md 到 plan 的输入契约:
5
+ * 1. 合法 design 通过
6
+ * 2. 缺关键章节失败
7
+ * 3. warning 不阻断
8
+ */
9
+ import { validateDesignForPlan } from '../src/stages/plan.js'
10
+
11
+ let failed = 0
12
+ const failures = []
13
+
14
+ function assert(condition, msg) {
15
+ if (!condition) {
16
+ failed++
17
+ failures.push(msg)
18
+ console.log(` ❌ FAIL: ${msg}`)
19
+ } else {
20
+ console.log(` ✅ PASS: ${msg}`)
21
+ }
22
+ }
23
+
24
+ console.log('=== Brainstorm → Plan Contract v1 测试 ===\n')
25
+
26
+ // ─────────────────────────────────────────
27
+ // Case 1: valid design 通过
28
+ // ─────────────────────────────────────────
29
+ console.log('--- Case 1: valid design 通过 ---')
30
+ {
31
+ const design = `# Design: 用户认证系统
32
+
33
+ ## 背景
34
+ 需要实现用户认证。
35
+
36
+ ## 设计目标
37
+ - 支持 OAuth2
38
+ - 支持手机号登录
39
+
40
+ ## 非目标
41
+ - 不做 SSO
42
+
43
+ ## 总体方案
44
+ 使用 JWT + Refresh Token。
45
+
46
+ ## 决策
47
+ - D-001@v1: 选择 JWT 而非 Session
48
+
49
+ ## 约束
50
+ - 必须兼容现有 API
51
+
52
+ ## 文件变更清单
53
+ | 操作 | 文件路径 | 说明 |
54
+ |------|---------|------|
55
+ | 新增 | src/auth.js | 认证模块 |
56
+ `
57
+ const result = validateDesignForPlan(design)
58
+ assert(result.ok, '完整 design 应校验通过')
59
+ assert(result.errors.length === 0, '不应有 errors')
60
+ assert(result.warnings.length === 0, '不应有 warnings')
61
+ }
62
+
63
+ // ─────────────────────────────────────────
64
+ // Case 2: empty design 失败
65
+ // ─────────────────────────────────────────
66
+ console.log('\n--- Case 2: empty design 失败 ---')
67
+ {
68
+ assert(!validateDesignForPlan('').ok, '空字符串应失败')
69
+ assert(!validateDesignForPlan(null).ok, 'null 应失败')
70
+ assert(!validateDesignForPlan(' ').ok, '纯空格应失败')
71
+ }
72
+
73
+ // ─────────────────────────────────────────
74
+ // Case 3: missing goal 失败
75
+ // ─────────────────────────────────────────
76
+ console.log('\n--- Case 3: missing goal/背景 失败 ---')
77
+ {
78
+ const design = `# Design
79
+
80
+ ## 总体方案
81
+ 用 JWT。
82
+
83
+ ## 决策
84
+ D-001@v1: 选 JWT
85
+ `
86
+ const result = validateDesignForPlan(design)
87
+ assert(!result.ok, '缺目标/背景应失败')
88
+ assert(result.errors.some(e => e.includes('目标') || e.includes('背景')), '错误应提到目标/背景')
89
+ }
90
+
91
+ // ─────────────────────────────────────────
92
+ // Case 4: missing scope/方案 失败
93
+ // ─────────────────────────────────────────
94
+ console.log('\n--- Case 4: missing scope/方案 失败 ---')
95
+ {
96
+ const design = `# Design
97
+
98
+ ## 背景
99
+ 需要认证。
100
+
101
+ ## 决策
102
+ D-001@v1: 选 JWT
103
+ `
104
+ const result = validateDesignForPlan(design)
105
+ assert(!result.ok, '缺范围/方案应失败')
106
+ assert(result.errors.some(e => e.includes('范围') || e.includes('方案')), '错误应提到范围/方案')
107
+ }
108
+
109
+ // ─────────────────────────────────────────
110
+ // Case 5: missing decisions 失败
111
+ // ─────────────────────────────────────────
112
+ console.log('\n--- Case 5: missing decisions 失败 ---')
113
+ {
114
+ const design = `# Design
115
+
116
+ ## 背景
117
+ 需要认证。
118
+
119
+ ## 总体方案
120
+ 用 JWT。
121
+ `
122
+ const result = validateDesignForPlan(design)
123
+ assert(!result.ok, '缺决策应失败')
124
+ assert(result.errors.some(e => e.includes('决策')), '错误应提到决策')
125
+ }
126
+
127
+ // ─────────────────────────────────────────
128
+ // Case 6: decisions.md 引用也算决策
129
+ // ─────────────────────────────────────────
130
+ console.log('\n--- Case 6: decisions.md 引用算决策 ---')
131
+ {
132
+ const design = `# Design
133
+
134
+ ## 背景
135
+ 需要认证。
136
+
137
+ ## 总体方案
138
+ 用 JWT。详见 decisions.md。
139
+
140
+ ## 文件变更清单
141
+ | 操作 | 文件 | 说明 |
142
+ `
143
+ const result = validateDesignForPlan(design)
144
+ assert(result.ok, 'decisions.md 引用应满足决策检查')
145
+ }
146
+
147
+ // ─────────────────────────────────────────
148
+ // Case 7: missing non-goals 只有 warning
149
+ // ─────────────────────────────────────────
150
+ console.log('\n--- Case 7: missing non-goals warning ---')
151
+ {
152
+ const design = `# Design
153
+
154
+ ## 背景
155
+ 需要认证。
156
+
157
+ ## 总体方案
158
+ 用 JWT。
159
+
160
+ ## 决策
161
+ D-001@v1: 选 JWT
162
+
163
+ ## 约束
164
+ 必须兼容现有 API。
165
+ `
166
+ const result = validateDesignForPlan(design)
167
+ assert(result.ok, '缺非目标不应阻断')
168
+ assert(result.warnings.some(w => w.includes('非目标') || w.includes('Non-goals')), '应有非目标 warning')
169
+ }
170
+
171
+ // ─────────────────────────────────────────
172
+ // Case 8: missing constraints 只有 warning
173
+ // ─────────────────────────────────────────
174
+ console.log('\n--- Case 8: missing constraints warning ---')
175
+ {
176
+ const design = `# Design
177
+
178
+ ## 目标
179
+ 实现认证。
180
+
181
+ ## 设计方案
182
+ 用 JWT。
183
+
184
+ ## 决策
185
+ 选择 JWT。
186
+
187
+ ## 非目标
188
+ 不做 SSO。
189
+ `
190
+ const result = validateDesignForPlan(design)
191
+ assert(result.ok, '缺约束不应阻断')
192
+ assert(result.warnings.some(w => w.includes('约束') || w.includes('风险') || w.includes('Trade-off')), '应有约束/风险 warning')
193
+ }
194
+
195
+ // ─────────────────────────────────────────
196
+ // Case 9: missing 文件变更清单 warning
197
+ // ─────────────────────────────────────────
198
+ console.log('\n--- Case 9: missing 文件变更清单 warning ---')
199
+ {
200
+ const design = `# Design
201
+
202
+ ## 背景
203
+ 需要认证。
204
+
205
+ ## 总体方案
206
+ 用 JWT。
207
+
208
+ ## 决策
209
+ D-001@v1: 选 JWT
210
+ `
211
+ const result = validateDesignForPlan(design)
212
+ assert(result.ok, '缺文件变更清单不应阻断')
213
+ assert(result.warnings.some(w => w.includes('文件变更')), '应有文件变更 warning')
214
+ }
215
+
216
+ // ─────────────────────────────────────────
217
+ // Case 10: 英文 design 通过
218
+ // ─────────────────────────────────────────
219
+ console.log('\n--- Case 10: 英文 design 通过 ---')
220
+ {
221
+ const design = `# Design: Auth System
222
+
223
+ ## Background
224
+ We need authentication.
225
+
226
+ ## Solution
227
+ Use JWT + Refresh Token.
228
+
229
+ ## Decision
230
+ D-001@v1: Choose JWT over Session
231
+
232
+ ## Non-goals
233
+ No SSO.
234
+
235
+ ## Constraints
236
+ Must be backwards compatible.
237
+ `
238
+ const result = validateDesignForPlan(design)
239
+ assert(result.ok, '英文 design 应校验通过')
240
+ assert(result.errors.length === 0, '不应有 errors')
241
+ }
242
+
243
+ // ─────────────────────────────────────────
244
+ // Case 11: 最小合法 design
245
+ // ─────────────────────────────────────────
246
+ console.log('\n--- Case 11: 最小合法 design ---')
247
+ {
248
+ const design = `# Design
249
+
250
+ ## 目标
251
+ 修 bug。
252
+
253
+ ## 方案
254
+ 改代码。
255
+
256
+ ## 决策
257
+ D-001@v1: 直接改。
258
+ `
259
+ const result = validateDesignForPlan(design)
260
+ assert(result.ok, '最小合法 design 应通过')
261
+ // 可能有 warning 但 ok
262
+ }
263
+
264
+ // ── 结果 ──
265
+ console.log(`\n${'='.repeat(50)}`)
266
+ console.log(`✅ 通过: ${11 - failed} ❌ 失败: ${failed}`)
267
+ if (failures.length > 0) {
268
+ console.log(`失败项:`)
269
+ failures.forEach(f => console.log(` - ${f}`))
270
+ }
271
+ console.log(`${'='.repeat(50)}`)
272
+
273
+ if (failed > 0) process.exit(1)