fix(plan): below 1.9 the step's test files are the paths right after File:

Operator decision 20 (12.10); review finding 6c31212c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-13 12:34:13 +02:00
commit 1320e3a4d5
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 92 additions and 27 deletions

View file

@ -499,6 +499,27 @@ export function isPathShape(s) {
return tickedPath(span) || BUILD_FILE.test(span) || BARE_NAME.test(span);
}
/**
* The trimmed contents of the backticked spans `text` opens with, each
* separated from the next only by commas or whitespace; empty when `text` does
* not open with a backticked span. The caller judges each span.
* @param {string} text
* @returns {string[]}
*/
function leadingSpans(text) {
const out = [];
let rest = String(text ?? '');
let m = LEADING_SPAN.exec(rest);
while (m) {
out.push(m[1].trim());
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 out;
}
/**
* The files a 1.9 `Files:` field names, by the grammar above.
* @param {string} text - the field's text (parseStepFields(body).get('Files').text)
@ -520,24 +541,18 @@ export function entryPaths(text) {
return { files, issues };
}
for (const { line, entry } of entries) {
let rest = entry;
let m = LEADING_SPAN.exec(rest);
if (!m) {
const spans = leadingSpans(entry);
if (spans.length === 0) {
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_FIRST' });
continue;
}
while (m) {
const span = m[1].trim();
for (const span of spans) {
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 };
@ -580,8 +595,9 @@ 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).
const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/i;
// A line of the `Test first:` field that names its test files (6.3.1's
// selector); global, so read it with matchAll only: one match per `File:`.
const TEST_FIRST_FILE_LINE = /\bFile:\**[ \t]*`/gi;
// 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;
@ -607,9 +623,15 @@ export function testFirstFileEntries(fields) {
* 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).
* exactly when TEST_FIRST_FILE_LINE matches it. Each `File:` marker on the
* line opens a run: the backticked spans that stand right after it, separated
* only by commas or whitespace (leadingSpans), and every span in that run with
* the shape of a path is a test file (a call `parse()`, an option, a URL is
* skipped and the run goes on). A path in the description after the run is no
* test file (operator decision 20, 12.10; review `6c31212c`: "covers `parse()`
* in `src/parser.mjs`" made the source a test and the step tests-only). The
* markers are matched globally because 6.3.1 read the first span after each
* `File:`, so a line with two markers keeps at least 6.3.1's files.
*
* 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
@ -631,13 +653,12 @@ export function testFirstFiles(fields, version) {
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;
for (const m of line.slice(at.index).matchAll(/`([^`\n]+)`/g)) {
const s = m[1].trim();
if (!(tickedPath(s) || BUILD_FILE.test(s))) continue;
const p = norm(s);
if (!out.includes(p)) out.push(p);
for (const at of line.matchAll(TEST_FIRST_FILE_LINE)) {
for (const s of leadingSpans(line.slice(at.index + at[0].length - 1))) {
if (!(tickedPath(s) || BUILD_FILE.test(s))) continue;
const p = norm(s);
if (!out.includes(p)) out.push(p);
}
}
}
return out;

View file

@ -105,28 +105,28 @@ test('1.9: every leading backticked path on the File: line is a test file', () =
assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs', 'tests/b.test.mjs']);
});
test('1.9: a backticked path in the description of the File: line is not a test file', () => {
test('decision 20: a backticked path in the description of the File: line is not a test file, at 1.9 and below', () => {
const body = step('`tests/a.test.mjs`', [
' - File: `tests/a.test.mjs` (new), covers `tests/b.test.mjs`',
' - Verifies: x',
]);
assert.deepEqual(testFirstFiles(body, '1.9'), ['tests/a.test.mjs']);
// Below 1.9 the line is read as 6.3.1 + Step 3 read it: every path-shaped span.
assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs', 'tests/b.test.mjs']);
// Operator decision 20 (12.10): below 1.9 too, only the paths right after File:.
assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs']);
});
test('1.9: a code file named only in the File: line description keeps the step from being tests-only; in 1.8 it is tests-only', () => {
test('decision 20: a code file named only in the File: line description keeps the step from being tests-only, at 1.9 and 1.8', () => {
const files = '`tests/a.test.mjs`, `checks/smoke.mjs`';
const line = ' - File: `tests/a.test.mjs` (new), covers `checks/smoke.mjs`';
const body = step(files, [line, ' - Verifies: x']);
assert.equal(m.stepTestsOnly(body, '1.9').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.9')));
assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, true, JSON.stringify(m.stepTestsOnly(body, '1.8')));
assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.8')));
const v19 = validatePlanContent(planWith('1.9', files, line));
assert.ok(!v19.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v19.warnings));
assert.deepEqual(v19.errors.filter((e) => PATH_CODES.includes(e.code)), [], JSON.stringify(v19.errors));
const v18 = validatePlanContent(planWith('1.8', files, line));
assert.ok(v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings));
assert.ok(!v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings));
});
test('1.9: a File: line that opens with `parse()` is refused as no path (error strict, warning soft)', () => {
@ -150,3 +150,47 @@ test('1.9: an unticked File: line gets PLAN_STEP_PATH_NOT_FIRST next to PLAN_STE
assert.match(e.message, /Step 1/);
assert.match(e.message, /tests\/a\.test\.mjs/);
});
// ---- operator decision 20 (12.10), review 6c31212c --------------------------
// Below 1.9 too, a step's test files are the backticked paths that stand right
// after `File:`, separated only by commas or whitespace. A path in the
// description after them (`covers parse() in src/parser.mjs`) is no test file,
// so a step that builds that source is not tests-only and its source is never
// locked. Each `File:` marker on a line starts its own run, as 6.3.1 read the
// first span after each marker.
// The review's exact input: the description names a call and the source.
const REVIEW_FILES = '`src/parser.mjs`, `tests/parser.test.mjs`';
const REVIEW_LINE = ' - File: `tests/parser.test.mjs` (new) \u2014 covers `parse()` in `src/parser.mjs`';
test('decision 20: the review input (6c31212c) gives only the test below 1.9, is not tests-only, and gets no PLAN_STEP_TESTS_ONLY', () => {
const body = step(REVIEW_FILES, [REVIEW_LINE, ' - Verifies: x']);
for (const v of ['1.8', '1.7', null, undefined]) {
assert.deepEqual(testFirstFiles(body, v), ['tests/parser.test.mjs'], `version ${v}`);
}
assert.equal(m.stepTestsOnly(body, '1.8').testsOnly, false, JSON.stringify(m.stepTestsOnly(body, '1.8')));
assert.equal(m.stepTestsOnly(body).testsOnly, false, JSON.stringify(m.stepTestsOnly(body)));
const v18 = validatePlanContent(planWith('1.8', REVIEW_FILES, REVIEW_LINE));
assert.ok(!v18.warnings.some((w) => w.code === 'PLAN_STEP_TESTS_ONLY'), JSON.stringify(v18.warnings));
});
test('decision 20: two leading tests (comma or space) before a description that names a source give the two tests at 1.8 and 1.9', () => {
for (const lead of ['`tests/a.test.mjs`, `tests/b.test.mjs`', '`tests/a.test.mjs` `tests/b.test.mjs`']) {
const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`, `src/a.mjs`', [
` - File: ${lead} (new) - covers \`run()\` in \`src/a.mjs\``,
' - Verifies: x',
]);
for (const v of ['1.8', '1.9']) {
assert.deepEqual(testFirstFiles(body, v), ['tests/a.test.mjs', 'tests/b.test.mjs'], `${v}: ${lead}`);
}
}
});
test('decision 20: a 1.8 line with two File: markers, each followed by a test, gives both tests (6.3.1 read both)', () => {
const body = step('`tests/a.test.mjs`, `tests/b.test.mjs`', [
' - File: `tests/a.test.mjs` (new) and File: `tests/b.test.mjs` (existing)',
' - Verifies: x',
]);
assert.deepEqual(testFirstFiles(body, '1.8'), ['tests/a.test.mjs', 'tests/b.test.mjs']);
assert.deepEqual(testFirstFiles(body, null), ['tests/a.test.mjs', 'tests/b.test.mjs']);
});