merge: wave 1 order 3 (plan and design) into main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:36:31 +02:00
commit 885d515c3b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
24 changed files with 3115 additions and 61 deletions

View 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'));
});

View 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 });
}
});