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
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