validate-harness.mjs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /**
  2. * 校验 harness 结构本身是否符合约定。
  3. *
  4. * 为什么要有它:文档里写「计划要放 exec-plans/」「同一时间只能有一个 in_progress」,
  5. * 这些都是**口头约定**——违反了没人知道。这个脚本把它变成**机械约束**:
  6. * 违反时退出码非 0,收尾时跑一下就能发现。
  7. *
  8. * 怎么跑(在项目根目录):
  9. * node harness/tools/validate-harness.mjs
  10. *
  11. * 退出码:0 通过;1 有违规项
  12. */
  13. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
  14. import { join, dirname } from 'node:path';
  15. import { fileURLToPath } from 'node:url';
  16. const HERE = dirname(fileURLToPath(import.meta.url));
  17. const HARNESS = join(HERE, '..');
  18. const ROOT = join(HARNESS, '..');
  19. const problems = [];
  20. const warnings = [];
  21. const bad = (msg) => problems.push(msg);
  22. const warn = (msg) => warnings.push(msg);
  23. // ── 1. 必备文件存在 ────────────────────────────────────────────────────
  24. const REQUIRED = [
  25. 'README.md',
  26. 'progress.md',
  27. 'feature_list.json',
  28. 'init.sh',
  29. 'docs/architecture.md',
  30. 'docs/exec-plans/README.md',
  31. 'docs/exec-plans/tech-debt-tracker.md',
  32. 'docs/exec-plans/active',
  33. 'docs/exec-plans/completed',
  34. 'docs/reference/README.md',
  35. 'sops/session-start.md',
  36. 'sops/session-end.md',
  37. 'tools/README.md',
  38. 'tools/validate-harness.mjs',
  39. ];
  40. for (const rel of REQUIRED) {
  41. if (!existsSync(join(HARNESS, rel))) bad(`缺少必备文件/目录:harness/${rel}`);
  42. }
  43. // ── 2. 计划必须在仓库里(踩过的坑:写到 ~/.claude/plans/ 去了)─────────
  44. // 能机械检查的部分:仓库根目录下不该出现游离的计划文件
  45. const STRAY_PATTERNS = [/^plan[-_].*\.md$/i, /^exec-plan.*\.md$/i, /计划.*\.md$/];
  46. for (const name of existsSync(ROOT) ? readdirSync(ROOT) : []) {
  47. if (STRAY_PATTERNS.some((re) => re.test(name))) {
  48. bad(`仓库根目录出现游离的计划文件:${name}(应放进 harness/docs/exec-plans/active/)`);
  49. }
  50. }
  51. // src/ 下也不该有
  52. const srcDir = join(ROOT, 'src');
  53. if (existsSync(srcDir)) {
  54. const walk = (dir, depth = 0) => {
  55. if (depth > 4) return;
  56. for (const name of readdirSync(dir)) {
  57. const p = join(dir, name);
  58. if (statSync(p).isDirectory()) walk(p, depth + 1);
  59. else if (/计划|plan[-_]/i.test(name) && name.endsWith('.md')) {
  60. bad(`src/ 下出现计划文件:${p.replace(ROOT, '')}(应放进 harness/docs/exec-plans/active/)`);
  61. }
  62. }
  63. };
  64. walk(srcDir);
  65. }
  66. // ── 3. feature_list.json ───────────────────────────────────────────────
  67. let features = [];
  68. try {
  69. const raw = JSON.parse(readFileSync(join(HARNESS, 'feature_list.json'), 'utf8'));
  70. features = raw.features || [];
  71. if (!raw.meta) warn('feature_list.json 缺少 meta 段');
  72. const ids = new Set();
  73. for (const f of features) {
  74. if (!f.id) bad('feature_list.json 有条目缺少 id');
  75. else if (ids.has(f.id)) bad(`feature_list.json 有重复 id:${f.id}`);
  76. else ids.add(f.id);
  77. const LEGAL = ['not_started', 'in_progress', 'blocked', 'passing'];
  78. if (!LEGAL.includes(f.status)) bad(`功能 ${f.id} 的 status 非法:${f.status}`);
  79. // 假 passing:passing 必须有 verification + evidence
  80. if (f.status === 'passing') {
  81. if (!Array.isArray(f.verification) || f.verification.length === 0) {
  82. bad(`功能 ${f.id} 是 passing 但没有 verification(假 passing)`);
  83. }
  84. if (!f.evidence || !String(f.evidence).trim()) {
  85. bad(`功能 ${f.id} 是 passing 但没有 evidence(假 passing)`);
  86. }
  87. }
  88. if (f.status === 'blocked' && !String(f.notes || '').trim()) {
  89. bad(`功能 ${f.id} 是 blocked 但没写清阻塞原因(notes 为空)`);
  90. }
  91. }
  92. const inProgress = features.filter((f) => f.status === 'in_progress');
  93. if (inProgress.length > 1) {
  94. bad(`同一时间只能有一个 in_progress,实际有 ${inProgress.length} 个:${inProgress.map((f) => f.id).join(', ')}`);
  95. }
  96. } catch (err) {
  97. bad(`feature_list.json 无法解析:${err.message}`);
  98. }
  99. // ── 4. progress.md 里的会话编号不重复 ──────────────────────────────────
  100. try {
  101. const text = readFileSync(join(HARNESS, 'progress.md'), 'utf8');
  102. const seen = new Map();
  103. for (const m of text.matchAll(/^## Session (\d+)/gm)) {
  104. const n = Number(m[1]);
  105. seen.set(n, (seen.get(n) || 0) + 1);
  106. }
  107. for (const [n, count] of seen) {
  108. if (count > 1) bad(`progress.md 里 Session ${String(n).padStart(3, '0')} 出现了 ${count} 次`);
  109. }
  110. if (!seen.size) warn('progress.md 里没有任何 Session 记录');
  111. } catch (err) {
  112. bad(`progress.md 读取失败:${err.message}`);
  113. }
  114. // ── 5. 计划归档:做完了就该从 active/ 移到 completed/ ──────────────────
  115. // 这条是被用户指出来的:三个计划全堆在 active/,其中两个早就实现了。
  116. // 判据很朴素——**文件自己写的状态**。状态写着已完成却还在 active/ 就是漏归档。
  117. const activeDir = join(HARNESS, 'docs/exec-plans/active');
  118. const completedDir = join(HARNESS, 'docs/exec-plans/completed');
  119. const readStatusLine = (file) => {
  120. const text = readFileSync(file, 'utf8');
  121. const match = text.match(/^>\s*\*\*状态\*\*[::]\s*(.+)$/m);
  122. return match ? match[1].trim() : '';
  123. };
  124. if (existsSync(activeDir)) {
  125. const actives = readdirSync(activeDir).filter((f) => f.endsWith('.md'));
  126. if (!actives.length && features.some((f) => f.status === 'in_progress')) {
  127. warn('有 in_progress 的功能,但 exec-plans/active/ 里没有计划文件——大改动应当先写计划');
  128. }
  129. for (const file of actives) {
  130. const status = readStatusLine(join(activeDir, file));
  131. if (status && /✅|已完成/.test(status)) {
  132. warn(`exec-plans/active/${file} 的状态写着「${status}」却还在 active/ —— 实现完就该移到 completed/`);
  133. }
  134. }
  135. }
  136. // completed/ 里的计划反过来不该再写「进行中」
  137. if (existsSync(completedDir)) {
  138. for (const file of readdirSync(completedDir).filter((f) => f.endsWith('.md'))) {
  139. const status = readStatusLine(join(completedDir, file));
  140. if (status && /🔄|进行中|待拍板|等用户决定/.test(status)) {
  141. warn(`exec-plans/completed/${file} 已归档,但状态仍写着「${status}」—— 状态与位置不一致`);
  142. }
  143. }
  144. }
  145. // ── 输出 ───────────────────────────────────────────────────────────────
  146. console.log('harness 结构校验\n');
  147. if (warnings.length) {
  148. console.log('提醒:');
  149. warnings.forEach((w) => console.log(' ⚠️ ' + w));
  150. console.log('');
  151. }
  152. if (problems.length) {
  153. console.log('违规:');
  154. problems.forEach((p) => console.log(' ❌ ' + p));
  155. console.log(`\n不通过:${problems.length} 项违规`);
  156. process.exit(1);
  157. }
  158. console.log(`✅ 通过(功能 ${features.length} 项,in_progress ${
  159. features.filter((f) => f.status === 'in_progress').length
  160. } 项)`);