fix(plan): a 1.9 wrapped line keeps its paths only after a comma or in an empty entry

Operator decision 29 (14.10); review findings d21ff81e and eaeb398e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-14 10:29:35 +02:00
commit b70f242e53
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 115 additions and 21 deletions

View file

@ -477,12 +477,15 @@ export function fieldPaths(text) {
//
// - The entries are the field's first non-empty line and each list-item line
// (`-`, `*`, `+`, `1.`, `1)`). Any other non-empty line continues the entry
// above it and is appended to that entry's text with one space before the
// entry is read, so a list wrapped after a comma ("`tests/a.test.mjs`," and
// "`src/a.mjs`" on the next line) names both files, a wrapped description
// line stays description, and an empty first line before a nested list is
// no entry. An issue carries the line number of the entry's first line
// (review `b4fa1927`).
// above it: when the entry so far ends with a comma it is appended with one
// space before the entry is read, so a list wrapped after a comma
// ("`tests/a.test.mjs`," and "`src/a.mjs`" on the next line) names both
// files; otherwise it and every later wrapped line of that entry are
// description and never read (operator decision 29, 14.10; reviews
// `d21ff81e`, `eaeb398e`). An entry with nothing on its own line (a `File:`
// line with nothing after it) takes its first wrapped line. An empty first
// line before a nested list is no entry. An issue carries the line number
// of the entry's first line (review `b4fa1927`).
// - 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`"
@ -494,9 +497,14 @@ export function fieldPaths(text) {
//
// - A `Test first:` `File:` line is a line that opens with `File:` (or
// `**File:**`) after an optional list marker of the same set (`-`, `*`, `+`,
// `1.`, `1)`); its rest is one entry under this grammar. The non-list lines
// right after it are appended to it with one space, as above; any list item
// (a `Verifies:` or `Pattern:` line too) ends it, and a non-list line after
// `1.`, `1)`); its rest is one entry under this grammar. Any other
// non-empty line right after it continues it by the same rule: when the
// entry so far ends with a comma it is appended with one space, so a list
// wrapped after a comma names both files; otherwise it and every later
// wrapped line of that entry are description and never read (operator
// decision 29, 14.10; reviews `d21ff81e`, `eaeb398e`). A `File:` line with
// nothing after it takes its first wrapped line. Any list item (a
// `Verifies:` or `Pattern:` line too) ends it, and a non-list line after
// such an item belongs to that item, never to the `File:` entry. A line that
// opens with `File:` always starts a new entry; a `File:` mid-line names no
// test (review `4b6460e5`).
@ -540,6 +548,26 @@ function leadingSpans(text) {
return out;
}
/**
* Whether a wrapped line (a non-empty line that is no list item) joins the
* 1.9 entry above it (operator decision 29, 14.10; reviews `d21ff81e`,
* `eaeb398e`). An entry with nothing on its own line (a `File:` line with
* nothing after it) takes its first wrapped line; an entry whose text so far
* ends with a comma gets the line appended with one space, so a list wrapped
* after a comma names both files. Otherwise the line is description: null,
* and the caller closes the entry, so every later wrapped line of it is
* description too.
* @param {string} text - the entry so far
* @param {string} line - the wrapped line
* @returns {string|null} the entry's new text, or null when the line is description
*/
function wrapLine(text, line) {
const so = String(text ?? '');
if (so.trim() === '') return line.trim();
if (so.trimEnd().endsWith(',')) return `${so} ${line.trim()}`.trim();
return null;
}
/**
* The files a 1.9 `Files:` field names, by the grammar above.
* @param {string} text - the field's text (parseStepFields(body).get('Files').text)
@ -554,13 +582,19 @@ export function entryPaths(text) {
String(text ?? '').split('\n').forEach((line, i) => {
const item = LIST_ITEM.exec(line);
const rest = line.trim();
if (item) entries.push({ line: i + 1, entry: line.slice(item[0].length).trim() });
if (item) entries.push({ line: i + 1, entry: line.slice(item[0].length).trim(), closed: false });
else if (rest === '') return;
else if (entries.length === 0) entries.push({ line: i + 1, entry: rest });
else if (entries.length === 0) entries.push({ line: i + 1, entry: rest, closed: false });
else {
// A wrapped continuation line belongs to the entry above it.
// A wrapped line joins the entry above it only after a comma, or when
// the entry is empty (wrapLine); otherwise it closes the entry, and it
// and every later wrapped line of that entry are description, never read
// (operator decision 29).
const last = entries[entries.length - 1];
last.entry = `${last.entry} ${rest}`.trim();
if (last.closed) return;
const joined = wrapLine(last.entry, rest);
if (joined === null) last.closed = true;
else last.entry = joined;
}
});
if (entries.length === 0) {
@ -631,10 +665,15 @@ const TEST_FIRST_FILE_LINE_19 = /^\s*(?:(?:[-*+]|\d+[.)])\s+)?(?:\*\*)?File:(?:\
/**
* plan_version 1.9: the rest of each `Test first:` `File:` line (after `File:`
* and any `**`), with the non-list lines right after it appended (one space
* each), one entry per `File:` line, in order. Any list item ends the entry;
* a line that opens with `File:` starts a new one. The one selector both
* testFirstFiles and the plan validator's grammar check read.
* and any `**`), one entry per `File:` line, in order. Any other non-empty
* line right after it continues the entry above it: when the entry so far
* ends with a comma it is appended with one space, so a list wrapped after a
* comma names both files; otherwise it and every later wrapped line of that
* entry are description and never read (operator decision 29, 14.10; reviews
* `d21ff81e`, `eaeb398e`). An entry with nothing on its own line (a `File:`
* line with nothing after it) takes its first wrapped line. Any list item ends
* the entry; a line that opens with `File:` starts a new one. The one
* selector both testFirstFiles and the plan validator's grammar check read.
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
* @returns {string[]}
*/
@ -651,7 +690,9 @@ export function testFirstFileEntries(fields) {
} else if (LIST_ITEM.test(line) || line.trim() === '') {
open = false;
} else if (open) {
out[out.length - 1] = `${out[out.length - 1]} ${line.trim()}`.trim();
const joined = wrapLine(out[out.length - 1], line);
if (joined === null) open = false;
else out[out.length - 1] = joined;
}
}
return out;
@ -673,9 +714,13 @@ export function testFirstFileEntries(fields) {
*
* From 1.9 a line counts when it opens with `File:` (testFirstFileEntries),
* backticked or not, after an optional list marker (`1.` and `1)` included);
* its rest, with its wrapped continuation lines, is one entry read by
* entryPaths: the leading backticked paths are the test files, the rest is
* description.
* its rest is one entry read by entryPaths: the leading backticked paths are
* the test files, the rest is description. Any other non-empty line right
* after it continues the entry: when the entry so far ends with a comma it is
* appended with one space, so a list wrapped after a comma names both files;
* otherwise it and every later wrapped line of that entry are description and
* never read (operator decision 29, 14.10; reviews `d21ff81e`, `eaeb398e`). A
* `File:` line with nothing after it takes its first wrapped line.
*
* Normalised, in order, deduplicated. A `Verifies:` line without a `File:` is
* never read.