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

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