feat(plan): read Files by the 1.9 grammar, warn on unticked paths below it [skip-docs]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-12 16:09:25 +02:00
commit 3e1a811c60
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 330 additions and 16 deletions

View file

@ -32,14 +32,27 @@
// /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).
// 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).
// - 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, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields,
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
@ -146,7 +159,7 @@ function checkRunnable(text, sections, opts) {
* checkpoint" in prose that nothing enforced (voyage-lekerepo trial 2,
* 2026-10-08). `Regression:` is that command, checked like `Verify:`.
*/
function checkRecovery(sections, opts) {
function checkRecovery(sections, opts, planVersion) {
const errors = [];
const warnings = [];
for (const s of sections) {
@ -165,7 +178,7 @@ function checkRecovery(sections, opts) {
`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).testsOnly) {
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')
@ -184,6 +197,35 @@ function checkRecovery(sections, opts) {
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.
*/
function checkPathGrammar(sections) {
const out = [];
for (const s of sections) {
const f = parseStepFields(s.body);
const { issues } = entryPaths(f.has('Files') ? f.get('Files').text : '');
for (const i of issues) {
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
`Step ${s.n}: a \`Files:\` entry opens with a backticked span that is no path: ${i.entry}`,
'plan_version 1.9: the leading backticked spans of an entry are its files; a call (`parse()`), an option ' +
'or a URL is description and goes after the path.'));
} else {
out.push(issue('PLAN_STEP_PATH_NOT_FIRST',
i.entry === ''
? `Step ${s.n}: \`Files:\` names no file`
: `Step ${s.n}: a \`Files:\` entry does not open with a backticked path: ${i.entry}`,
'plan_version 1.9: each `Files:` entry opens with its backticked path(s), separated by commas or spaces; ' +
'the description follows (`` `src/a.mjs` - the parser ``). A step that changes no file names none.'));
}
}
}
return out;
}
/** Table body rows (skips the header and the |---| separator). */
function tableRows(text) {
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
@ -289,11 +331,29 @@ export function validatePlanContent(rawText, opts = {}) {
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
}
const recovery = checkRecovery(sections, { cwd: opts.cwd });
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 (versionAtLeast(planVersion, '1.9')) {
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