| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- /**
- * 校验 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
- } 项)`);
|