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:
parent
8bf97cc834
commit
3e1a811c60
6 changed files with 330 additions and 16 deletions
|
|
@ -136,8 +136,9 @@ export function validatePlanHeadings(text, opts = {}) {
|
|||
// ---- plan_version 1.8 ------------------------------------------------------
|
||||
|
||||
export const PLAN_VERSION_CURRENT = '1.8';
|
||||
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on.
|
||||
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']);
|
||||
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on, the 1.9 `Files:`
|
||||
// grammar only from 1.9 on (see "plan_version 1.9" below).
|
||||
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8', '1.9']);
|
||||
|
||||
/** True when dotted version `v` is >= `min` (numeric per part; null → false). */
|
||||
export function versionAtLeast(v, min) {
|
||||
|
|
@ -461,6 +462,93 @@ export function fieldPaths(text) {
|
|||
return out.filter(Boolean).map(norm);
|
||||
}
|
||||
|
||||
// ---- plan_version 1.9 ------------------------------------------------------
|
||||
//
|
||||
// From plan_version 1.9 a step's `Files:` field has a grammar, so no reader has
|
||||
// to tell a path from a description in free text (decision 16, operator
|
||||
// 2026-10-11: only a 1.9 plan is refused; an older plan is read by fieldPaths
|
||||
// as before and gets one warning, PLAN_STEP_PATH_UNTICKED):
|
||||
//
|
||||
// - The entries are the field's first non-empty line and each list-item line
|
||||
// (`-`, `*`, `+`, `1.`, `1)`). Any other line continues the entry above it,
|
||||
// so a wrapped description line is no entry, and an empty first line before
|
||||
// a nested list is no entry either.
|
||||
// - An entry's files are the backticked spans it opens with, separated only by
|
||||
// commas or whitespace. Everything from the first other text on is
|
||||
// description and is never read ("`a.test.mjs` - covers `src/parse.mjs`"
|
||||
// names one file).
|
||||
// - An entry that does not open with a backticked span is refused
|
||||
// (PLAN_STEP_PATH_NOT_FIRST); so are `none` and a field that names no file.
|
||||
// A leading span that fails isPathShape (`parse()`, an option, a URL) is
|
||||
// refused as no path (PLAN_STEP_PATH_NOT_A_PATH).
|
||||
//
|
||||
// isPathShape is the test fieldPaths puts on a span in the leading position of
|
||||
// an entry (a path, a build file, or a capitalised name such as `LICENSE`), so
|
||||
// a 1.9 entry's leading span is judged exactly as the reader below 1.9 judges
|
||||
// one.
|
||||
|
||||
const LEADING_SPAN = /^`([^`\n]+)`/;
|
||||
|
||||
/** A backticked span in the leading position of an entry that names a file. */
|
||||
export function isPathShape(s) {
|
||||
const span = String(s ?? '').trim();
|
||||
return tickedPath(span) || BUILD_FILE.test(span) || BARE_NAME.test(span);
|
||||
}
|
||||
|
||||
/**
|
||||
* The files a 1.9 `Files:` field names, by the grammar above.
|
||||
* @param {string} text - the field's text (parseStepFields(body).get('Files').text)
|
||||
* @returns {{files: string[], issues: Array<{line: number, entry: string,
|
||||
* code: 'PLAN_STEP_PATH_NOT_FIRST'|'PLAN_STEP_PATH_NOT_A_PATH'}>}}
|
||||
* files normalised, in order, deduplicated; `line` is 1-based in `text`.
|
||||
*/
|
||||
export function entryPaths(text) {
|
||||
const files = [];
|
||||
const issues = [];
|
||||
const entries = [];
|
||||
String(text ?? '').split('\n').forEach((line, i) => {
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (item) entries.push({ line: i + 1, entry: line.slice(item[0].length).trim() });
|
||||
else if (entries.length === 0 && line.trim() !== '') entries.push({ line: i + 1, entry: line.trim() });
|
||||
});
|
||||
if (entries.length === 0) {
|
||||
issues.push({ line: 1, entry: '', code: 'PLAN_STEP_PATH_NOT_FIRST' });
|
||||
return { files, issues };
|
||||
}
|
||||
for (const { line, entry } of entries) {
|
||||
let rest = entry;
|
||||
let m = LEADING_SPAN.exec(rest);
|
||||
if (!m) {
|
||||
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_FIRST' });
|
||||
continue;
|
||||
}
|
||||
while (m) {
|
||||
const span = m[1].trim();
|
||||
if (isPathShape(span)) {
|
||||
const p = norm(span);
|
||||
if (!files.includes(p)) files.push(p);
|
||||
} else {
|
||||
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_A_PATH' });
|
||||
}
|
||||
rest = rest.slice(m[0].length);
|
||||
const sep = /^[\s,]+/.exec(rest);
|
||||
m = sep ? LEADING_SPAN.exec(rest.slice(sep[0].length)) : null;
|
||||
if (m) rest = rest.slice(sep[0].length);
|
||||
}
|
||||
}
|
||||
return { files, issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths fieldPaths reads from `text` that do not stand in backticks: the
|
||||
* paths below plan_version 1.9 that a 1.9 plan would refuse.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function untickedPaths(text) {
|
||||
const ticked = new Set([...String(text ?? '').matchAll(/`([^`\n]+)`/g)].map((m) => norm(m[1].trim())));
|
||||
return fieldPaths(text).filter((p) => !ticked.has(p));
|
||||
}
|
||||
|
||||
// A document or a data file outside tests/ is never the test here:
|
||||
// `docs/specs/api.md` and `api/specs/openapi.yaml` sit under a `specs/` folder,
|
||||
// which lib/util/test-path.mjs reads as tests for the floor guard. Under tests/
|
||||
|
|
@ -478,12 +566,14 @@ const UNDER_TESTS_RE = /(^|\/)(tests?|__tests__)\//;
|
|||
* trial 3, 2026-10-09: `not-red-at-base`, stop class 4). The plan validator
|
||||
* warns on it (PLAN_STEP_TESTS_ONLY) and red-proof.mjs reports it
|
||||
* (`tests_only`), so the executor sends the step up to the plan. A step
|
||||
* without `Files:` is not such a step.
|
||||
* without `Files:` is not such a step. From plan_version 1.9 `Files:` is read
|
||||
* by its grammar (entryPaths); below 1.9 (or without a version) by fieldPaths.
|
||||
* @param {string} body - the step's section (sliceSteps)
|
||||
* @param {string|null} [version] - the plan's plan_version
|
||||
* @returns {{testsOnly: boolean, files: string[]}}
|
||||
*/
|
||||
export function stepTestsOnly(body) {
|
||||
return stepTestsOnlyFromFields(parseStepFields(body));
|
||||
export function stepTestsOnly(body, version) {
|
||||
return stepTestsOnlyFromFields(parseStepFields(body), version);
|
||||
}
|
||||
|
||||
// A line of the `Test first:` field that names its test files (6.3.1's selector).
|
||||
|
|
@ -529,9 +619,10 @@ function stepTestFile(f, version) {
|
|||
}
|
||||
|
||||
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
|
||||
export function stepTestsOnlyFromFields(f) {
|
||||
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
|
||||
return { testsOnly: files.length > 0 && files.every(stepTestFile(f)), files };
|
||||
export function stepTestsOnlyFromFields(f, version) {
|
||||
const text = f.has('Files') ? f.get('Files').text : null;
|
||||
const files = text === null ? [] : versionAtLeast(version, '1.9') ? entryPaths(text).files : fieldPaths(text);
|
||||
return { testsOnly: files.length > 0 && files.every(stepTestFile(f, version)), files };
|
||||
}
|
||||
|
||||
// ---- sections (fence-aware) ------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue