feat(plan): the Test first File line follows the 1.9 grammar [skip-docs]

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

View file

@ -578,15 +578,41 @@ export function stepTestsOnly(body, version) {
// A line of the `Test first:` field that names its test files (6.3.1's selector).
const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/i;
// From plan_version 1.9: a line that opens with `File:` (after an optional
// list marker and `**`), backticked or not.
const TEST_FIRST_FILE_LINE_19 = /^\s*(?:[-*+]\s+)?(?:\*\*)?File:(?:\*\*)?/i;
/**
* The step's test files: every file on the `Test first:` `File:` line. A line
* counts as that line exactly when TEST_FIRST_FILE_LINE matches it. Below
* plan_version 1.9 (or without a version) every backticked span on the line,
* from the match on, that has the shape of a path is a test file (a call
* `parse()`, an option, a URL is not), normalised and deduplicated. A
* `Verifies:` line without a `File:` is never read. Every version reads this
* way until the 1.9 branch exists.
* plan_version 1.9: the rest of each `Test first:` `File:` line (after `File:`
* and any `**`), one entry per line, in order. The one selector both
* testFirstFiles and the plan validator's grammar check read.
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
* @returns {string[]}
*/
export function testFirstFileEntries(fields) {
if (!fields || !fields.has('Test first')) return [];
const out = [];
for (const line of String(fields.get('Test first').text ?? '').split('\n')) {
const at = TEST_FIRST_FILE_LINE_19.exec(line);
if (at) out.push(line.slice(at[0].length).trim());
}
return out;
}
/**
* The step's test files: every file on the `Test first:` `File:` line.
*
* Below plan_version 1.9 (or without a version) a line counts as that line
* exactly when TEST_FIRST_FILE_LINE matches it, and every backticked span on
* the line, from the match on, that has the shape of a path is a test file (a
* call `parse()`, an option, a URL is not).
*
* From 1.9 a line counts when it opens with `File:` (testFirstFileEntries),
* backticked or not, and its rest is one entry read by entryPaths: the
* leading backticked paths are the test files, the rest is description.
*
* Normalised, in order, deduplicated. A `Verifies:` line without a `File:` is
* never read.
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
* @param {string|null} [version] - the plan's plan_version
* @returns {string[]}
@ -594,6 +620,12 @@ const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/i;
export function testFirstFiles(fields, version) {
if (!fields || !fields.has('Test first')) return [];
const out = [];
if (versionAtLeast(version, '1.9')) {
for (const entry of testFirstFileEntries(fields)) {
for (const p of entryPaths(entry).files) if (!out.includes(p)) out.push(p);
}
return out;
}
for (const line of String(fields.get('Test first').text ?? '').split('\n')) {
const at = TEST_FIRST_FILE_LINE.exec(line);
if (!at) continue;

View file

@ -42,7 +42,9 @@
// 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).
// 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.
@ -53,6 +55,7 @@ import {
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
ON_FAILURE_ACTIONS, onFailureAction, stepTestsOnlyFromFields, entryPaths, untickedPaths,
testFirstFileEntries,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
@ -200,7 +203,8 @@ function checkRecovery(sections, opts, planVersion) {
/**
* 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.
* names no file. Each `Test first:` `File:` line (plan-schema
* `testFirstFileEntries`) is one entry under the same grammar.
*/
function checkPathGrammar(sections) {
const out = [];
@ -222,6 +226,23 @@ function checkPathGrammar(sections) {
'the description follows (`` `src/a.mjs` - the parser ``). A step that changes no file names none.'));
}
}
for (const entry of testFirstFileEntries(f)) {
for (const i of entryPaths(entry).issues) {
if (i.code === 'PLAN_STEP_PATH_NOT_A_PATH') {
out.push(issue('PLAN_STEP_PATH_NOT_A_PATH',
`Step ${s.n}: a \`Test first:\` \`File:\` line opens with a backticked span that is no path: ${entry}`,
'plan_version 1.9: the leading backticked spans of the `File:` line are the test 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',
entry === ''
? `Step ${s.n}: a \`Test first:\` \`File:\` line names no file`
: `Step ${s.n}: a \`Test first:\` \`File:\` line does not open with a backticked path: ${entry}`,
'plan_version 1.9: the `File:` line opens with its backticked test file(s), separated by commas or ' +
'spaces; the description follows (`` File: `tests/a.test.mjs` (new) ``).'));
}
}
}
}
return out;
}