feat(plan): plan 1.8, the plan gate, the design part and the plan-side checks
Wave 1, order 3 (P3 plan mode as the start, P2 design as its own part, P9
plan side). Contracts K3-K6 for the later merges.
- plan_version 1.8 (1.7 plans stay valid): Test first on every step, one
End-to-end ("run the thing") step, optional Interface in / Interface out /
Review focus fields, a ceremony class in the header (absent =
architectural), and for architectural plans a ## Design part: module map
without cycles, architecture rules that name their enforcer and the step
that proves them, a requirements check asked as keep / change.
- K3 lib/validators/plan-gate.mjs: the critic verdict is recorded INSIDE the
plan (Claude Code's plan approval hands a hook only the plan file's text),
bound to the hash taken before the reviewers read it; 0 go / 1 stop / 2
could not run; at most 3 critic rounds, then stop and ask.
- K5 lib/verification/arch-rules.mjs: runs the target repo's own
dependency-cruiser or import-linter (installs nothing; 2 = not measured,
never green); --prove --rule shows pass -> fail on a planted violation of
that rule -> pass. Verified against dependency-cruiser 18.5.0 and
import-linter 2.15; madge 8.0.0 left out (missed a planted .mjs cycle).
- K6 lib/verification/plan-scope-sweep.mjs: changed files no step names.
- /trekplan Phases 8-10: design part, bounded critic loop, the gate before
"execute", a plan-mode path through ExitPlanMode (not yet run end to end
inside plan mode). plan-critic 10 -> 12 dimensions; scope-guardian checks
the ceremony.
- Fixed on the way: plan-validator and plan-review-dedup used the
file://argv[1] main guard (no-op under a symlink or a space); CRLF plans
failed every manifest; the dedup dropped finding severity.
Borrowed, credited in place: obra/superpowers, Kiro (docs), addyosmani/
agent-skills, mattpocock/skills, GitHub Spec Kit, bashebr/ai-native-sdlc,
Boris Cherny's public threads.
Suite 1497 (1495 / 0 / 2), 86 new tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c89a6b580
commit
f5ca220bc0
24 changed files with 3115 additions and 61 deletions
129
tests/helpers/plan-v18-builder.mjs
Normal file
129
tests/helpers/plan-v18-builder.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// tests/helpers/plan-v18-builder.mjs
|
||||
// Builds plan_version 1.8 plans for the plan-contract tests (K3 gate, K4 step
|
||||
// fields, the design part, the scope sweep). One builder, many variants, so a
|
||||
// test states only the difference it is about.
|
||||
|
||||
function manifest(paths) {
|
||||
return [
|
||||
'- **Manifest:**',
|
||||
' ```yaml',
|
||||
' manifest:',
|
||||
' expected_paths:',
|
||||
...paths.map(p => ` - ${p}`),
|
||||
` min_file_count: ${paths.length}`,
|
||||
' commit_message_pattern: "^feat\\\\(x\\\\):"',
|
||||
' bash_syntax_check: []',
|
||||
' forbidden_paths: []',
|
||||
' must_contain: []',
|
||||
' ```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function step(n, opts = {}) {
|
||||
const file = opts.file ?? `src/mod${n}.mjs`;
|
||||
const test = opts.test ?? `tests/mod${n}.test.mjs`;
|
||||
const lines = [
|
||||
`### Step ${n}: ${opts.title ?? `Build module ${n}`}`,
|
||||
'',
|
||||
`- **Files:** \`${file}\``,
|
||||
`- **Changes:** Add \`run${n}()\` to \`${file}\`. (new file)`,
|
||||
];
|
||||
if (opts.interfaceIn !== undefined) lines.push(`- **Interface in:** ${opts.interfaceIn}`);
|
||||
if (opts.interfaceOut !== undefined) lines.push(`- **Interface out:** ${opts.interfaceOut}`);
|
||||
if (opts.reviewFocus !== undefined) lines.push(`- **Review focus:** ${opts.reviewFocus}`);
|
||||
if (opts.testFirst !== false) {
|
||||
lines.push(
|
||||
'- **Test first:**',
|
||||
` - File: \`${test}\` (new)`,
|
||||
` - Verifies: run${n}() returns the value the next step reads`,
|
||||
' - Pattern: `tests/lib/plan-schema.test.mjs`',
|
||||
);
|
||||
}
|
||||
if (opts.endToEnd) lines.push(`- **End-to-end:** \`${opts.endToEnd}\` → expected: exit 0 and the CLI prints a result`);
|
||||
lines.push(
|
||||
`- **Verify:** \`node --test ${test}\` → expected: pass`,
|
||||
`- **On failure:** revert — \`git checkout -- ${file}\``,
|
||||
`- **Checkpoint:** \`git commit -m "feat(x): step ${n}"\``,
|
||||
manifest(opts.manifestPaths ?? [file, test]),
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const DESIGN = [
|
||||
'## Design',
|
||||
'',
|
||||
'### Architecture',
|
||||
'Two modules: a parser and a CLI on top of it.',
|
||||
'',
|
||||
'### Data Flow',
|
||||
'CLI argv → parser → result JSON on stdout.',
|
||||
'',
|
||||
'### Interfaces',
|
||||
'`parse(text) → {ok, items}`',
|
||||
'',
|
||||
'### Data Models',
|
||||
'`Item = {name: string, n: number}`',
|
||||
'',
|
||||
'### Error Handling',
|
||||
'Unreadable input → exit 2; invalid input → exit 1.',
|
||||
'',
|
||||
'### Module Map',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' cli[src/cli.mjs] --> parser[src/parser.mjs]',
|
||||
' parser --> util[src/util.mjs]',
|
||||
'```',
|
||||
'',
|
||||
'### Architecture Rules',
|
||||
'| Rule | Enforced by | Proof |',
|
||||
'|------|-------------|-------|',
|
||||
'| The parser never imports the CLI | checked by `node_modules/.bin/depcruise --config .dependency-cruiser.cjs src` | Step 1 |',
|
||||
'| Prefer pure functions in the parser | advisory — `.claude/rules/parser.md` | — |',
|
||||
'',
|
||||
'### Requirements Check',
|
||||
'No conflicts or gaps found between the brief\'s requirements.',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Build a plan.
|
||||
* @param {object} o
|
||||
* @param {string} [o.version='1.8']
|
||||
* @param {string|null} [o.ceremony] - header value; null = omit (→ architectural)
|
||||
* @param {boolean} [o.design=true]
|
||||
* @param {string} [o.designText]
|
||||
* @param {Array<object>} [o.steps] - step() option objects
|
||||
*/
|
||||
export function buildPlan(o = {}) {
|
||||
const version = o.version ?? '1.8';
|
||||
const ceremony = o.ceremony === undefined ? 'architectural' : o.ceremony;
|
||||
const header = ceremony === null
|
||||
? `> Generated by trekplan v6.1.1 on 2026-10-06 — \`plan_version: ${version}\``
|
||||
: `> Generated by trekplan v6.1.1 on 2026-10-06 — \`plan_version: ${version}\` · \`ceremony: ${ceremony}\``;
|
||||
const stepOpts = o.steps ?? [{}, { endToEnd: 'node src/cli.mjs --demo' }];
|
||||
const parts = [
|
||||
'# Test plan',
|
||||
'',
|
||||
'> **Plan quality: A** (92/100) — APPROVE',
|
||||
'>',
|
||||
header,
|
||||
'',
|
||||
'## Context',
|
||||
'',
|
||||
'Why: the brief asks for a parser CLI.',
|
||||
'',
|
||||
];
|
||||
if (o.design !== false) parts.push(o.designText ?? DESIGN, '');
|
||||
parts.push('## Implementation Plan', '');
|
||||
stepOpts.forEach((s, i) => parts.push(step(i + 1, s), ''));
|
||||
parts.push(
|
||||
'## Test Strategy',
|
||||
'',
|
||||
'- **Framework:** node:test',
|
||||
'',
|
||||
'## Verification',
|
||||
'',
|
||||
'- [ ] `npm test` → expected: exit 0',
|
||||
'',
|
||||
);
|
||||
return parts.join('\n');
|
||||
}
|
||||
265
tests/lib/arch-rules.test.mjs
Normal file
265
tests/lib/arch-rules.test.mjs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
// tests/lib/arch-rules.test.mjs
|
||||
// K5 — the architecture-rule runner. `arch-rules.mjs --repo <dir>`: 0 = passed,
|
||||
// 1 = violation, 2 = not measured (never green). JSON on stdout. It runs the
|
||||
// target repo's OWN tool and installs nothing: no local tool → 2.
|
||||
//
|
||||
// The tools are test doubles that print the shapes measured from the real tools
|
||||
// on 2026-10-06: dependency-cruiser 18.5.0 `--output-type json` (exit 0 even
|
||||
// with violations; the verdict is `summary.error`, the violation carries
|
||||
// `type: "cycle"`, `rule.name`, `from`, `to`) and import-linter 2.15 (exit 0
|
||||
// kept, exit 1 broken, summary line `Contracts: N kept, M broken.`). The
|
||||
// dependency-cruiser double really walks relative imports, so the planted
|
||||
// cycle below is a real cycle in the fixture source.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, symlinkSync, lstatSync, readlinkSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const RUNNER = join(HERE, '..', '..', 'lib', 'verification', 'arch-rules.mjs');
|
||||
|
||||
const DEPCRUISE_DOUBLE = `#!/usr/bin/env node
|
||||
// Test double for dependency-cruiser: walks relative ESM imports and reports
|
||||
// cycles in the JSON shape dependency-cruiser 18.5.0 prints.
|
||||
const fs = require('node:fs'); const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
if (process.env.DOUBLE_CRASH) { process.stderr.write('ERROR: boom'); process.exit(1); }
|
||||
const targets = []; for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--')) { i++; continue; } targets.push(args[i]); }
|
||||
const files = [];
|
||||
const walk = d => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.mjs')) files.push(p); } };
|
||||
for (const t of targets) { if (!fs.existsSync(t)) { process.stderr.write("ERROR: Can't open '" + t + "' for reading. Does it exist?"); process.exit(1); } walk(t); }
|
||||
const edges = {};
|
||||
for (const f of files) { edges[f] = []; for (const m of fs.readFileSync(f, 'utf8').matchAll(/from '(\\.[^']+)'/g)) edges[f].push(path.join(path.dirname(f), m[1])); }
|
||||
const violations = []; const seen = new Set();
|
||||
for (const a of files) for (const b of edges[a]) if ((edges[b] || []).includes(a) && !seen.has([a, b].sort().join())) {
|
||||
seen.add([a, b].sort().join());
|
||||
violations.push({ type: 'cycle', from: a, to: b, rule: { severity: 'error', name: 'no-circular' }, cycle: [{ name: b }, { name: a }] });
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ modules: files.map(f => ({ source: f })), summary: { violations, error: violations.length, warn: 0, info: 0, ignore: 0, advisedExitCode: violations.length ? 1 : 0, totalCruised: process.env.DOUBLE_EMPTY ? 0 : files.length } }));
|
||||
`;
|
||||
|
||||
const LINT_IMPORTS_DOUBLE = `#!/usr/bin/env node
|
||||
const mode = require('node:fs').readFileSync(process.cwd() + '/linter-mode', 'utf8').trim();
|
||||
if (mode === 'kept') { console.log('ui above domain KEPT\\n\\nContracts: 1 kept, 0 broken.'); process.exit(0); }
|
||||
if (mode === 'broken') { console.log('ui above domain BROKEN\\n\\nContracts: 0 kept, 1 broken.\\n\\napp.domain is not allowed to import app.ui:\\n\\n- app.domain.core -> app.ui.view (l.1)'); process.exit(1); }
|
||||
if (mode === 'zero') { console.log('Contracts: 0 kept, 0 broken.'); process.exit(0); }
|
||||
console.log('Could not read any configuration.'); process.exit(1);
|
||||
`;
|
||||
|
||||
function exe(path, body) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, body);
|
||||
chmodSync(path, 0o755);
|
||||
}
|
||||
|
||||
function jsRepo({ cycle = false, binary = true } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-'));
|
||||
mkdirSync(join(dir, 'src'));
|
||||
writeFileSync(join(dir, 'src', 'a.mjs'), "import { b } from './b.mjs';\nexport const a = 1;\n");
|
||||
writeFileSync(join(dir, 'src', 'b.mjs'), cycle ? "import { a } from './a.mjs';\nexport const b = 2;\n" : 'export const b = 2;\n');
|
||||
writeFileSync(join(dir, '.dependency-cruiser.cjs'), "module.exports = { forbidden: [{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } }] };\n");
|
||||
if (binary) exe(join(dir, 'node_modules', '.bin', 'depcruise'), DEPCRUISE_DOUBLE);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(args, env = {}) {
|
||||
const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: { ...process.env, ...env } });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
|
||||
return { code: r.status, json, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
const cleanup = d => rmSync(d, { recursive: true, force: true });
|
||||
|
||||
// ---- the tests that failed first ----
|
||||
test('K5: a planted cycle under the repo\'s own dependency-cruiser → 1, violation listed', () => {
|
||||
const dir = jsRepo({ cycle: true });
|
||||
try {
|
||||
const r = run(['--repo', dir]);
|
||||
assert.equal(r.code, 1, r.out + r.err);
|
||||
assert.equal(r.json.status, 'violation');
|
||||
const t = r.json.tools.find(x => x.tool === 'dependency-cruiser');
|
||||
assert.equal(t.status, 'violation');
|
||||
assert.equal(t.violations[0].rule, 'no-circular');
|
||||
assert.match(t.violations[0].text, /src\/a\.mjs/);
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: a repo with no architecture tool → 2 "not measured", never green', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-none-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'index.mjs'), 'export {};\n');
|
||||
const r = run(['--repo', dir]);
|
||||
assert.equal(r.code, 2, r.out + r.err);
|
||||
assert.equal(r.json.status, 'not-measured');
|
||||
assert.deepEqual(r.json.tools, []);
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: the same repo without the cycle → 0', () => {
|
||||
const dir = jsRepo();
|
||||
try {
|
||||
const r = run(['--repo', dir]);
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.equal(r.json.status, 'passed');
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: config without a local binary → 2 (never npx, never installs)', () => {
|
||||
const dir = jsRepo({ binary: false });
|
||||
try {
|
||||
const r = run(['--repo', dir]);
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.json.tools[0].reason, /node_modules\/\.bin\/depcruise/);
|
||||
assert.ok(!existsSync(join(dir, 'node_modules')), 'the runner must not create node_modules');
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: a tool that crashes or cruises nothing → 2', () => {
|
||||
const dir = jsRepo({ cycle: true });
|
||||
try {
|
||||
assert.equal(run(['--repo', dir], { DOUBLE_CRASH: '1' }).code, 2);
|
||||
assert.equal(run(['--repo', dir], { DOUBLE_EMPTY: '1' }).code, 2);
|
||||
assert.equal(run(['--repo', dir, '--target', 'nosuchdir']).code, 2);
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: import-linter — kept → 0, broken → 1, no summary line → 2', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arch-rules-py-'));
|
||||
try {
|
||||
writeFileSync(join(dir, '.importlinter'), '[importlinter]\nroot_package = app\n');
|
||||
exe(join(dir, '.venv', 'bin', 'lint-imports'), LINT_IMPORTS_DOUBLE);
|
||||
writeFileSync(join(dir, 'linter-mode'), 'kept');
|
||||
assert.equal(run(['--repo', dir]).code, 0);
|
||||
writeFileSync(join(dir, 'linter-mode'), 'broken');
|
||||
const broken = run(['--repo', dir]);
|
||||
assert.equal(broken.code, 1);
|
||||
assert.equal(broken.json.tools[0].violations[0].rule, 'ui above domain', 'the broken contract is the rule');
|
||||
assert.match(broken.json.tools[0].violations[0].text, /not allowed to import/);
|
||||
writeFileSync(join(dir, 'linter-mode'), 'noconfig');
|
||||
assert.equal(run(['--repo', dir]).code, 2);
|
||||
writeFileSync(join(dir, 'linter-mode'), 'zero');
|
||||
assert.equal(run(['--repo', dir]).code, 2, '0 contracts checked is not measured, not passed');
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: a missing --repo dir → 2', () => {
|
||||
assert.equal(run(['--repo', '/nonexistent/repo']).code, 2);
|
||||
});
|
||||
|
||||
// ---- pass → fail on a planted violation → pass ----
|
||||
test('--prove: pass → fail on the planted cycle → pass, and the plant is removed', () => {
|
||||
const dir = jsRepo();
|
||||
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
|
||||
try {
|
||||
writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n");
|
||||
writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n");
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular',
|
||||
'--plant', `${join(plantDir, 'c.mjs')}=src/planted/c.mjs`,
|
||||
'--plant', `${join(plantDir, 'd.mjs')}=src/planted/d.mjs`]);
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.equal(r.json.proven, true);
|
||||
assert.deepEqual(r.json.phases.map(p => p.code), [0, 1, 0]);
|
||||
assert.ok(!existsSync(join(dir, 'src', 'planted')), 'planted files and the dirs made for them are removed');
|
||||
} finally { cleanup(dir); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
test('--prove: a plant the tool does not catch → 1 (the rule is not proven)', () => {
|
||||
const dir = jsRepo();
|
||||
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
|
||||
try {
|
||||
writeFileSync(join(plantDir, 'e.mjs'), 'export const e = 1;\n');
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'e.mjs')}=src/e.mjs`]);
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.equal(r.json.proven, false);
|
||||
assert.ok(!existsSync(join(dir, 'src', 'e.mjs')));
|
||||
} finally { cleanup(dir); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
test('--prove refuses to overwrite an existing file → 2, file untouched', () => {
|
||||
const dir = jsRepo();
|
||||
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
|
||||
try {
|
||||
writeFileSync(join(plantDir, 'x.mjs'), 'export const x = 1;\n');
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'x.mjs')}=src/a.mjs`]);
|
||||
assert.equal(r.code, 2, r.out);
|
||||
assert.ok(existsSync(join(dir, 'src', 'a.mjs')));
|
||||
} finally { cleanup(dir); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
// ---- review findings (independent code review, 06.10) ----
|
||||
function cyclePlants() {
|
||||
const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-'));
|
||||
writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n");
|
||||
writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n");
|
||||
return plantDir;
|
||||
}
|
||||
|
||||
test('--prove refuses a plant destination outside the repo: ../ and through a symlink → 2, nothing written', () => {
|
||||
const dir = jsRepo();
|
||||
const outside = mkdtempSync(join(tmpdir(), 'arch-outside-'));
|
||||
const plantDir = cyclePlants();
|
||||
try {
|
||||
symlinkSync(outside, join(dir, 'src', 'link'));
|
||||
for (const dest of ['../escaped.mjs', 'src/link/escaped.mjs']) {
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=${dest}`]);
|
||||
assert.equal(r.code, 2, `${dest}: ${r.out}`);
|
||||
assert.match(r.json.reason, /outside the repo/);
|
||||
}
|
||||
assert.ok(!existsSync(join(outside, 'escaped.mjs')));
|
||||
assert.ok(!existsSync(join(dir, '..', 'escaped.mjs')));
|
||||
} finally { cleanup(dir); cleanup(outside); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
test('--prove refuses a dangling symlink at the destination and leaves it in place', () => {
|
||||
const dir = jsRepo();
|
||||
const plantDir = cyclePlants();
|
||||
try {
|
||||
symlinkSync(join(dir, 'nowhere.mjs'), join(dir, 'src', 'dangling.mjs'));
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=src/dangling.mjs`]);
|
||||
assert.equal(r.code, 2, r.out);
|
||||
assert.ok(lstatSync(join(dir, 'src', 'dangling.mjs')).isSymbolicLink());
|
||||
assert.equal(readlinkSync(join(dir, 'src', 'dangling.mjs')), join(dir, 'nowhere.mjs'));
|
||||
assert.ok(!existsSync(join(dir, 'nowhere.mjs')));
|
||||
} finally { cleanup(dir); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
test('--prove: a plant that trips another rule proves nothing about the named one → 1', () => {
|
||||
const dir = jsRepo();
|
||||
const plantDir = cyclePlants();
|
||||
try {
|
||||
const r = run(['--repo', dir, '--prove', '--rule', 'no-orphans',
|
||||
'--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`, '--plant', `${join(plantDir, 'd.mjs')}=src/p/d.mjs`]);
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.match(r.json.reason, /not no-orphans/);
|
||||
assert.ok(!existsSync(join(dir, 'src', 'p')));
|
||||
assert.equal(run(['--repo', dir, '--prove', '--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`]).code, 2, '--rule is required');
|
||||
} finally { cleanup(dir); cleanup(plantDir); }
|
||||
});
|
||||
|
||||
test('K5: with src/ and lib/ both present, a cycle in lib/ is caught (every default dir is cruised)', () => {
|
||||
const dir = jsRepo();
|
||||
try {
|
||||
mkdirSync(join(dir, 'lib'));
|
||||
writeFileSync(join(dir, 'lib', 'x.mjs'), "import { y } from './y.mjs';\nexport const x = 1;\n");
|
||||
writeFileSync(join(dir, 'lib', 'y.mjs'), "import { x } from './x.mjs';\nexport const y = 1;\n");
|
||||
const r = run(['--repo', dir]);
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.deepEqual(r.json.tools[0].targets, ['src', 'lib']);
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
||||
test('K5: a flag without its value → 2, never a crash', () => {
|
||||
const dir = jsRepo();
|
||||
try {
|
||||
for (const args of [['--repo', dir, '--target'], ['--repo'], ['--repo', dir, '--prove', '--rule']]) {
|
||||
const r = run(args);
|
||||
assert.equal(r.code, 2, `${args.join(' ')}: ${r.err}`);
|
||||
assert.ok(r.json, 'JSON on stdout');
|
||||
}
|
||||
} finally { cleanup(dir); }
|
||||
});
|
||||
|
|
@ -139,9 +139,9 @@ test('every command frontmatter name matches its filename', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('templates/plan-template.md declares plan_version: 1.7', () => {
|
||||
test('templates/plan-template.md declares plan_version: 1.8', () => {
|
||||
const tpl = read('templates/plan-template.md');
|
||||
assert.match(tpl, /plan_version:\s*['"]?1\.7['"]?/);
|
||||
assert.match(tpl, /plan_version:\s*['"]?1\.8['"]?/);
|
||||
});
|
||||
|
||||
test('HANDOVER-CONTRACTS.md locates plan_version in the plan header line, not frontmatter (S28)', () => {
|
||||
|
|
@ -162,8 +162,8 @@ test('HANDOVER-CONTRACTS.md locates plan_version in the plan header line, not fr
|
|||
// "commands/trekexecute.md still parses v1.7 plan schema" was an OR-chain
|
||||
// (`includes('plan_version') || includes('Step N:') || includes('### Step ')`)
|
||||
// that is ~always true and asserts almost nothing. The real v1.7 coverage lives
|
||||
// in the structural pin "templates/plan-template.md declares plan_version: 1.7"
|
||||
// (above) + the plan-validator / plan-schema behavior tests.
|
||||
// in the structural pin "templates/plan-template.md declares plan_version: 1.8"
|
||||
// (above; 1.7 plans stay valid) + the plan-validator / plan-schema behavior tests.
|
||||
|
||||
test('settings.json has only known top-level scopes after Spor 0 cleanup', () => {
|
||||
const cfg = JSON.parse(read('settings.json'));
|
||||
|
|
|
|||
129
tests/lib/plan-prose-pins.test.mjs
Normal file
129
tests/lib/plan-prose-pins.test.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// tests/lib/plan-prose-pins.test.mjs
|
||||
// Prose pins for the plan process (wave 1, order 3): /trekplan Phases 8-10, the
|
||||
// plan and design templates, plan-critic and the Handover 4 contract must keep
|
||||
// saying what the plan gate, the validator and the verification CLIs do.
|
||||
// Counted as doc pins by lib/util/test-census.mjs (PIN_FILE_RE).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
|
||||
import { DESIGN_SUBSECTIONS, OPTIONAL_STEP_FIELDS } from '../../lib/validators/plan-validator.mjs';
|
||||
import { findSteps, PLAN_VERSION_CURRENT } from '../../lib/parsers/plan-schema.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
|
||||
|
||||
function phase(text, n) {
|
||||
const start = text.indexOf(`\n## Phase ${n} `);
|
||||
assert.ok(start >= 0, `## Phase ${n} missing`);
|
||||
const end = text.indexOf('\n## Phase ', start + 1);
|
||||
return text.slice(start, end < 0 ? undefined : end);
|
||||
}
|
||||
|
||||
const PLAN_CMD = read('commands/trekplan.md');
|
||||
|
||||
test('Phase 9 is a bounded loop: --record after the dedup, the cap equals MAX_ROUNDS, stop and ask after it', () => {
|
||||
const p9 = phase(PLAN_CMD, 9);
|
||||
assert.match(p9, /plan-review-dedup\.mjs --stdin <<'JSON' \| node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/plan-gate\.mjs --record/);
|
||||
assert.ok(p9.includes(`at most **${MAX_ROUNDS} critic rounds**`), `Phase 9 must state the cap of ${MAX_ROUNDS}`);
|
||||
assert.match(p9, /After round 3 with a BLOCKER open/);
|
||||
assert.match(p9, /never goes on silently/);
|
||||
assert.match(p9, /--extra-round/);
|
||||
assert.ok(p9.indexOf('plan-gate.mjs --hash') < p9.indexOf('Then launch two review agents'), 'the hash is taken before the reviewers read the plan');
|
||||
assert.match(p9, /--record "\{plan_path\}" --reviewed \{the hash from --hash\}/);
|
||||
assert.match(p9, /Kiro/, 'the bounded loop is borrowed and credited');
|
||||
});
|
||||
|
||||
test('Phase 10 runs the plan gate before offering "execute", and covers plan mode', () => {
|
||||
const p10 = phase(PLAN_CMD, 10);
|
||||
assert.ok(p10.indexOf('plan-gate.mjs --json') < p10.indexOf('Say **"execute"**'), 'the gate runs before the offer');
|
||||
assert.match(p10, /only when the gate exited 0/);
|
||||
assert.match(p10, /ExitPlanMode/);
|
||||
assert.match(p10, /\*\*Not\s+verified:\*\* that `\/trekplan` runs end to end inside plan mode/);
|
||||
assert.match(p10, /hedged reply/i);
|
||||
});
|
||||
|
||||
test('Phase 8 reads ceremony (absent = architectural), the design template and the arch-rules runner', () => {
|
||||
const p8 = phase(PLAN_CMD, 8);
|
||||
assert.match(p8, /Absent means `architectural`/);
|
||||
assert.ok(p8.includes('templates/design-template.md'));
|
||||
assert.ok(p8.includes('lib/verification/arch-rules.mjs --repo .'));
|
||||
assert.ok(p8.includes('--prove --rule <rule name> --plant'), 'the proof names the rule the plant must trip');
|
||||
assert.match(p8, /End-to-end:/);
|
||||
for (const f of OPTIONAL_STEP_FIELDS) assert.ok(p8.includes(`${f}:`), `Phase 8 names the optional field ${f}:`);
|
||||
for (const credit of ['obra/superpowers', 'Spec Kit', 'mattpocock/skills', 'Cherny']) {
|
||||
assert.ok(p8.includes(credit), `Phase 8 credits ${credit}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the CLIs and templates /trekplan points at exist', () => {
|
||||
for (const rel of [
|
||||
'lib/validators/plan-gate.mjs', 'lib/verification/arch-rules.mjs',
|
||||
'lib/verification/plan-scope-sweep.mjs', 'templates/design-template.md',
|
||||
]) assert.ok(existsSync(join(ROOT, rel)), `${rel} missing`);
|
||||
});
|
||||
|
||||
test('the hard rules carry the bounded critic loop and the gate', () => {
|
||||
const rules = PLAN_CMD.slice(PLAN_CMD.indexOf('\n## Hard rules'));
|
||||
assert.match(rules, /\*\*Bounded critic loop\*\*: at most 3 critic rounds/);
|
||||
assert.match(rules, /never while `plan-gate\.mjs` exits non-zero/);
|
||||
});
|
||||
|
||||
test('design template: every subsection the validator requires is a ### heading, and the borrowings are credited', () => {
|
||||
const t = read('templates/design-template.md');
|
||||
for (const s of DESIGN_SUBSECTIONS) assert.match(t, new RegExp(`^### ${s}$`, 'm'), `design template lacks ### ${s}`);
|
||||
for (const credit of ['Kiro', 'obra/superpowers', 'addyosmani/agent-skills', 'mattpocock/skills', 'Spec Kit', 'ai-native-sdlc']) {
|
||||
assert.ok(t.includes(credit), `design template credits ${credit}`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`plan template: plan_version ${PLAN_VERSION_CURRENT} with ceremony, Test first on every example step, one End-to-end step, no hand-written Critic Verdict`, () => {
|
||||
const t = read('templates/plan-template.md');
|
||||
assert.ok(t.includes(`\`plan_version: ${PLAN_VERSION_CURRENT}\` · \`ceremony:`));
|
||||
const steps = findSteps(t);
|
||||
assert.ok(steps.length >= 2);
|
||||
const bodies = steps.map((s, i) => t.slice(s.offset, i + 1 < steps.length ? steps[i + 1].offset : undefined));
|
||||
for (const [i, b] of bodies.entries()) assert.match(b, /- \*\*Test first:\*\*/, `example Step ${i + 1} lacks Test first`);
|
||||
assert.equal(bodies.filter(b => /- \*\*End-to-end:\*\* `/.test(b)).length, 1);
|
||||
assert.doesNotMatch(t, /^## Critic Verdict$/m, 'the planner must not copy a verdict heading: only plan-gate --record writes it');
|
||||
assert.match(t, /plan-gate\.mjs\s+--record` appends it/);
|
||||
assert.match(t, /^## Global Constraints$/m);
|
||||
assert.doesNotMatch(t, /omit "Test first"/, 'a 1.8 plan never omits Test first');
|
||||
});
|
||||
|
||||
test('plan-critic carries dimensions 11 (interfaces and slices) and 12 (design and architecture rules)', () => {
|
||||
const c = read('agents/plan-critic.md');
|
||||
assert.match(c, /^### 11\. Interfaces and slices$/m);
|
||||
assert.match(c, /^### 12\. Design and architecture rules$/m);
|
||||
assert.match(c, /`severity` is exactly one of `blocker`, `major`, `minor`/);
|
||||
});
|
||||
|
||||
test('Handover 4 documents every PLAN_* code the validator and the gate emit', () => {
|
||||
const contract = read('docs/HANDOVER-CONTRACTS.md');
|
||||
const h4 = contract.slice(contract.indexOf('## Handover 4'), contract.indexOf('## Handover 5'));
|
||||
const codes = new Set();
|
||||
for (const rel of ['lib/validators/plan-validator.mjs', 'lib/validators/plan-gate.mjs']) {
|
||||
for (const m of read(rel).matchAll(/'(PLAN_[A-Z_]+)'/g)) codes.add(m[1]);
|
||||
}
|
||||
assert.ok(codes.size >= 15, `expected the 1.8 and gate codes, found ${codes.size}`);
|
||||
const missing = [...codes].filter(c => !h4.includes(c));
|
||||
assert.deepEqual(missing, [], `Handover 4 does not document: ${missing.join(', ')}`);
|
||||
});
|
||||
|
||||
test('Handover 4 states 1.8 as current and 1.7 as still valid', () => {
|
||||
const contract = read('docs/HANDOVER-CONTRACTS.md');
|
||||
assert.match(contract, /\| `plan\.md` \| `plan_version` \(plan header line\) \| `1\.8` \(1\.7 still valid\) \|/);
|
||||
assert.match(contract, /`"1\.8"` \(current\), `"1\.7"` \(valid\)/);
|
||||
});
|
||||
|
||||
test('the verification CLIs credit what they borrow and say they install nothing', () => {
|
||||
const arch = read('lib/verification/arch-rules.mjs');
|
||||
assert.match(arch, /installs nothing/);
|
||||
assert.match(arch, /agent-skills/);
|
||||
assert.match(arch, /Pocock/);
|
||||
assert.match(read('lib/verification/plan-scope-sweep.mjs'), /ai-native-sdlc \(MIT\)/);
|
||||
assert.match(read('lib/validators/plan-gate.mjs'), /Kiro/);
|
||||
});
|
||||
|
|
@ -164,6 +164,31 @@ test('CLI --stdin tolerates a single present payload (other key absent)', () =>
|
|||
assert.deepEqual(parsed.findings[0].raised_by, ['plan-critic']);
|
||||
});
|
||||
|
||||
test('severity survives the dedup, and a merge keeps the more severe of the two (the plan gate reads it)', () => {
|
||||
const sources = [
|
||||
{ agent: 'plan-critic', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'Minor', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
|
||||
{ agent: 'scope-guardian', payload: { findings: [{ file: 'plan.md', line: 42, rule_key: 'PC1', severity: 'blocker', text: 'Step 4 lacks verifiable acceptance criteria' }] } },
|
||||
];
|
||||
const r = dedupFindings(sources);
|
||||
assert.equal(r.findings.length, 1);
|
||||
assert.equal(r.findings[0].severity, 'blocker');
|
||||
const lone = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', severity: 'MAJOR', text: 't' }] } }]);
|
||||
assert.equal(lone.findings[0].severity, 'major');
|
||||
const unrated = dedupFindings([{ agent: 'plan-critic', payload: { findings: [{ file: 'p', line: 1, rule_key: 'k', text: 't' }] } }]);
|
||||
assert.equal(unrated.findings[0].severity, null, 'an unrated finding stays unrated here; the gate decides what that means');
|
||||
});
|
||||
|
||||
test('sources says which reviewers delivered a findings block (null, misnamed or findings-less = absent)', () => {
|
||||
const stdin = JSON.stringify({ plan_critic: { agent: 'plan-critic', findings: [] }, scopeGuardian: { findings: [] } });
|
||||
const parsed = JSON.parse(execFileSync(process.execPath, [SHIM, '--stdin'], { input: stdin, encoding: 'utf-8' }));
|
||||
assert.deepEqual(parsed.sources, [
|
||||
{ agent: 'plan-critic', present: true },
|
||||
{ agent: 'scope-guardian', present: false },
|
||||
]);
|
||||
const noFindings = dedupFindings([{ agent: 'plan-critic', payload: { agent: 'plan-critic' } }]);
|
||||
assert.deepEqual(noFindings.sources, [{ agent: 'plan-critic', present: false }]);
|
||||
});
|
||||
|
||||
test('CLI --stdin exits non-zero on malformed input (loud, not a silent empty merge)', () => {
|
||||
assert.throws(
|
||||
() => execFileSync(process.execPath, [SHIM, '--stdin'], { input: 'not json{', encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }),
|
||||
|
|
|
|||
|
|
@ -150,5 +150,5 @@ test('extractPlanVersion — recognizes the canonical plan template (regression
|
|||
// The real generated artifact: no test exercised the parser against the
|
||||
// actual template, which is why defect S26 went unnoticed.
|
||||
const tpl = readFileSync(new URL('../../templates/plan-template.md', import.meta.url), 'utf-8');
|
||||
assert.equal(extractPlanVersion(tpl), '1.7');
|
||||
assert.equal(extractPlanVersion(tpl), '1.8');
|
||||
});
|
||||
|
|
|
|||
177
tests/lib/plan-scope-sweep.test.mjs
Normal file
177
tests/lib/plan-scope-sweep.test.mjs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// tests/lib/plan-scope-sweep.test.mjs
|
||||
// K6 — the sweep for files the plan does not name.
|
||||
// `plan-scope-sweep.mjs --plan <plan.md> --base <ref>`: changed files since the
|
||||
// merge base, minus every step's `Files:` / `Test first:` file / manifest
|
||||
// `expected_paths`, minus a named process-file list, must be empty.
|
||||
// 0 = nothing outside the plan, 1 = deviation (listed), 2 = could not run.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { plannedPaths, PROCESS_FILES } from '../../lib/verification/plan-scope-sweep.mjs';
|
||||
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SWEEP = join(HERE, '..', '..', 'lib', 'verification', 'plan-scope-sweep.mjs');
|
||||
|
||||
const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
|
||||
function repo() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'scope-sweep-'));
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
git(dir, 'config', 'user.email', 't@example.invalid');
|
||||
git(dir, 'config', 'user.name', 't');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
writeFileSync(join(dir, 'README.md'), '# r\n');
|
||||
git(dir, 'add', 'README.md');
|
||||
git(dir, 'commit', '-q', '-m', 'base');
|
||||
mkdirSync(join(dir, '.claude', 'projects', 'p1'), { recursive: true });
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), buildPlan());
|
||||
mkdirSync(join(dir, 'src'));
|
||||
mkdirSync(join(dir, 'tests'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function put(dir, rel, body = 'x\n') {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), body);
|
||||
}
|
||||
|
||||
function run(dir, extra = []) {
|
||||
const r = spawnSync('node', [SWEEP, '--plan', '.claude/projects/p1/plan.md', '--base', 'main', ...extra],
|
||||
{ cwd: dir, encoding: 'utf-8' });
|
||||
let json = null;
|
||||
try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ }
|
||||
return { code: r.status, json, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
// ---- the test that failed first ----
|
||||
test('K6: one changed file outside the plan → 1, and it is listed', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'checkout', '-q', '-b', 'work');
|
||||
put(dir, 'src/mod1.mjs');
|
||||
put(dir, 'tests/mod1.test.mjs');
|
||||
put(dir, 'src/unplanned.mjs');
|
||||
git(dir, 'add', 'src', 'tests', '.claude');
|
||||
git(dir, 'commit', '-q', '-m', 'work');
|
||||
const r = run(dir);
|
||||
assert.equal(r.code, 1, r.out + r.err);
|
||||
assert.deepEqual(r.json.outside, ['src/unplanned.mjs']);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: only planned files and process files changed → 0', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'checkout', '-q', '-b', 'work');
|
||||
put(dir, 'src/mod1.mjs');
|
||||
put(dir, 'tests/mod2.test.mjs');
|
||||
put(dir, '.claude/projects/p1/progress.json', '{}');
|
||||
git(dir, 'add', 'src', 'tests', '.claude');
|
||||
git(dir, 'commit', '-q', '-m', 'work');
|
||||
const r = run(dir);
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.deepEqual(r.json.outside, []);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: uncommitted and untracked changes count too', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
git(dir, 'add', '.claude');
|
||||
git(dir, 'commit', '-q', '-m', 'plan');
|
||||
put(dir, 'README.md', '# changed\n');
|
||||
put(dir, 'stray.txt');
|
||||
const r = run(dir);
|
||||
assert.equal(r.code, 1);
|
||||
assert.deepEqual(r.json.outside, ['README.md', 'stray.txt']);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: --process names extra process files; a deviation is allowed once the plan names the file', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
put(dir, 'CHANGELOG.md');
|
||||
assert.equal(run(dir).code, 1);
|
||||
assert.equal(run(dir, ['--process', 'CHANGELOG.md']).code, 0);
|
||||
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `CHANGELOG.md`');
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
|
||||
assert.equal(run(dir).code, 0);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: a bad ref, a missing plan, or a plan without steps → 2', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
assert.equal(run(dir, ['--base', 'no-such-ref']).code, 2);
|
||||
const r = spawnSync('node', [SWEEP, '--plan', 'missing.md', '--base', 'main'], { cwd: dir, encoding: 'utf-8' });
|
||||
assert.equal(r.status, 2);
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), '# no steps\n');
|
||||
assert.equal(run(dir).code, 2);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('plannedPaths collects Files, Test first files and manifest expected_paths', () => {
|
||||
const p = plannedPaths(buildPlan());
|
||||
for (const f of ['src/mod1.mjs', 'tests/mod1.test.mjs', 'src/mod2.mjs', 'tests/mod2.test.mjs']) {
|
||||
assert.ok(p.includes(f), `${f} missing from ${p.join(',')}`);
|
||||
}
|
||||
assert.ok(PROCESS_FILES.length > 0);
|
||||
});
|
||||
|
||||
// ---- review findings (independent code review, 06.10) ----
|
||||
test('K6: renames list both paths, deletions count, non-ASCII and spaced names match when planned', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
put(dir, 'src/old name.mjs');
|
||||
put(dir, 'src/gone.mjs');
|
||||
git(dir, 'add', 'src');
|
||||
git(dir, 'commit', '-q', '-m', 'more');
|
||||
git(dir, 'checkout', '-q', '-b', 'work');
|
||||
git(dir, 'mv', 'src/old name.mjs', 'src/prøve fil.mjs');
|
||||
git(dir, 'rm', '-q', 'src/gone.mjs');
|
||||
git(dir, 'commit', '-q', '-m', 'work');
|
||||
const r = run(dir);
|
||||
assert.equal(r.code, 1);
|
||||
assert.deepEqual(r.json.outside, ['src/gone.mjs', 'src/old name.mjs', 'src/prøve fil.mjs']);
|
||||
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`',
|
||||
'- **Files:** `src/mod1.mjs`, `src/old name.mjs`, `src/prøve fil.mjs`, `src/gone.mjs`');
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
|
||||
const ok = run(dir);
|
||||
assert.equal(ok.code, 0, ok.out);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: a glob with no fixed directory (`**/*`) plans nothing and is reported as ignored', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
put(dir, 'src/anything.mjs');
|
||||
const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `**/*`');
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan);
|
||||
const r = run(dir);
|
||||
assert.equal(r.code, 1);
|
||||
assert.deepEqual(r.json.ignored, ['**/*']);
|
||||
assert.ok(r.json.outside.includes('src/anything.mjs'));
|
||||
const scoped = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/**`');
|
||||
writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), scoped);
|
||||
assert.equal(run(dir).code, 0, 'a glob under a fixed directory still plans');
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('K6: a flag without its value, or a directory as the plan → 2, never a crash', () => {
|
||||
const dir = repo();
|
||||
try {
|
||||
for (const extra of [['--process'], ['--repo']]) {
|
||||
const r = run(dir, extra);
|
||||
assert.equal(r.code, 2, `${extra.join(' ')}: ${r.err}`);
|
||||
assert.ok(r.json);
|
||||
}
|
||||
const r = spawnSync('node', [SWEEP, '--plan', 'src', '--base', 'main'], { cwd: dir, encoding: 'utf-8' });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
234
tests/validators/plan-gate.test.mjs
Normal file
234
tests/validators/plan-gate.test.mjs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
// tests/validators/plan-gate.test.mjs
|
||||
// K3 — the plan gate. `plan-gate.mjs <plan.md>`: 0 = the plan may go on,
|
||||
// 1 = stop (invalid plan, plan_version < 1.8, no critic verdict, a verdict the
|
||||
// plan has moved away from, or an open BLOCKER), 2 = could not run. The
|
||||
// critic's verdict lives machine-readably INSIDE the plan (`## Critic Verdict`),
|
||||
// because Claude Code's plan approval hands a hook the plan file's text and
|
||||
// nothing next to it. `--record` writes the verdict from plan-review-dedup
|
||||
// output, bound to the hash taken (`--hash`) before the reviewers read the
|
||||
// plan; at most 3 rounds, then stop and ask — never silently on.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
gatePlan, recordVerdict, planHash, operativeText, extractVerdict, MAX_ROUNDS,
|
||||
} from '../../lib/validators/plan-gate.mjs';
|
||||
import { buildPlan, step } from '../helpers/plan-v18-builder.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const GATE = join(HERE, '..', '..', 'lib', 'validators', 'plan-gate.mjs');
|
||||
|
||||
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
|
||||
const finding = (severity, text, rule_key = 'step-quality') =>
|
||||
({ id: `${severity}-${rule_key}`, file: 'plan.md', line: 10, rule_key, severity, text, raised_by: ['plan-critic'] });
|
||||
const dedupOut = (...findings) => ({ findings, sources: BOTH, dedup_stats: { total_in: findings.length, total_out: findings.length } });
|
||||
// One round as Phase 9 runs it: hash before the review, record after it.
|
||||
const rec = (text, dedup, opts = {}) => recordVerdict(text, dedup, { reviewed: planHash(text), ...opts });
|
||||
|
||||
function run(args, input) {
|
||||
const r = spawnSync('node', [GATE, ...args], { input, encoding: 'utf-8' });
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
function withPlan(text, fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'plan-gate-'));
|
||||
const p = join(dir, 'plan.md');
|
||||
writeFileSync(p, text);
|
||||
try { return fn(p, dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
// ---- the test that failed first ----
|
||||
test('K3: a plan with an open critic BLOCKER exits 1', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut(finding('blocker', 'Step 2 reads a value no step writes')));
|
||||
assert.equal(r0.code, 0, r0.message);
|
||||
withPlan(r0.text, (p) => {
|
||||
const r = run([p, '--json']);
|
||||
assert.equal(r.code, 1, r.out + r.err);
|
||||
const j = JSON.parse(r.out);
|
||||
assert.equal(j.status, 'stop');
|
||||
assert.ok(j.reasons.some(x => x.code === 'PLAN_GATE_OPEN_BLOCKER'), r.out);
|
||||
});
|
||||
});
|
||||
|
||||
test('K3: a valid plan whose latest verdict has no blocker exits 0 (majors are warnings)', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut(finding('major', 'Step 1 lacks an error path'), finding('minor', 'typo')));
|
||||
withPlan(r0.text, (p) => {
|
||||
const r = run([p, '--json']);
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
const j = JSON.parse(r.out);
|
||||
assert.equal(j.verdict.verdict, 'REVISE');
|
||||
assert.ok(j.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR'));
|
||||
});
|
||||
});
|
||||
|
||||
test('K3: no critic verdict → 1 (the plan has not been through the critic)', () => {
|
||||
const g = gatePlan(buildPlan());
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_NO_VERDICT'));
|
||||
});
|
||||
|
||||
test('K3: an invalid plan → 1 even with a clean verdict', () => {
|
||||
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] });
|
||||
const g = gatePlan(rec(bad, dedupOut()).text);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_INVALID_PLAN'));
|
||||
});
|
||||
|
||||
test('K3: a plan below plan_version 1.8 → 1, even with a clean verdict', () => {
|
||||
const old = buildPlan({ version: '1.7', ceremony: null, design: false });
|
||||
const g = gatePlan(rec(old, dedupOut()).text);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERSION'), JSON.stringify(g.reasons));
|
||||
});
|
||||
|
||||
test('K3: editing a step after the verdict makes it stale → 1', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut());
|
||||
assert.equal(gatePlan(r0.text).code, 0);
|
||||
const edited = r0.text.replace('Add `run1()`', 'Add `run1()` and `cleanup1()`');
|
||||
const g = gatePlan(edited);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'));
|
||||
});
|
||||
|
||||
test('K3: a step appended after the verdict (inside a review-log section) stales it', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut());
|
||||
const appended = `${r0.text}\n${step(3, { title: 'Sneaked in' })}\n`;
|
||||
assert.notEqual(planHash(appended), planHash(r0.text));
|
||||
const g = gatePlan(appended);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'), JSON.stringify(g.reasons));
|
||||
});
|
||||
|
||||
test('K3: review-log sections (quality header, score, revisions, verdict) do not stale the verdict', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'nit')));
|
||||
const logged = r0.text
|
||||
.replace('> **Plan quality: A** (92/100) — APPROVE', '> **Plan quality: B** (80/100) — APPROVE_WITH_NOTES')
|
||||
.replace('## Verification', '## Revisions\n\n| # | Finding | Severity | Resolution |\n|---|---|---|---|\n| 1 | nit | minor | left |\n\n## Verification');
|
||||
assert.equal(planHash(logged), planHash(r0.text));
|
||||
assert.equal(gatePlan(logged).code, 0);
|
||||
});
|
||||
|
||||
test('K3: only exact review-log titles are excluded ("Revisions to the API" is plan)', () => {
|
||||
const plan = buildPlan().replace('## Verification', '## Revisions to the API\n\nThe endpoint moves.\n\n## Verification');
|
||||
const changed = plan.replace('The endpoint moves.', 'The endpoint is deleted.');
|
||||
assert.notEqual(planHash(changed), planHash(plan));
|
||||
});
|
||||
|
||||
test('K3: whitespace at line ends and CRLF do not stale the verdict', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut());
|
||||
assert.equal(gatePlan(r0.text.replace(/\n/g, ' \r\n')).code, 0);
|
||||
});
|
||||
|
||||
test('K3: a malformed verdict block → 1 (bad JSON, a null finding, max_rounds raised); a missing file → 2', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'n')));
|
||||
for (const broken of [
|
||||
r0.text.replace('"schema"', '"schema" oops'),
|
||||
r0.text.replace(/"findings": \[\n\s*\{/, '"findings": [\n null,\n {'),
|
||||
r0.text.replace('"max_rounds": 3', '"max_rounds": 99'),
|
||||
]) {
|
||||
const g = gatePlan(broken);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_MALFORMED'), JSON.stringify(g.reasons));
|
||||
}
|
||||
assert.equal(run(['/nonexistent/plan.md', '--json']).code, 2);
|
||||
});
|
||||
|
||||
// ---- what --record refuses ----
|
||||
test('--record refuses a dedup input where a reviewer is absent (an absent review is not a clean one)', () => {
|
||||
const plan = buildPlan();
|
||||
const oneMissing = { findings: [], sources: [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: false }] };
|
||||
const r1 = rec(plan, oneMissing);
|
||||
assert.equal(r1.code, 2);
|
||||
assert.match(r1.message, /scope-guardian/);
|
||||
assert.equal(rec(plan, { findings: [] }).code, 2, 'no sources at all');
|
||||
});
|
||||
|
||||
test('--record binds the verdict to the text the reviewers read: no hash or a changed plan → 2', () => {
|
||||
const plan = buildPlan();
|
||||
assert.equal(recordVerdict(plan, dedupOut()).code, 2, '--reviewed is required');
|
||||
const before = planHash(plan);
|
||||
const revised = plan.replace('Add `run1()`', 'Add `run1()` after review');
|
||||
const r = recordVerdict(revised, dedupOut(), { reviewed: before });
|
||||
assert.equal(r.code, 2);
|
||||
assert.match(r.message, /changed after the reviewers read it/);
|
||||
});
|
||||
|
||||
test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra round, which is only for then`, () => {
|
||||
assert.equal(MAX_ROUNDS, 3);
|
||||
let text = buildPlan();
|
||||
assert.equal(rec(text, dedupOut(), { extraRound: true }).code, 2, '--extra-round within the cap is refused, not banked');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`)));
|
||||
assert.equal(r.code, 0, r.message);
|
||||
assert.equal(r.verdict.round, i);
|
||||
text = r.text;
|
||||
}
|
||||
const fourth = rec(text, dedupOut());
|
||||
assert.equal(fourth.code, 1);
|
||||
assert.match(fourth.message, /ask the operator/i);
|
||||
const g = gatePlan(text);
|
||||
assert.equal(g.code, 1);
|
||||
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_ROUNDS_EXHAUSTED'), JSON.stringify(g.reasons));
|
||||
const extra = rec(text, dedupOut(), { extraRound: true });
|
||||
assert.equal(extra.code, 0);
|
||||
assert.equal(extra.verdict.round, 4);
|
||||
assert.equal(extra.verdict.extra_rounds, 1);
|
||||
assert.equal(gatePlan(extra.text).code, 0);
|
||||
assert.equal(rec(extra.text, dedupOut()).code, 1, 'one yes buys one round');
|
||||
});
|
||||
|
||||
test('--record clamps a raised round cap to MAX_ROUNDS', () => {
|
||||
const r = rec(buildPlan(), dedupOut(), { maxRounds: 99 });
|
||||
assert.equal(r.verdict.max_rounds, MAX_ROUNDS);
|
||||
});
|
||||
|
||||
test('--record replaces the verdict section instead of stacking a second one, and replaces a copied placeholder', () => {
|
||||
const once = rec(buildPlan(), dedupOut()).text;
|
||||
const twice = rec(once, dedupOut()).text;
|
||||
assert.equal((twice.match(/^## Critic Verdict$/gm) || []).length, 1);
|
||||
assert.equal(extractVerdict(twice).verdict.round, 2);
|
||||
const withPlaceholder = `${buildPlan()}\n## Critic Verdict\n\n*Written by plan-gate --record.*\n`;
|
||||
const r = rec(withPlaceholder, dedupOut());
|
||||
assert.equal(r.code, 0, r.message);
|
||||
assert.equal(r.verdict.round, 1);
|
||||
assert.doesNotMatch(r.text, /\*Written by plan-gate --record\.\*/);
|
||||
});
|
||||
|
||||
test('--record: a finding without a valid severity counts as a blocker', () => {
|
||||
const r = rec(buildPlan(), dedupOut({ file: 'plan.md', line: 1, rule_key: 'x', text: 'unrated' }));
|
||||
assert.equal(r.verdict.verdict, 'BLOCK');
|
||||
assert.equal(r.verdict.findings[0].severity, 'blocker');
|
||||
assert.equal(r.verdict.findings[0].severity_defaulted, true);
|
||||
});
|
||||
|
||||
// ---- the CLI as Phase 9 calls it ----
|
||||
test('CLI: --hash, then --record --reviewed writes in place; malformed stdin or unknown flags → 2, nothing written', () => {
|
||||
withPlan(buildPlan(), (p, dir) => {
|
||||
const before = readFileSync(p, 'utf-8');
|
||||
const hash = run(['--hash', p]).out.trim();
|
||||
assert.match(hash, /^[0-9a-f]{64}$/);
|
||||
assert.equal(run(['--record', p, '--reviewed', hash], 'not json').code, 2);
|
||||
assert.equal(run(['--record', p, '--reviewed', hash, '--max-rounds', '9'], JSON.stringify(dedupOut())).code, 2);
|
||||
assert.equal(run(['--record', p], JSON.stringify(dedupOut())).code, 2, '--reviewed is required');
|
||||
assert.equal(readFileSync(p, 'utf-8'), before);
|
||||
const ok = run(['--record', p, '--reviewed', hash], JSON.stringify(dedupOut(finding('major', 'm'))));
|
||||
assert.equal(ok.code, 0, ok.out + ok.err);
|
||||
assert.equal(JSON.parse(ok.out).verdict, 'REVISE');
|
||||
assert.equal(run([p]).code, 0);
|
||||
assert.ok(!existsSync(join(dir, 'plan.md.plan-gate.tmp')));
|
||||
});
|
||||
});
|
||||
|
||||
test('operativeText drops only the review-log sections', () => {
|
||||
const r0 = rec(buildPlan(), dedupOut());
|
||||
const op = operativeText(r0.text);
|
||||
assert.ok(!op.includes('## Critic Verdict'));
|
||||
assert.ok(!op.includes('Plan quality:'));
|
||||
assert.ok(op.includes('### Step 2:'));
|
||||
assert.ok(op.includes('## Design'));
|
||||
});
|
||||
252
tests/validators/plan-validator-v18.test.mjs
Normal file
252
tests/validators/plan-validator-v18.test.mjs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// tests/validators/plan-validator-v18.test.mjs
|
||||
// plan_version 1.8 (wave 1, order 3): `Test first:` is required on every step,
|
||||
// every plan carries one "run the thing" step (`End-to-end:`), the K4 step
|
||||
// fields (`Interface in:`, `Interface out:`, `Review focus:`) are optional but
|
||||
// never empty, and an architectural plan carries the design part. 1.7 plans
|
||||
// keep validating exactly as before.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
|
||||
import {
|
||||
parseStepFields, ceremonyFromBrief, resolveCeremony, findModuleCycle, splitSections,
|
||||
} from '../../lib/parsers/plan-schema.mjs';
|
||||
import { buildPlan, DESIGN } from '../helpers/plan-v18-builder.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const VALIDATOR = join(HERE, '..', '..', 'lib', 'validators', 'plan-validator.mjs');
|
||||
const codes = r => r.errors.map(e => e.code);
|
||||
|
||||
test('a complete 1.8 architectural plan validates strict', () => {
|
||||
const r = validatePlanContent(buildPlan());
|
||||
assert.deepEqual(r.errors, [], JSON.stringify(r.errors, null, 2));
|
||||
assert.equal(r.valid, true);
|
||||
assert.equal(r.parsed.planVersion, '1.8');
|
||||
assert.equal(r.parsed.ceremony, 'architectural');
|
||||
});
|
||||
|
||||
test('1.8: a step without `Test first:` is rejected', () => {
|
||||
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node src/cli.mjs' }] }));
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(codes(r).includes('PLAN_STEP_NO_TEST_FIRST'), codes(r).join(','));
|
||||
assert.match(r.errors.find(e => e.code === 'PLAN_STEP_NO_TEST_FIRST').message, /Step 1/);
|
||||
});
|
||||
|
||||
test('1.8: `Test first:` must name a File and what it Verifies', () => {
|
||||
const plan = buildPlan().replace(/ - Verifies: run1\(\)[^\n]*\n/, '');
|
||||
const r = validatePlanContent(plan);
|
||||
assert.ok(codes(r).includes('PLAN_STEP_NO_TEST_FIRST'), codes(r).join(','));
|
||||
});
|
||||
|
||||
test('1.8: a plan without a "run the thing" step is rejected', () => {
|
||||
const r = validatePlanContent(buildPlan({ steps: [{}, {}] }));
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(codes(r).includes('PLAN_NO_END_TO_END'), codes(r).join(','));
|
||||
});
|
||||
|
||||
test('1.8: `End-to-end:` without a backticked command does not count', () => {
|
||||
const plan = buildPlan().replace(/- \*\*End-to-end:\*\* `[^`]+`/, '- **End-to-end:** run it and look');
|
||||
assert.ok(codes(validatePlanContent(plan)).includes('PLAN_NO_END_TO_END'));
|
||||
});
|
||||
|
||||
test('1.8: K4 fields are optional, but an empty one is rejected', () => {
|
||||
const withFields = buildPlan({ steps: [
|
||||
{ interfaceOut: '`run1(): number` read by Step 2', reviewFocus: 'empty input — pinned by `tests/mod1.test.mjs`' },
|
||||
{ interfaceIn: '`run1()` from Step 1', endToEnd: 'node src/cli.mjs' },
|
||||
] });
|
||||
assert.deepEqual(validatePlanContent(withFields).errors, []);
|
||||
const empty = buildPlan({ steps: [{ interfaceIn: '' }, { endToEnd: 'node src/cli.mjs' }] });
|
||||
assert.ok(codes(validatePlanContent(empty)).includes('PLAN_STEP_FIELD_EMPTY'));
|
||||
});
|
||||
|
||||
test('1.7 plans stay valid: no Test first, no End-to-end, no design required', () => {
|
||||
const plan = buildPlan({ version: '1.7', ceremony: null, design: false, steps: [{ testFirst: false }] });
|
||||
const r = validatePlanContent(plan);
|
||||
assert.deepEqual(r.errors, [], JSON.stringify(r.errors));
|
||||
assert.equal(r.warnings.find(w => w.code === 'PLAN_VERSION_MISMATCH'), undefined);
|
||||
});
|
||||
|
||||
test('a CRLF plan validates like its LF twin (the manifest parser used to fail on \\r)', () => {
|
||||
const r = validatePlanContent(buildPlan().replace(/\n/g, '\r\n'));
|
||||
assert.deepEqual(r.errors, [], JSON.stringify(r.errors));
|
||||
});
|
||||
|
||||
test('1.8: --soft downgrades the 1.8 rules to warnings', () => {
|
||||
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, {}] }), { strict: false });
|
||||
assert.equal(r.valid, true);
|
||||
const w = r.warnings.map(x => x.code);
|
||||
assert.ok(w.includes('PLAN_STEP_NO_TEST_FIRST') && w.includes('PLAN_NO_END_TO_END'), w.join(','));
|
||||
});
|
||||
|
||||
// --- design part (P2) gated on ceremony (K1) ---
|
||||
|
||||
test('ceremony: absent means architectural (heaviest on doubt), so the design part is required', () => {
|
||||
const r = validatePlanContent(buildPlan({ ceremony: null, design: false }));
|
||||
assert.ok(codes(r).includes('PLAN_DESIGN_MISSING'), codes(r).join(','));
|
||||
assert.equal(r.parsed.ceremony, 'architectural');
|
||||
});
|
||||
|
||||
test('ceremony: small and bounded plans need no design part', () => {
|
||||
for (const c of ['small', 'bounded']) {
|
||||
const r = validatePlanContent(buildPlan({ ceremony: c, design: false }));
|
||||
assert.deepEqual(r.errors, [], `${c}: ${JSON.stringify(r.errors)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ceremony: an unknown class is rejected', () => {
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ ceremony: 'huge' }))).includes('PLAN_CEREMONY_INVALID'));
|
||||
});
|
||||
|
||||
test('design part: every required subsection must be present', () => {
|
||||
const r = validatePlanContent(buildPlan({ designText: DESIGN.replace('### Data Flow', '### Flow of data') }));
|
||||
const e = r.errors.find(x => x.code === 'PLAN_DESIGN_SECTION_MISSING');
|
||||
assert.ok(e, codes(r).join(','));
|
||||
assert.match(e.message, /Data Flow/);
|
||||
});
|
||||
|
||||
test('design part: a cycle in the module map is rejected', () => {
|
||||
const cyclic = DESIGN.replace(' parser --> util[src/util.mjs]', ' parser --> util[src/util.mjs]\n util --> cli');
|
||||
const r = validatePlanContent(buildPlan({ designText: cyclic }));
|
||||
const e = r.errors.find(x => x.code === 'PLAN_MODULE_CYCLE');
|
||||
assert.ok(e, codes(r).join(','));
|
||||
assert.match(e.message, /cli -> parser -> util -> cli/);
|
||||
});
|
||||
|
||||
test('design part: every architecture rule names its enforcer', () => {
|
||||
const noEnforcer = DESIGN.replace('| Prefer pure functions in the parser | advisory — `.claude/rules/parser.md` | — |',
|
||||
'| Prefer pure functions in the parser | we will try | — |');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: noEnforcer }))).includes('PLAN_ARCH_RULE_NO_ENFORCER'));
|
||||
});
|
||||
|
||||
test('design part: a checked rule names the step that proves it, and the step exists', () => {
|
||||
const noProof = DESIGN.replace('src` | Step 1 |', 'src` | — |');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: noProof }))).includes('PLAN_ARCH_RULE_NO_PROOF'));
|
||||
const ghost = DESIGN.replace('src` | Step 1 |', 'src` | Step 9 |');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: ghost }))).includes('PLAN_ARCH_RULE_NO_PROOF'));
|
||||
});
|
||||
|
||||
test('design part: an advisory rule says which .claude/rules/ file carries it', () => {
|
||||
const nowhere = DESIGN.replace('advisory — `.claude/rules/parser.md`', 'advisory');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: nowhere }))).includes('PLAN_ARCH_RULE_NO_ENFORCER'));
|
||||
});
|
||||
|
||||
// --- parser units ---
|
||||
|
||||
test('parseStepFields reads bold and plain field bullets with their blocks', () => {
|
||||
const f = parseStepFields('### Step 1: x\n\n- **Files:** `a.ts`\n- Test first:\n - File: `t.ts`\n - Verifies: y\n- **Interface in:** `f()`\n');
|
||||
assert.equal(f.get('Files').inline, '`a.ts`');
|
||||
assert.match(f.get('Test first').text, /File: `t\.ts`/);
|
||||
assert.equal(f.get('Interface in').inline, '`f()`');
|
||||
});
|
||||
|
||||
test('parseStepFields stops at the next heading: a later section cannot lend the last step a field', () => {
|
||||
const f = parseStepFields('### Step 2: x\n\n- **Files:** `a.ts`\n\n## Verification\n\n- **End-to-end:** `npm start`\n');
|
||||
assert.equal(f.has('End-to-end'), false);
|
||||
const plan = buildPlan({ steps: [{}, {}] }).replace('## Verification\n', '## Verification\n\n- **End-to-end:** `npm start` → expected: up\n');
|
||||
assert.ok(codes(validatePlanContent(plan)).includes('PLAN_NO_END_TO_END'), 'only a step can be the run step');
|
||||
});
|
||||
|
||||
test('findModuleCycle finds a cycle through labelled and chained edges', () => {
|
||||
assert.equal(findModuleCycle('graph TD\n a --> b\n b -->|uses| c\n'), null);
|
||||
assert.deepEqual(findModuleCycle('graph LR\n a --> b --> c\n c -.-> a\n'), ['a', 'b', 'c', 'a']);
|
||||
});
|
||||
|
||||
// --- review findings (independent code review, 06.10) ---
|
||||
|
||||
test('ceremony is read only from frontmatter or the header line, never from prose', () => {
|
||||
const plan = buildPlan({ ceremony: null, design: false })
|
||||
.replace('Why: the brief asks for a parser CLI.', 'Why: the brief once said `ceremony: small`.\nceremony: small');
|
||||
const r = validatePlanContent(plan);
|
||||
assert.equal(r.parsed.ceremony, 'architectural');
|
||||
assert.ok(codes(r).includes('PLAN_DESIGN_MISSING'));
|
||||
});
|
||||
|
||||
test('module map: the edge forms mermaid allows are all read, so their cycles are caught', () => {
|
||||
const cyclic = [
|
||||
'a-->b\nb-->a',
|
||||
'a <--> b',
|
||||
'a ---> b\nb ===> a',
|
||||
'a -. uses .-> b\nb == calls ==> a',
|
||||
'a -- calls --> b\nb --> a',
|
||||
'a & c --> b\nb --> a',
|
||||
'a --> b; b --> a',
|
||||
'a:::hot --> b\nb --> a',
|
||||
'a{{hex}} --> b[[sub]]\nb --> a',
|
||||
'end-user --> api\napi --> end-user',
|
||||
'graph.mjs --> x\nx --> graph.mjs',
|
||||
];
|
||||
for (const body of cyclic) {
|
||||
assert.ok(findModuleCycle(`graph TD\n${body}\n`), `cycle not found in: ${JSON.stringify(body)}`);
|
||||
}
|
||||
assert.equal(findModuleCycle('graph TD\n a --- b\n b --> a\n'), null, 'an undirected link is not a dependency');
|
||||
});
|
||||
|
||||
test('module map: a link line the parser cannot read is an error, not silently dropped', () => {
|
||||
const odd = DESIGN.replace(' parser --> util[src/util.mjs]', ' parser --> util[src/util.mjs]\n util --> @weird id');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: odd }))).includes('PLAN_MODULE_MAP_UNPARSED'));
|
||||
});
|
||||
|
||||
test('module map: a cycle across two mermaid blocks is caught', () => {
|
||||
const two = DESIGN.replace(' parser --> util[src/util.mjs]\n```',
|
||||
' parser --> util[src/util.mjs]\n```\n\n```mermaid\ngraph TD\n util --> cli\n```');
|
||||
assert.ok(codes(validatePlanContent(buildPlan({ designText: two }))).includes('PLAN_MODULE_CYCLE'));
|
||||
});
|
||||
|
||||
test('findModuleCycle survives a 20 000-node chain (iterative, no stack overflow)', () => {
|
||||
const edges = [];
|
||||
for (let i = 0; i < 20000; i++) edges.push([`n${i}`, `n${i + 1}`]);
|
||||
assert.equal(findModuleCycle(edges), null);
|
||||
edges.push(['n20000', 'n0']);
|
||||
assert.equal(findModuleCycle(edges).length, 20002);
|
||||
});
|
||||
|
||||
test('Test first with an empty File: or Verifies: does not count', () => {
|
||||
const noPath = buildPlan().replace(' - File: `tests/mod1.test.mjs` (new)', ' - File:');
|
||||
assert.ok(codes(validatePlanContent(noPath)).includes('PLAN_STEP_NO_TEST_FIRST'));
|
||||
const noWhat = buildPlan().replace(/ - Verifies: run1\(\)[^\n]*/, ' - Verifies:');
|
||||
assert.ok(codes(validatePlanContent(noWhat)).includes('PLAN_STEP_NO_TEST_FIRST'));
|
||||
});
|
||||
|
||||
test('a column-0 # comment inside a fenced block does not end the step fields', () => {
|
||||
const f = parseStepFields('### Step 1: x\n\n- **Changes:** run\n ```bash\n# a comment\nnpm test\n ```\n- **Test first:**\n - File: `t.mjs`\n - Verifies: y\n');
|
||||
assert.ok(f.has('Test first'));
|
||||
});
|
||||
|
||||
test('a ~~~ line inside a ``` block does not end the fence (headings in code stay code)', () => {
|
||||
const t = '## A\n\n```md\n~~~\n## Not a heading\n~~~\n```\n\n## B\n';
|
||||
assert.deepEqual(splitSections(t, 2).filter(s => s.title).map(s => s.title), ['A', 'B']);
|
||||
});
|
||||
|
||||
test('K1 stub: ceremonyFromBrief reads the brief frontmatter; absent → architectural', () => {
|
||||
assert.equal(ceremonyFromBrief('---\ntype: brief\nceremony: small\n---\n# B\n').value, 'small');
|
||||
const none = ceremonyFromBrief('---\ntype: brief\n---\n# B\n');
|
||||
assert.equal(none.value, 'architectural');
|
||||
assert.equal(none.defaulted, true);
|
||||
assert.equal(resolveCeremony('huge').valid, false);
|
||||
});
|
||||
|
||||
// --- CLI: strict is the default, and the main-module guard survives a symlink ---
|
||||
|
||||
test('CLI --strict exits 1 on a 1.8 plan missing Test first; exits 0 through a symlinked path with a space', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'plan v18 '));
|
||||
try {
|
||||
const bad = join(dir, 'bad.md');
|
||||
writeFileSync(bad, buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] }));
|
||||
let code = 0;
|
||||
try { execFileSync('node', [VALIDATOR, '--strict', '--json', bad], { stdio: 'pipe' }); } catch (e) { code = e.status; }
|
||||
assert.equal(code, 1);
|
||||
const good = join(dir, 'good.md');
|
||||
writeFileSync(good, buildPlan());
|
||||
const link = join(dir, 'linked validator.mjs');
|
||||
symlinkSync(VALIDATOR, link);
|
||||
const out = execFileSync('node', [link, '--json', good], { encoding: 'utf-8' });
|
||||
assert.equal(JSON.parse(out).valid, true, 'symlinked CLI must run, not silently no-op');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue