/** * 校验 harness 结构本身是否符合约定。 * * 为什么要有它:文档里写「计划要放 exec-plans/」「同一时间只能有一个 in_progress」, * 这些都是**口头约定**——违反了没人知道。这个脚本把它变成**机械约束**: * 违反时退出码非 0,收尾时跑一下就能发现。 * * 怎么跑(在项目根目录): * node harness/tools/validate-harness.mjs * * 退出码:0 通过;1 有违规项 */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const HARNESS = join(HERE, '..'); const ROOT = join(HARNESS, '..'); const problems = []; const warnings = []; const bad = (msg) => problems.push(msg); const warn = (msg) => warnings.push(msg); // ── 1. 必备文件存在 ──────────────────────────────────────────────────── const REQUIRED = [ 'README.md', 'progress.md', 'feature_list.json', 'init.sh', 'docs/architecture.md', 'docs/exec-plans/README.md', 'docs/exec-plans/tech-debt-tracker.md', 'docs/exec-plans/active', 'docs/exec-plans/completed', 'docs/reference/README.md', 'sops/session-start.md', 'sops/session-end.md', 'tools/README.md', 'tools/validate-harness.mjs', ]; for (const rel of REQUIRED) { if (!existsSync(join(HARNESS, rel))) bad(`缺少必备文件/目录:harness/${rel}`); } // ── 2. 计划必须在仓库里(踩过的坑:写到 ~/.claude/plans/ 去了)───────── // 能机械检查的部分:仓库根目录下不该出现游离的计划文件 const STRAY_PATTERNS = [/^plan[-_].*\.md$/i, /^exec-plan.*\.md$/i, /计划.*\.md$/]; for (const name of existsSync(ROOT) ? readdirSync(ROOT) : []) { if (STRAY_PATTERNS.some((re) => re.test(name))) { bad(`仓库根目录出现游离的计划文件:${name}(应放进 harness/docs/exec-plans/active/)`); } } // src/ 下也不该有 const srcDir = join(ROOT, 'src'); if (existsSync(srcDir)) { const walk = (dir, depth = 0) => { if (depth > 4) return; for (const name of readdirSync(dir)) { const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, depth + 1); else if (/计划|plan[-_]/i.test(name) && name.endsWith('.md')) { bad(`src/ 下出现计划文件:${p.replace(ROOT, '')}(应放进 harness/docs/exec-plans/active/)`); } } }; walk(srcDir); } // ── 3. feature_list.json ─────────────────────────────────────────────── let features = []; try { const raw = JSON.parse(readFileSync(join(HARNESS, 'feature_list.json'), 'utf8')); features = raw.features || []; if (!raw.meta) warn('feature_list.json 缺少 meta 段'); const ids = new Set(); for (const f of features) { if (!f.id) bad('feature_list.json 有条目缺少 id'); else if (ids.has(f.id)) bad(`feature_list.json 有重复 id:${f.id}`); else ids.add(f.id); const LEGAL = ['not_started', 'in_progress', 'blocked', 'passing']; if (!LEGAL.includes(f.status)) bad(`功能 ${f.id} 的 status 非法:${f.status}`); // 假 passing:passing 必须有 verification + evidence if (f.status === 'passing') { if (!Array.isArray(f.verification) || f.verification.length === 0) { bad(`功能 ${f.id} 是 passing 但没有 verification(假 passing)`); } if (!f.evidence || !String(f.evidence).trim()) { bad(`功能 ${f.id} 是 passing 但没有 evidence(假 passing)`); } } if (f.status === 'blocked' && !String(f.notes || '').trim()) { bad(`功能 ${f.id} 是 blocked 但没写清阻塞原因(notes 为空)`); } } const inProgress = features.filter((f) => f.status === 'in_progress'); if (inProgress.length > 1) { bad(`同一时间只能有一个 in_progress,实际有 ${inProgress.length} 个:${inProgress.map((f) => f.id).join(', ')}`); } } catch (err) { bad(`feature_list.json 无法解析:${err.message}`); } // ── 4. progress.md 里的会话编号不重复 ────────────────────────────────── try { const text = readFileSync(join(HARNESS, 'progress.md'), 'utf8'); const seen = new Map(); for (const m of text.matchAll(/^## Session (\d+)/gm)) { const n = Number(m[1]); seen.set(n, (seen.get(n) || 0) + 1); } for (const [n, count] of seen) { if (count > 1) bad(`progress.md 里 Session ${String(n).padStart(3, '0')} 出现了 ${count} 次`); } if (!seen.size) warn('progress.md 里没有任何 Session 记录'); } catch (err) { bad(`progress.md 读取失败:${err.message}`); } // ── 5. 计划归档:做完了就该从 active/ 移到 completed/ ────────────────── // 这条是被用户指出来的:三个计划全堆在 active/,其中两个早就实现了。 // 判据很朴素——**文件自己写的状态**。状态写着已完成却还在 active/ 就是漏归档。 const activeDir = join(HARNESS, 'docs/exec-plans/active'); const completedDir = join(HARNESS, 'docs/exec-plans/completed'); const readStatusLine = (file) => { const text = readFileSync(file, 'utf8'); const match = text.match(/^>\s*\*\*状态\*\*[::]\s*(.+)$/m); return match ? match[1].trim() : ''; }; if (existsSync(activeDir)) { const actives = readdirSync(activeDir).filter((f) => f.endsWith('.md')); if (!actives.length && features.some((f) => f.status === 'in_progress')) { warn('有 in_progress 的功能,但 exec-plans/active/ 里没有计划文件——大改动应当先写计划'); } for (const file of actives) { const status = readStatusLine(join(activeDir, file)); if (status && /✅|已完成/.test(status)) { warn(`exec-plans/active/${file} 的状态写着「${status}」却还在 active/ —— 实现完就该移到 completed/`); } } } // completed/ 里的计划反过来不该再写「进行中」 if (existsSync(completedDir)) { for (const file of readdirSync(completedDir).filter((f) => f.endsWith('.md'))) { const status = readStatusLine(join(completedDir, file)); if (status && /🔄|进行中|待拍板|等用户决定/.test(status)) { warn(`exec-plans/completed/${file} 已归档,但状态仍写着「${status}」—— 状态与位置不一致`); } } } // ── 输出 ─────────────────────────────────────────────────────────────── console.log('harness 结构校验\n'); if (warnings.length) { console.log('提醒:'); warnings.forEach((w) => console.log(' ⚠️ ' + w)); console.log(''); } if (problems.length) { console.log('违规:'); problems.forEach((p) => console.log(' ❌ ' + p)); console.log(`\n不通过:${problems.length} 项违规`); process.exit(1); } console.log(`✅ 通过(功能 ${features.length} 项,in_progress ${ features.filter((f) => f.status === 'in_progress').length } 项)`);