/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery). - coordinator-contract: engine names come from engine-findings ENGINES; one markedMajorCodeReview predicate under both lowering checks; isEngineFinding everywhere; the unreachable branch (c) of illegalBriefLowering folded into (e). - armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a literal pinned equal to ENGINES by the workflow test. - plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep, testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk. - plan-validator: one issue builder for the Files and Test first grammar. - plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else. - artifact-commit: TRAILER_RE/trailerProblem no longer exported (no importer); the order comment says the CLI finds the repo root first. - tests: the lock drivers in session-id-isolation (d) run side by side per mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and extractFindingsBlock reused; childEnv(env) without a spread; one copy of the PLAN_VERSION_CURRENT check. Plan readers: validator errors/warnings, plannedPaths, fieldPaths, testFirstFiles and stepTestsOnlyFromFields compared byte for byte against HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same. Suite 2384 (2382/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
24 KiB
JavaScript
488 lines
24 KiB
JavaScript
// lib/validators/plan-validator.mjs
|
|
// Wraps plan-schema (heading shape) + manifest-yaml (per-step Manifest blocks).
|
|
// This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator.
|
|
//
|
|
// Schema is forward-compatible: unknown top-level frontmatter keys are
|
|
// tolerated silently. Strict-key checks are intentionally avoided so new
|
|
// optional fields (jf. the source_findings precedent) can be added without
|
|
// a plan_version bump.
|
|
//
|
|
// plan_version 1.8 rules (apply only to plans declaring >= 1.8; 1.7 plans
|
|
// validate exactly as before):
|
|
// - every step has `Test first:` naming a File and what it Verifies;
|
|
// - the plan has a "run the thing" step: an `End-to-end:` field with a
|
|
// backticked command (without it a result is "not verified end to end");
|
|
// - the optional step fields `Interface in:`, `Interface out:`,
|
|
// `Review focus:` are never empty when present;
|
|
// - `ceremony:` is small | bounded | architectural (absent = architectural);
|
|
// an architectural plan carries `## Design` (see checkDesign);
|
|
// - every check the run will execute can run (checkRunnable): each
|
|
// `## Verification` criterion, each step's `Verify:` (its first backticked
|
|
// span is the step's test command) and each `End-to-end:` command pass the
|
|
// criteria runner's allowlist, and each `End-to-end:` command also stands
|
|
// under `## Verification`, where Phase 7 runs it.
|
|
// Strict mode reports these as errors; --soft downgrades them to warnings.
|
|
//
|
|
// Every plan version (the fields are new; a plan without them is unchanged):
|
|
// - K10: the optional step field `Regression:` holds ONE command the
|
|
// criteria runner's allowlist runs (first backticked span, no pipe, no
|
|
// `&&`); the executor runs it green before the step's checkpoint. One that
|
|
// can never run is an error (a warning under --soft).
|
|
// - K11: `On failure:` opens with revert | retry | skip | escalate, the words
|
|
// /trekexecute reads; any other opening is a warning.
|
|
// - A step whose `Files:` are all tests (plan-schema `stepTestsOnly`) is the
|
|
// warning PLAN_STEP_TESTS_ONLY: its tests pass at its start point, so it can
|
|
// never be proven red (voyage-lekerepo trial 3, 2026-10-09). The check reads
|
|
// `Files:` by the plan's version (the 1.9 grammar from 1.9 on).
|
|
//
|
|
// plan_version 1.9 rules (decision 16, operator 2026-10-11: an unticked path in
|
|
// `Files:` is refused only from 1.9; older plans are read as before):
|
|
// - every `Files:` entry opens with its backticked path(s); the rest of the
|
|
// entry is description (plan-schema `entryPaths`). An entry that opens with
|
|
// anything else, `none`, and an empty or absent `Files:` are
|
|
// PLAN_STEP_PATH_NOT_FIRST; a leading span that is no path (`parse()`, an
|
|
// option, a URL) is PLAN_STEP_PATH_NOT_A_PATH. Errors under strict,
|
|
// warnings under --soft (checkPathGrammar). Each `Test first:` `File:`
|
|
// line (a line that opens with `File:`, backticked or not) is one entry
|
|
// under the same grammar and raises the same two codes.
|
|
// - below 1.9, a step whose `Files:` reader (plan-schema `fieldPaths`) finds a
|
|
// path outside backticks gets the warning PLAN_STEP_PATH_UNTICKED, under
|
|
// --strict too; the plan is read and gated as before.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import {
|
|
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
|
|
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
|
|
versionAtLeast, usesFilesGrammar, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
|
|
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
|
|
testFirstFileEntries, testFirstFiles,
|
|
} from '../parsers/plan-schema.mjs';
|
|
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
|
import { parseDocument } from '../util/frontmatter.mjs';
|
|
import { issue, fail } from '../util/result.mjs';
|
|
import { parsePlanVerification, firstCommand, runnableCheck, refusal } from '../verification/criteria-runner.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']);
|
|
export const DESIGN_SUBSECTIONS = Object.freeze([
|
|
'Architecture', 'Data Flow', 'Interfaces', 'Data Models', 'Error Handling',
|
|
'Module Map', 'Architecture Rules', 'Requirements Check',
|
|
]);
|
|
const BACKTICKED = /`[^`\n]+`/;
|
|
|
|
// From 1.9 the step names its test the way the executor reads it
|
|
// (testFirstFiles): only through a line that opens with `File:` and its
|
|
// backticked path, so a `File:` mid-line names no test. Below 1.9 the
|
|
// unanchored test stands (decision 16: older plans are read as before).
|
|
function checkSteps(sections, planVersion) {
|
|
const out = [];
|
|
let endToEnd = 0;
|
|
const grammar = usesFilesGrammar(planVersion);
|
|
for (const s of sections) {
|
|
const f = parseStepFields(s.body);
|
|
const tf = f.get('Test first');
|
|
const file = grammar
|
|
? testFirstFiles(f, planVersion).length > 0
|
|
: /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf?.text ?? '');
|
|
const names = tf && file && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
|
|
if (!names) {
|
|
out.push(issue(
|
|
'PLAN_STEP_NO_TEST_FIRST',
|
|
`Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`,
|
|
grammar
|
|
? 'plan_version 1.9 requires `Test first:` on every step, with a line that opens with `File:` and its ' +
|
|
'backticked path, and a `Verifies:` line.'
|
|
: 'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
|
|
));
|
|
}
|
|
for (const name of OPTIONAL_STEP_FIELDS) {
|
|
if (f.has(name) && f.get(name).text === '') {
|
|
out.push(issue('PLAN_STEP_FIELD_EMPTY', `Step ${s.n}: \`${name}:\` is present but empty`,
|
|
'Fill the field or remove it; it is optional.'));
|
|
}
|
|
}
|
|
if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) endToEnd += 1;
|
|
}
|
|
if (sections.length > 0 && endToEnd === 0) {
|
|
out.push(issue(
|
|
'PLAN_NO_END_TO_END',
|
|
'No "run the thing" step: no step carries `End-to-end:` with a backticked command',
|
|
'Add a step that runs the product the way a user does (`End-to-end: `<command>` → expected: …`). ' +
|
|
'Without it the result is "not verified end to end", never green.',
|
|
));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const WAY_IN = 'A check the allowlist has no name for (a pipe, `jq`, `grep`, the product\'s own binary) is ' +
|
|
'wrapped in a script under tests/ and declared as `bash tests/<script>.sh`.';
|
|
|
|
/**
|
|
* Every check the run will execute can run. Without this a plan passed the
|
|
* validator, the plan gate and three critic rounds with a `git grep` criterion
|
|
* the runner refuses, and Phase 7 felled a run whose steps had all passed
|
|
* (voyage-lekerepo trial, 2026-10-06).
|
|
*/
|
|
function checkRunnable(text, sections, opts) {
|
|
const out = [];
|
|
const criteria = parsePlanVerification(text);
|
|
// opts.stepsOnly: only the given steps are judged; ## Verification is read
|
|
// for the commands it lists, never reported on (the --steps-from path).
|
|
for (const c of opts.stepsOnly ? [] : criteria) {
|
|
const r = runnableCheck(c, opts);
|
|
if (!r.runnable) {
|
|
out.push(issue('PLAN_VERIFICATION_NOT_RUNNABLE',
|
|
`## Verification ${c.label} can never run (${r.reason}): ${c.text}`,
|
|
`Phase 7 runs every criterion and fells the run on one it cannot run. ${WAY_IN}`));
|
|
}
|
|
}
|
|
const verified = new Set(criteria.filter(c => !refusal(c)).map(c => c.command.trim().replace(/\s+/g, ' ')));
|
|
for (const s of sections) {
|
|
const f = parseStepFields(s.body);
|
|
if (f.has('Verify')) {
|
|
const v = firstCommand(f.get('Verify').text);
|
|
const r = runnableCheck(v, opts);
|
|
if (!r.runnable) {
|
|
out.push(issue('PLAN_STEP_VERIFY_NOT_RUNNABLE',
|
|
`Step ${s.n}: \`Verify:\` does not open with one runnable test command (${r.reason})`,
|
|
'The first backticked span of `Verify:` is the step\'s test command: proof of red and the done line run it, ' +
|
|
`and nothing after it is run. ${WAY_IN}`));
|
|
}
|
|
}
|
|
if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) {
|
|
const e = firstCommand(f.get('End-to-end').text);
|
|
const r = runnableCheck(e, opts);
|
|
if (!r.runnable) {
|
|
out.push(issue('PLAN_END_TO_END_NOT_RUNNABLE',
|
|
`Step ${s.n}: \`End-to-end:\` command can never run (${r.reason})`,
|
|
`Run the product from a script: \`bash tests/e2e.sh\`. ${WAY_IN}`));
|
|
} else if (!verified.has(e.command.trim().replace(/\s+/g, ' '))) {
|
|
out.push(issue('PLAN_END_TO_END_NOT_VERIFIED',
|
|
`Step ${s.n}: \`End-to-end:\` command \`${e.command}\` does not stand under ## Verification`,
|
|
'Phase 7 runs `## Verification`; list the same command there, or the result is "not verified end to end".'));
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* K10 and K11. `Verify:` is both the step's one test command and its proof of
|
|
* red, so a step whose red test is a shell script could not also run the frozen
|
|
* suite before its checkpoint; the plan wrote "run npm test before the
|
|
* checkpoint" in prose that nothing enforced (voyage-lekerepo trial 2,
|
|
* 2026-10-08). `Regression:` is that command, checked like `Verify:`.
|
|
*/
|
|
function checkRecovery(sections, opts, planVersion) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
for (const s of sections) {
|
|
const f = parseStepFields(s.body);
|
|
if (f.has('Regression')) {
|
|
const r = runnableCheck(firstCommand(f.get('Regression').text), opts);
|
|
if (!r.runnable) {
|
|
errors.push(issue('PLAN_STEP_REGRESSION_NOT_RUNNABLE',
|
|
`Step ${s.n}: \`Regression:\` is not one runnable command (${r.reason})`,
|
|
'Backtick ONE command the criteria runner allows, unpiped and without `&&` (`npm test`); ' +
|
|
`the executor runs it green before the checkpoint. ${WAY_IN}`));
|
|
}
|
|
}
|
|
if (f.has('On failure') && onFailureAction(f.get('On failure').text) === null) {
|
|
warnings.push(issue('PLAN_STEP_ON_FAILURE_UNKNOWN',
|
|
`Step ${s.n}: \`On failure:\` does not open with a recovery word the executor reads`,
|
|
`Open with one of ${ON_FAILURE_ACTIONS.join(' | ')}, then the specifics (\`retry once, then escalate\`).`));
|
|
}
|
|
if (stepTestsOnlyFromFields(f, planVersion).testsOnly) {
|
|
// The run-the-thing script alone is such a step too: `End-to-end:` rides on
|
|
// the last step that builds behaviour (release review of 6.3.0, 2026-10-10).
|
|
const e2e = f.has('End-to-end')
|
|
? ' `End-to-end:` rides on the last step that builds behaviour, with its script in that step\'s `Files:`, ' +
|
|
'not on a step of its own.'
|
|
: '';
|
|
warnings.push(issue('PLAN_STEP_TESTS_ONLY',
|
|
`Step ${s.n}: every file in \`Files:\` is a test; a step of only tests can never be proven red`,
|
|
'The test for a behaviour belongs in the step that builds it: at this step\'s start point the behaviour ' +
|
|
'already exists (an earlier step built it, or it was there before the plan), so its test passes there and ' +
|
|
'red proof rejects it (`not-red-at-base`). Move the test into the step that builds the behaviour; a test ' +
|
|
'meant to pass from the start (a golden or characterization test) is the `Regression:` of the step that ' +
|
|
'changes the code, not a step of its own.' + e2e));
|
|
}
|
|
}
|
|
return { errors, warnings };
|
|
}
|
|
|
|
/**
|
|
* plan_version 1.9: every `Files:` entry opens with its backticked path(s)
|
|
* (plan-schema `entryPaths`). An absent `Files:` is read as empty text, so it
|
|
* names no file. Each `Test first:` `File:` line (plan-schema
|
|
* `testFirstFileEntries`) is one entry under the same grammar.
|
|
*/
|
|
// How checkPathGrammar words an issue in each of the two places the grammar applies.
|
|
const FILES_WORDING = {
|
|
subject: 'a `Files:` entry', none: '`Files:` names no file', spans: 'an entry are its files',
|
|
opens: 'each `Files:` entry opens with its backticked path(s)',
|
|
example: '`` `src/a.mjs` - the parser ``', tail: ' A step that changes no file names none.',
|
|
};
|
|
const TEST_FIRST_WORDING = {
|
|
subject: 'a `Test first:` `File:` line', none: 'a `Test first:` `File:` line names no file',
|
|
spans: 'the `File:` line are the test files', opens: 'the `File:` line opens with its backticked test file(s)',
|
|
example: '`` File: `tests/a.test.mjs` (new) ``', tail: '',
|
|
};
|
|
|
|
function pathGrammarIssue(n, code, shown, w) {
|
|
if (code === 'PLAN_STEP_PATH_NOT_A_PATH') {
|
|
return issue(code, `Step ${n}: ${w.subject} opens with a backticked span that is no path: ${shown}`,
|
|
`plan_version 1.9: the leading backticked spans of ${w.spans}; a call (\`parse()\`), an option ` +
|
|
'or a URL is description and goes after the path.');
|
|
}
|
|
return issue('PLAN_STEP_PATH_NOT_FIRST',
|
|
shown === '' ? `Step ${n}: ${w.none}` : `Step ${n}: ${w.subject} does not open with a backticked path: ${shown}`,
|
|
`plan_version 1.9: ${w.opens}, separated by commas or spaces; the description follows (${w.example}).${w.tail}`);
|
|
}
|
|
|
|
function checkPathGrammar(sections) {
|
|
const out = [];
|
|
for (const s of sections) {
|
|
const f = parseStepFields(s.body);
|
|
for (const i of entryPaths(f.has('Files') ? f.get('Files').text : '').issues) {
|
|
out.push(pathGrammarIssue(s.n, i.code, i.entry, FILES_WORDING));
|
|
}
|
|
for (const entry of testFirstFileEntries(f)) {
|
|
for (const i of entryPaths(entry).issues) out.push(pathGrammarIssue(s.n, i.code, entry, TEST_FIRST_WORDING));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Table body rows (skips the header and the |---| separator). */
|
|
function tableRows(text) {
|
|
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
|
|
return rows.slice(2).map(r => r.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()));
|
|
}
|
|
|
|
function checkDesign(text, stepNumbers) {
|
|
const out = [];
|
|
const design = findSection(text, 'Design', 2);
|
|
if (!design) {
|
|
return [issue('PLAN_DESIGN_MISSING', 'Architectural plan without a `## Design` section',
|
|
'Add the design part (templates/design-template.md), or plan under `ceremony: small|bounded` if the brief says so.')];
|
|
}
|
|
const subs = splitSections(design.text, 3);
|
|
const titles = new Set(subs.filter(s => s.title).map(s => s.title.toLowerCase()));
|
|
const missing = DESIGN_SUBSECTIONS.filter(t => !titles.has(t.toLowerCase()));
|
|
if (missing.length > 0) {
|
|
out.push(issue('PLAN_DESIGN_SECTION_MISSING', `## Design lacks: ${missing.join(', ')}`,
|
|
`Required subsections: ${DESIGN_SUBSECTIONS.join(', ')}.`));
|
|
}
|
|
if (!findSection(text, 'Test Strategy', 2)) {
|
|
out.push(issue('PLAN_DESIGN_SECTION_MISSING', 'Architectural plan lacks `## Test Strategy`',
|
|
'The design part\'s test strategy lives in the plan\'s `## Test Strategy` section.'));
|
|
}
|
|
const sub = t => subs.find(s => s.title && s.title.toLowerCase() === t.toLowerCase());
|
|
|
|
const map = sub('Module Map');
|
|
if (map) {
|
|
const blocks = fencedBlocks(map.text, 'mermaid');
|
|
if (blocks.length === 0) {
|
|
out.push(issue('PLAN_MODULE_MAP_EMPTY', '### Module Map has no ```mermaid block',
|
|
'Draw the modules and their dependency direction as a mermaid graph.'));
|
|
}
|
|
// All blocks form one map: a cycle may run across two of them.
|
|
const parsed = blocks.map(b => parseMermaid(b));
|
|
const unparsed = parsed.flatMap(p => p.unparsed);
|
|
if (unparsed.length > 0) {
|
|
out.push(issue('PLAN_MODULE_MAP_UNPARSED', `Module map lines that look like links but give no edge: ${unparsed.join(' | ')}`,
|
|
'Write links as `a --> b` (ids of letters, digits, _ . - /); the validator cannot check what it cannot read.'));
|
|
}
|
|
const cycle = findModuleCycle(parsed.flatMap(p => p.edges));
|
|
if (cycle) {
|
|
out.push(issue('PLAN_MODULE_CYCLE', `Module map has a dependency cycle: ${cycle.join(' -> ')}`,
|
|
'Dependencies point one way. Break the cycle (extract the shared part, or invert one edge).'));
|
|
}
|
|
}
|
|
|
|
const rules = sub('Architecture Rules');
|
|
if (rules) {
|
|
const rows = tableRows(rules.text);
|
|
if (rows.length === 0) {
|
|
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', '### Architecture Rules lists no rule',
|
|
'List at least one rule as a table row: | Rule | Enforced by | Proof |.'));
|
|
}
|
|
for (const row of rows) {
|
|
const line = row.join(' | ');
|
|
const checked = /checked by\s+`[^`]+`/i.test(line);
|
|
const advisory = /\badvisory\b/i.test(line) && /\.claude\/rules\/[^\s`|]+\.md/.test(line);
|
|
if (!checked && !advisory) {
|
|
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', `Rule without an enforcer: ${row[0] || line}`,
|
|
'Each rule says "checked by `<command>`" or "advisory — `.claude/rules/<file>.md`".'));
|
|
continue;
|
|
}
|
|
if (checked) {
|
|
const proof = (row[2] ?? '').match(/Step (\d+)/);
|
|
if (!proof || !stepNumbers.includes(Number(proof[1]))) {
|
|
out.push(issue('PLAN_ARCH_RULE_NO_PROOF',
|
|
`Checked rule without an existing proving step: ${row[0] || line}`,
|
|
'Name the step (Proof column, "Step N") that shows the check pass, fail on a planted violation, and pass again.'));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function validatePlanContent(rawText, opts = {}) {
|
|
const strict = opts.strict !== false;
|
|
// CRLF plans (an editor on Windows) used to fail every manifest with
|
|
// FM_SYNTAX: the YAML line parser does not expect a trailing \r.
|
|
const text = typeof rawText === 'string' ? rawText.replace(/\r\n?/g, '\n') : rawText;
|
|
const headRes = validatePlanHeadings(text, { strict });
|
|
const errors = [...headRes.errors];
|
|
const warnings = [...headRes.warnings];
|
|
|
|
const steps = headRes.parsed?.steps || [];
|
|
const sections = sliceSteps(text);
|
|
const manRes = validateAllManifests(sections);
|
|
errors.push(...manRes.errors);
|
|
warnings.push(...manRes.warnings);
|
|
|
|
if (steps.length > 0 && manRes.parsed.length !== steps.length) {
|
|
errors.push(issue(
|
|
'PLAN_MANIFEST_COUNT_MISMATCH',
|
|
`Step count (${steps.length}) does not equal manifest count (${manRes.parsed.length})`,
|
|
));
|
|
}
|
|
|
|
const planVersion = extractPlanVersion(text);
|
|
if (planVersion === null) {
|
|
warnings.push(issue('PLAN_NO_VERSION', `No plan_version detected; current target is ${PLAN_VERSION_CURRENT}`));
|
|
} else if (!PLAN_VERSIONS_SUPPORTED.includes(planVersion)) {
|
|
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
|
|
}
|
|
|
|
const recovery = checkRecovery(sections, { cwd: opts.cwd }, planVersion);
|
|
if (strict) errors.push(...recovery.errors);
|
|
else warnings.push(...recovery.errors);
|
|
warnings.push(...recovery.warnings);
|
|
|
|
// The `Files:` grammar (decision 16): refused from 1.9, a warning below it.
|
|
if (usesFilesGrammar(planVersion)) {
|
|
const grammar = checkPathGrammar(sections);
|
|
if (strict) errors.push(...grammar);
|
|
else warnings.push(...grammar);
|
|
} else {
|
|
for (const s of sections) {
|
|
const f = parseStepFields(s.body);
|
|
const unticked = f.has('Files') ? [...new Set(untickedPaths(f.get('Files').text))] : [];
|
|
if (unticked.length > 0) {
|
|
warnings.push(issue('PLAN_STEP_PATH_UNTICKED',
|
|
`Step ${s.n}: \`Files:\` names ${unticked.join(', ')} outside backticks; read as before`,
|
|
'From plan_version 1.9 an unticked path in `Files:` is refused: each entry opens with its backticked ' +
|
|
'path(s), and the description follows.'));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Steps a run appended (the review loop's remediation steps, /trekexecute
|
|
// Phase 7.2) must be runnable steps whatever the plan's version: below 1.8
|
|
// the step rules never ran, and a review step whose Verify is "re-run
|
|
// /trekreview" validated and was executed (release review of 6.3.0,
|
|
// 2026-10-10). From 1.8 the rules below cover every step except one: none of
|
|
// them asks for a `Verify:`, so an appended step needs one on any version
|
|
// (round 3).
|
|
const stepsFrom = Number.isInteger(opts.stepsFrom) && opts.stepsFrom > 0 ? opts.stepsFrom : null;
|
|
if (stepsFrom !== null) {
|
|
const appended = sections.filter((s) => Number(s.n) >= stepsFrom);
|
|
const stepIssues = versionAtLeast(planVersion, '1.8') ? [] : [
|
|
...checkSteps(appended).filter((i) => i.code !== 'PLAN_NO_END_TO_END'),
|
|
...checkRunnable(text, appended, { cwd: opts.cwd, stepsOnly: true }),
|
|
];
|
|
for (const s of appended) {
|
|
if (!parseStepFields(s.body).has('Verify')) {
|
|
stepIssues.push(issue('PLAN_STEP_VERIFY_NOT_RUNNABLE', `Step ${s.n}: no \`Verify:\` field`,
|
|
'An appended step needs one test command: proof of red and the done line run it.'));
|
|
}
|
|
}
|
|
if (strict) errors.push(...stepIssues);
|
|
else warnings.push(...stepIssues);
|
|
}
|
|
|
|
const ceremony = extractPlanCeremony(text);
|
|
if (versionAtLeast(planVersion, '1.8')) {
|
|
const v18 = [...checkSteps(sections, planVersion), ...checkRunnable(text, sections, { cwd: opts.cwd })];
|
|
if (!ceremony.valid) {
|
|
v18.push(issue('PLAN_CEREMONY_INVALID', `ceremony=${ceremony.raw}`,
|
|
`Allowed: ${CEREMONY_VALUES.join(' | ')} (absent = architectural).`));
|
|
} else if (ceremony.value === 'architectural') {
|
|
v18.push(...checkDesign(text, steps.map(s => s.n)));
|
|
}
|
|
if (strict) errors.push(...v18);
|
|
else warnings.push(...v18);
|
|
}
|
|
|
|
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
|
|
// If plan frontmatter declares `profile: <name>` and any step manifest
|
|
// declares `profile_used: <other>`, emit a warning (NOT an error) so
|
|
// operators see drift but parsing remains forward-compat.
|
|
if (strict) {
|
|
const planFm = parseDocument(text).parsed?.frontmatter;
|
|
const planProfile =
|
|
planFm && typeof planFm.profile === 'string' ? planFm.profile : null;
|
|
if (planProfile) {
|
|
for (const m of manRes.parsed) {
|
|
const stepProfile = m.manifest && m.manifest.profile_used;
|
|
if (typeof stepProfile === 'string' && stepProfile !== planProfile) {
|
|
warnings.push(issue(
|
|
'MANIFEST_PROFILE_DRIFT',
|
|
`step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`,
|
|
'A step manifest declares a different profile than the plan frontmatter; ' +
|
|
'verify whether this is intentional (manual override) or accidental drift.',
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
parsed: { steps, manifests: manRes.parsed, planVersion, ceremony: ceremony.value },
|
|
};
|
|
}
|
|
|
|
export function validatePlan(filePath, opts = {}) {
|
|
if (!existsSync(filePath)) return fail(issue('PLAN_NOT_FOUND', `File not found: ${filePath}`));
|
|
let text;
|
|
try { text = readFileSync(filePath, 'utf-8'); }
|
|
catch (e) { return fail(issue('PLAN_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
|
|
const r = validatePlanContent(text, opts);
|
|
return { ...r, parsed: { ...r.parsed, filePath } };
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const strict = !args.includes('--soft');
|
|
const at = args.indexOf('--steps-from');
|
|
const stepsFrom = at >= 0 ? Number(args[at + 1]) : null;
|
|
const filePath = args.find((a, i) => !a.startsWith('--') && !(at >= 0 && i === at + 1));
|
|
if (!filePath || (at >= 0 && !(Number.isInteger(stepsFrom) && stepsFrom > 0))) {
|
|
process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] [--json] [--steps-from <N>] <plan.md>\n');
|
|
process.exit(2);
|
|
}
|
|
const r = validatePlan(filePath, { strict, stepsFrom });
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify({
|
|
valid: r.valid,
|
|
errors: r.errors,
|
|
warnings: r.warnings,
|
|
steps: r.parsed?.steps?.length ?? 0,
|
|
planVersion: r.parsed?.planVersion ?? null,
|
|
ceremony: r.parsed?.ceremony ?? null,
|
|
}, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);
|
|
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
|
|
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
|
|
}
|
|
process.exit(r.valid ? 0 : 1);
|
|
}
|