voyage/lib/parsers/plan-schema.mjs
Kjell Tore Guttormsen 4d5c927c5d
refactor(simplify): drop a dead branch and repeated fixtures from the round-2 remediation
/simplify over bcf3adb..HEAD. Pass 3's last else could never run (RULE_KEYS is checked first); entryPaths tracks an open entry the way testFirstFileEntries does; wrapLine drops normalisations its callers already made. Tests: one payload builder, one BLOCKER fixture, one unreleasedBody helper, one 1.9-block loop, one plan-unreadable loop, and make_repo in the e2e script. No behaviour change; suite 2515 (2513/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:43:24 +02:00

996 lines
44 KiB
JavaScript

// lib/parsers/plan-schema.mjs
// Plan schema parser — heading shape, per-step fields, ceremony, design part.
//
// The canonical step heading is `### Step N: <title>` (literal colon-space).
// Forbidden narrative drift formats (introduced in v1.8.0 to defend against
// Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`.
//
// plan_version 1.8 adds per-step field parsing (`parseStepFields`), the
// ceremony class the plan was made under (`small | bounded | architectural`,
// absent = architectural) and the design-part helpers (`splitSections`,
// `findModuleCycle`). plan_version 1.9 (the current target) adds the grammar
// of the `Files:` field and the `Test first:` `File:` line, the file first on
// every line (`entryPaths`). The rules that use them live in plan-validator.mjs.
import { ok, fail, issue } from '../util/result.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { isTestPath } from '../util/test-path.mjs';
export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m;
export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm;
export const FORBIDDEN_HEADING_REGEX = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/m;
export const FORBIDDEN_HEADING_GLOBAL = /^(?:##|###) (?:Fase|Phase|Stage|Steg) \d+/gm;
// Matches plan_version in either location the schema allows: at line start
// (frontmatter) or backtick-wrapped inside the prose "Generated by" metadata
// line the plan template emits (`> Generated by ... — `plan_version: 1.7``).
export const PLAN_VERSION_REGEX = /(?:^|`)plan_version:\s*['"]?([\d.]+)['"]?/m;
/**
* Find all step heading positions in plan text.
* @returns {Array<{n: number, title: string, line: number, offset: number}>}
*/
export function findSteps(text) {
if (typeof text !== 'string') return [];
const out = [];
STEP_HEADING_GLOBAL.lastIndex = 0;
let m;
while ((m = STEP_HEADING_GLOBAL.exec(text)) !== null) {
const offset = m.index;
const line = text.slice(0, offset).split(/\r?\n/).length;
out.push({ n: Number.parseInt(m[1], 10), title: m[2].trim(), line, offset });
}
return out;
}
/**
* Find forbidden narrative-drift heading occurrences (Fase/Phase/Stage/Steg N).
* @returns {Array<{form: string, line: number, offset: number, raw: string}>}
*/
export function findForbiddenHeadings(text) {
if (typeof text !== 'string') return [];
const out = [];
FORBIDDEN_HEADING_GLOBAL.lastIndex = 0;
let m;
while ((m = FORBIDDEN_HEADING_GLOBAL.exec(text)) !== null) {
const offset = m.index;
const line = text.slice(0, offset).split(/\r?\n/).length;
const raw = m[0];
out.push({ form: raw, line, offset, raw });
}
return out;
}
/**
* Slice plan text into per-step sections.
* @returns {Array<{n: number, title: string, body: string, line: number}>}
*/
export function sliceSteps(text) {
const heads = findSteps(text);
const sections = [];
for (let i = 0; i < heads.length; i++) {
const start = heads[i].offset;
const end = i + 1 < heads.length ? heads[i + 1].offset : text.length;
const block = text.slice(start, end);
sections.push({
n: heads[i].n,
title: heads[i].title,
body: block,
line: heads[i].line,
});
}
return sections;
}
/**
* Extract `plan_version: X.Y` from frontmatter or doc body.
*/
export function extractPlanVersion(text) {
const m = typeof text === 'string' ? text.match(PLAN_VERSION_REGEX) : null;
return m ? m[1] : null;
}
/**
* Validate plan structure at the heading level.
* Strict mode: forbidden-heading count > 0 → error. Step numbers must be 1..N contiguous.
* @returns {import('../util/result.mjs').Result}
*/
export function validatePlanHeadings(text, opts = {}) {
const strict = opts.strict !== false;
const errors = [];
const warnings = [];
if (typeof text !== 'string') {
return fail(issue('PLAN_INPUT', 'Plan text is not a string'));
}
const forbidden = findForbiddenHeadings(text);
if (forbidden.length > 0) {
const list = forbidden.map(f => `line ${f.line}: ${f.raw}`).join('; ');
const errorIssue = issue(
'PLAN_FORBIDDEN_HEADING',
`Found ${forbidden.length} forbidden narrative-drift heading(s): ${list}`,
'Use canonical "### Step N: <title>". Forbidden forms: Fase/Phase/Stage/Steg.',
);
if (strict) errors.push(errorIssue);
else warnings.push(errorIssue);
}
const steps = findSteps(text);
if (steps.length === 0) {
errors.push(issue('PLAN_NO_STEPS', 'No step headings found', 'Expected at least one "### Step 1: <title>".'));
} else {
const numbers = steps.map(s => s.n);
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] !== i + 1) {
errors.push(issue(
'PLAN_STEP_NUMBERING',
`Step numbering breaks at position ${i + 1} (got Step ${numbers[i]})`,
'Steps must be 1..N contiguous and ordered.',
));
break;
}
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } };
}
// ---- plan_version 1.8 ------------------------------------------------------
export const PLAN_VERSION_CURRENT = '1.9';
// 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) {
if (typeof v !== 'string' || !/^\d+(\.\d+)*$/.test(v)) return false;
const a = v.split('.').map(Number);
const b = min.split('.').map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x > y;
}
return true;
}
/** True when plan_version `v` reads `Files:` and the `Test first:` `File:` line by the 1.9 grammar. */
export const usesFilesGrammar = (v) => versionAtLeast(v, '1.9');
// ---- ceremony (the brief's size class) -------------------------------------
export const CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
// Heaviest on doubt: a plan or brief that does not say is architectural.
export const CEREMONY_DEFAULT = 'architectural';
// A plan states its ceremony in exactly two places: frontmatter, or the
// "Generated by" header line (`> Generated by … · `ceremony: architectural``).
// Prose elsewhere ("the brief sets `ceremony: small`") never counts.
const HEADER_CEREMONY = /^> Generated by .*`ceremony:\s*([^`\s]+)\s*`/m;
/** @returns {{value: string, raw: string|null, defaulted: boolean, valid: boolean}} */
export function resolveCeremony(raw) {
if (raw === null || raw === undefined || raw === '') {
return { value: CEREMONY_DEFAULT, raw: null, defaulted: true, valid: true };
}
const s = String(raw).trim();
return { value: s, raw: s, defaulted: false, valid: CEREMONY_VALUES.includes(s) };
}
function frontmatterCeremony(text) {
const doc = typeof text === 'string' ? parseDocument(text) : null;
const fm = doc?.parsed?.frontmatter;
return fm && typeof fm === 'object' && fm.ceremony !== undefined ? fm.ceremony : null;
}
export function extractPlanCeremony(text) {
if (typeof text !== 'string') return resolveCeremony(null);
const fm = frontmatterCeremony(text);
if (fm !== null) return resolveCeremony(fm);
const m = text.match(HEADER_CEREMONY);
return resolveCeremony(m ? m[1] : null);
}
/** Read `ceremony:` from a brief's frontmatter. Absent (or no frontmatter) → architectural. */
export function ceremonyFromBrief(briefText) {
return resolveCeremony(frontmatterCeremony(briefText));
}
// ---- fences ------------------------------------------------------------------
/**
* Tracks fenced code blocks the CommonMark way: a fence closes only on the same
* character (` or ~), at least as long as the opener, with nothing after it.
* `feed(line)` returns true when the line is inside a fence or is a fence line.
* A fence line is indented up to three spaces; with `anyIndent` any indent
* counts (the trailing-json readers, findings-schema `fencedBlocks`).
*/
export function fenceTracker({ anyIndent = false } = {}) {
const fenceLine = anyIndent ? /^[ \t]*(`{3,}|~{3,})(.*)$/ : /^\s{0,3}(`{3,}|~{3,})(.*)$/;
let open = null; // { ch, len }
return {
feed(line) {
const m = line.match(fenceLine);
if (!open) {
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
open = { ch: m[1][0], len: m[1].length };
return true;
}
return false;
}
if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === '') open = null;
return true;
},
get inside() { return open !== null; },
};
}
// ---- per-step fields -------------------------------------------------------
// A top-level field bullet: `- **Name:** rest`, `- **Name**: rest` or `- Name: rest`.
const FIELD_LINE = /^- (?:\*\*)?([A-Z][A-Za-z -]*?)(?::\*\*|\*\*:|:)(?:\s+(.*))?$/;
/**
* Parse a step body into its top-level `- **Field:**` bullets. A field's block
* runs until the next top-level field bullet; indented lines (sub-bullets, the
* Manifest YAML fence) belong to the field above them. Parsing stops at the
* first heading after the step's own, outside fenced code: sliceSteps gives the
* last step the rest of the plan, and a later section must not lend it fields.
* @returns {Map<string, {name: string, inline: string, text: string}>}
* `inline` = text on the bullet line; `text` = inline + the indented block.
*/
export function parseStepFields(body) {
const fields = new Map();
if (typeof body !== 'string') return fields;
const fence = fenceTracker();
let current = null;
let first = true;
for (const line of body.split(/\r?\n/)) {
const inFence = fence.feed(line);
if (!inFence && /^#{1,6} /.test(line)) {
if (!first) break;
first = false;
continue;
}
first = false;
if (inFence) {
if (current) current.lines.push(line);
continue;
}
const m = line.match(FIELD_LINE);
if (m) {
current = { name: m[1].trim(), inline: (m[2] ?? '').trim(), lines: [] };
if (!fields.has(current.name)) fields.set(current.name, current);
continue;
}
if (current && /^\s/.test(line)) current.lines.push(line);
else if (current && line.trim() !== '') current = null;
}
for (const f of fields.values()) {
f.text = [f.inline, ...f.lines].join('\n').trim();
delete f.lines;
}
return fields;
}
// ---- `On failure:` (K11) ---------------------------------------------------
// The recovery words /trekexecute reads from a step's `On failure:` ("Parse
// steps"). Free text that opens with none of them left the executor to guess:
// "retry once; if still failing, revert ... and STOP" met a ladder of fix rounds
// (voyage-lekerepo trial 2, 2026-10-08).
export const ON_FAILURE_ACTIONS = Object.freeze(['revert', 'retry', 'skip', 'escalate']);
/**
* The recovery word an `On failure:` text opens with, or null. Case-insensitive;
* leading markdown (`**`, backticks, a sub-bullet's `-`) is stripped.
*/
export function onFailureAction(text) {
const s = String(text ?? '').trim().replace(/^[*_`\s-]+/, '').toLowerCase();
const m = s.match(/^([a-z]+)\b/);
return m && ON_FAILURE_ACTIONS.includes(m[1]) ? m[1] : null;
}
// ---- a step that holds nothing but tests -----------------------------------
/**
* The paths a step's `Files:` field names, in order, for the tests-only check
* below (the K6 sweep keeps v6.2.0's own reader: plan-scope-sweep.mjs,
* operator 2026-10-11). The convention in the plan template is a backticked
* path. Measured on the plan.md files outside worktrees under the marketplace
* and the test repo: 520 Files: fields in 48 plan files at rounds 7-8 (round 7
* wrote "182 plans" here; round 8's count of the files is 48), and 560 fields
* in 48 plan files at round 9 (2026-10-11; the same at round 10), 548 of
* them with a backticked path. In 3 of those a plain path-like word stands
* beside it, each inside a description, and none of them is read as a file:
*
* - A field with a backticked path names its backticked spans that pass the
* path test (a `.`, `/` or `\`; no URL, option or call like `parse()`),
* wherever they sit (`a.mjs`/`b.mjs`, inside a note), a backticked build
* file (`Makefile`, `Dockerfile`), and a backticked capitalised name with no
* extension that stands alone in its entry before any description. Beside
* them a plain word counts only when it is clearly a file or a directory,
* and only before the entry's description starts (a dash, or a colon after
* a path, backticked or plain, wherever it stands: "src/cli.mjs: wires
* parse", "the entry cli.mjs: wires parse"; a colon after a span that is no
* path, `x()`:, starts none): a path with a slash that ends in a file or a
* `/` anywhere there ("the CLI entry src/cli.mjs"), and a name with an
* extension, a dotfile or a build file only where an entry or a list starts:
* first in its entry, or after "and", "or", "+" or "&" (", Dockerfile", "and
* Makefile", ", package.json"). A description runs on past a comma and onto
* the next line until a new entry starts: a list item on a new line (`-`,
* `1.` or `1)`), a backticked path, or a new line that opens with a plain
* file with a slash, or with one alone on the line or followed by a colon or
* a dash ("src/cli.mjs: the fix", "package.json", "Makefile — a target").
* Round 7: such files were dropped, and a step that builds something read as
* tests-only. Rounds 8-9: "— mocks fs.readFileSync", "runs on Node.js 22.x",
* "no change to package.json", "— mocks fs, child_process.spawnSync" or
* "src/cli.mjs: wires parse and README.md" read as files, and a step that
* holds only its test did not. Round 10: a plain file on the next line, a
* numbered item, a `Parser` inside a description, `x()`: and a mid-entry
* "cli.mjs:" were each misread. "covers pass/fail", "JSON, YAML", "v6.3.0
* format" or a next line "index.js and the CLI" is no path.
* - A field with no backticked path names every path-like word: a name with an
* extension, a dotfile, a path with a slash, a directory (`docs/`), a glob
* (`src/*.mjs`) or a build file; a parenthesised note is not read. Another
* word with no slash and no extension counts only alone in its entry and
* capitalised; "format" or "v6.3.0" in a description is no path.
*
* A backslash is read as a slash, a leading `./` is dropped. Release review of
* 6.3.0, rounds 1-10.
*
* Three answers 6.3.0 gave and 6.3.1 lost are given back, each in its narrow
* form, and the round 7-10 neighbours of each keep 6.3.1's answer:
*
* - 6.4 / regression A: inside a description, a comma entry that opens with a
* plain file with a slash, or with one followed by a colon or a dash, starts
* a new entry ("`a.test.mjs` - new test, src/a.mjs - the fix" names both).
* That is the new-line test without its "alone on the line" branch
* (opensChunk), so "— mocks fs, child_process.spawnSync and path.join" stays
* description (round 9).
* - 6.4 / regression B: in an entry opened by a backticked path, a plain path
* with a slash after a word of description ("`a.test.mjs` covers the parser
* in src/parse.mjs") counts only where a list starts (after "and", "or", "+"
* or "&"). Right after the backticked path ("`a.test.mjs` src/b.mjs"), and in
* an entry not opened by one (", the CLI entry src/cli.mjs", round 7), it is
* read as before. A list joiner is no word of description: "`a.mjs` +
* `b.mjs` src/c.mjs" reads all three.
* - 6.4 / regression C: a new line that is no list item and holds only a
* backticked capitalised name ("`LICENSE`") ends the description, and the
* name is a file. On the description's own line it stays no file (round 10).
*/
const SEG = '[\\w@.*?()-]';
const PATH_WORD = new RegExp(`^(?:${SEG}+\\/)+${SEG}*$|^[\\w@*?-][\\w@.*?-]*\\.[A-Za-z*][A-Za-z0-9*]*$|^\\.[\\w-][\\w.-]*$`);
// Abbreviations that PATH_WORD would read as a name with an extension.
const NOT_A_PATH = /^(?:e\.g|i\.e|n\.b|cf|etc|vs)$/i;
const BARE_NAME = /^[A-Z][\w.-]*$/;
// The last segment is a dotfile or a name with an extension, or the path is a directory.
const FILE_OR_DIR = /(?:^|\/)(?:\.[\w-][\w.-]*|[^/]*\.[A-Za-z*][A-Za-z0-9*]*)$|\/$/;
// A build file named without an extension: a capitalised name that ends in
// `file` (Makefile, Dockerfile, Gemfile, Jenkinsfile, ...), not the word Profile,
// and the lowercase names make, rake and just also read (round 8).
const BUILD_FILE = /^(?!Profile$)(?:[A-Z][a-z]+|GNUmake)file$|^(?:make|rake|just)file$/;
// Where an entry's description starts, and the words that start a list item.
const DESCRIPTION_DASH = /^(?:\u2014|\u2013|-|--)$/;
const LIST_JOINER = /^(?:and|or|\+|&)$/i;
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
const lastSegment = (p) => p.replace(/\/$/, '').split('/').pop();
/** A plan path as the readers hold it: `/` separators, no leading `./`. */
export const normPlanPath = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
const norm = normPlanPath;
// A backticked span that is a path, not a URL, an option or a call (`parse()`, `p.parse()`).
const tickedPath = (s) => /[./\\]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && !/^[\w$.]+\(.*\)$/.test(s);
// A plain word without the brackets and punctuation around it, `\` read as `/`.
const plainWord = (w) => w.replace(/^[(\[]+/, '').replace(/[.,;:)\]]+$/, '').replace(/\\/g, '/');
const isPathWord = (w) => PATH_WORD.test(w) && !NOT_A_PATH.test(w);
// Clearly a file or a directory: what a plain word beside backticked paths must be.
const isFileWord = (w) => BUILD_FILE.test(w) || (isPathWord(w) && (FILE_OR_DIR.test(w) || BUILD_FILE.test(lastSegment(w))));
// A line inside a description that opens a new entry: its first word is a
// plain file with a slash, or a plain file alone on the line or followed by a
// colon or a dash ("src/cli.mjs", "package.json", "Makefile — a target"), not
// "child_process.spawnSync, path.join" or "Node.js 22 only" (round 10).
// A comma entry inside a description that opens a new entry: opensEntry's test
// without "alone on the line", so "path.join" after a comma stays description
// (6.4 / regression A).
const opensChunk = (tokens) => {
const w = plainWord(tokens[0]);
return isFileWord(w) && (w.includes('/') || tokens[0].endsWith(':') || DESCRIPTION_DASH.test(tokens[1]));
};
const opensEntry = (tokens) => opensChunk(tokens) || (tokens.length === 1 && isFileWord(plainWord(tokens[0])));
export function fieldPaths(text) {
const spans = [];
const masked = String(text ?? '')
.replace(/`([^`\n]+)`/g, (_, p) => `\u0000${spans.push(p.trim()) - 1}\u0000`)
.replace(/(^|\s)\(([^()\n]*)\)/g, (m, pre, inner) => (inner.includes('\u0000') ? m : `${pre} `));
const isTicked = spans.map((p) => tickedPath(p) || BUILD_FILE.test(p));
const tickedField = isTicked.some(Boolean);
const out = [];
// A description runs on past a comma and onto the next line until a new
// entry starts: a list item on a new line (`-`, `1.`, `1)`), a backticked
// path, or a line that opens with a plain file (rounds 9-10).
let described = false;
for (const line of masked.split('\n')) {
const item = LIST_ITEM.exec(line);
const body = item ? line.slice(item[0].length) : line;
if (item || (described && body.trim() !== '' && opensEntry(body.trim().split(/\s+/)))) described = false;
// A new line that holds only a backticked capitalised name ends the
// description (6.4 / regression C).
const alone = /^\u0000(\d+)\u0000$/.exec(body.trim());
if (alone && BARE_NAME.test(spans[Number(alone[1])])) described = false;
for (const raw of body.split(',')) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const tokens = entry.split(/\s+/);
const first = /^\u0000(\d+)\u0000/.exec(entry);
// An entry opened by a backticked path: after a word of description a
// plain path with a slash counts only where a list starts (6.4 / regression B).
const tickedEntry = Boolean(first && isTicked[Number(first[1])]);
if (tickedEntry) described = false;
if (described && opensChunk(tokens)) described = false;
let prose = false;
// A backticked capitalised name alone in its entry, before any
// description, is a file (rounds 6, 10).
if (first && first[0] === entry && !described && BARE_NAME.test(spans[Number(first[1])])) {
out.push(spans[Number(first[1])]);
continue;
}
if (tickedField) {
// In the order they stand: the backticked paths, and before the
// description a plain word that is clearly a file where it may stand
// (rounds 7-8). A path followed by a colon starts its description,
// backticked or plain, wherever it stands; a span that is no path
// does not (rounds 9-10).
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.includes('\u0000')) {
const ticked = [...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((n) => isTicked[n]);
out.push(...ticked.map((n) => spans[n]));
if (ticked.length > 0 && t.endsWith(':')) described = true;
continue;
}
if (DESCRIPTION_DASH.test(t)) described = true;
const w = plainWord(t);
const file = isFileWord(w);
if (!file && !LIST_JOINER.test(t)) prose = true;
if (described || !file) continue;
if ((w.includes('/') && !(tickedEntry && prose)) || i === 0 || LIST_JOINER.test(tokens[i - 1])) out.push(w);
if (t.endsWith(':')) described = true;
}
continue;
}
const words = tokens.filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean);
const found = words.filter((w) => isPathWord(w) || BUILD_FILE.test(w));
if (found.length === 0 && words.length === 1 && tokens.length === 1 && BARE_NAME.test(words[0])) found.push(words[0]);
out.push(...found);
}
}
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 non-empty line continues the entry
// 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`"
// 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).
//
// - 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. 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`).
//
// isPathShape is the 1.9-only test on a span in the leading position of an
// entry (operator decision 22, 13.10): a placeholder (none, TBD, TODO, TBA,
// N/A, NA, `...`) is no file; a path, a build file or an all-capitals name
// such as `LICENSE` or `README` is; a mixed-case bare name (`Parser`) is not,
// and `./Name` names such a file. The readers below 1.9 keep BARE_NAME.
const LEADING_SPAN = /^`([^`\n]+)`/;
// Operator decision 22: these words are the input a 1.9 plan refuses, not unfinished code.
const PLACEHOLDER = /^(?:none|n\/?a|tbd|tba|todo|\.{3})$/i;
const CAPS_NAME = /^[A-Z][A-Z0-9_-]*$/;
/** A backticked span in the leading position of an entry that names a file. */
export function isPathShape(s) {
const span = String(s ?? '').trim();
if (PLACEHOLDER.test(span)) return false;
return tickedPath(span) || BUILD_FILE.test(span) || CAPS_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;
}
/**
* 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) {
if (text === '') return line.trim();
return text.endsWith(',') ? `${text} ${line.trim()}` : 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)
* @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 = [];
// Whether the last entry still takes wrapped lines (operator decision 29).
let open = false;
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() }); open = true; }
else if (rest === '') return;
else if (entries.length === 0) { entries.push({ line: i + 1, entry: rest }); open = true; }
else {
// 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).
if (!open) return;
const last = entries[entries.length - 1];
const joined = wrapLine(last.entry, rest);
if (joined === null) open = false;
else last.entry = joined;
}
});
if (entries.length === 0) {
issues.push({ line: 1, entry: '', code: 'PLAN_STEP_PATH_NOT_FIRST' });
return { files, issues };
}
for (const { line, entry } of entries) {
const spans = leadingSpans(entry);
if (spans.length === 0) {
issues.push({ line, entry, code: 'PLAN_STEP_PATH_NOT_FIRST' });
continue;
}
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' });
}
}
}
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/
// either one is a fixture, and a fixture is a test file.
const DOC_RE = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
const DATA_RE = /\.(ya?ml|json|toml|xml|csv)$/i;
const UNDER_TESTS_RE = /(^|\/)(tests?|__tests__)\//;
/** A path that is clearly a test: a test by its path, and under tests/ or neither a document nor a data file. */
function clearlyTest(p) {
return isTestPath(p) && (UNDER_TESTS_RE.test(p) || !(DOC_RE.test(p) || DATA_RE.test(p)));
}
/**
* Whether every file in a step's `Files:` is a test: the step's own `Test
* first:` file, or a path that is clearly a test (lib/util/test-path.mjs). Such
* a step can never be proven red - the behaviour its tests cover already
* exists at its start point (an earlier step built it, or it was there before
* the plan), so they already pass there (voyage-lekerepo
* 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. 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, version) {
return stepTestsOnlyFromFields(parseStepFields(body), version);
}
// 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 of the set LIST_ITEM accepts, and `**`), backticked or not.
const TEST_FIRST_FILE_LINE_19 = /^\s*(?:(?:[-*+]|\d+[.)])\s+)?(?:\*\*)?File:(?:\*\*)?/i;
// Below plan_version 1.9: one glue token at the start of the rest of a `File:`
// run (operator decisions 28, 13.10, and 31, 14.10): whitespace or a comma
// (group 1); a note in brackets holding no bracket, backtick or newline,
// optionally in one `*` or `_` (`(new)`, `*(new)*`, `(existing, the parser)`);
// the whole word `and` or `plus`, any case (`android` is no glue); or `+`.
const OLDER_GLUE = /^(?:(\s+|,)|[*_]?\([^()`\n]*\)[*_]?|(?:and|plus)(?!\w)|\+)/i;
/**
* The files of one below-1.9 `File:` run, read from just after the marker:
* the leading backticked span, then each span after one or more glue tokens
* (OLDER_GLUE). While every glue token so far was whitespace or a comma, a
* span with the shape of a path is kept and any other span is skipped
* (operator decision 20). Once a glue token was a note or a word, a span is
* kept only when it has the shape of a path and is clearly a test, and the
* first span that is not ends the run (operator decisions 28 and 31). Any text
* that is neither a span nor glue ends the run.
* @param {string} text - the line after one `File:` marker
* @returns {string[]} the kept spans, trimmed, not normalised
*/
function olderFileRun(text) {
const out = [];
let rest = String(text ?? '');
let loose = false;
let m = LEADING_SPAN.exec(rest);
while (m) {
const s = m[1].trim();
if (!loose) {
if (tickedPath(s) || BUILD_FILE.test(s)) out.push(s);
} else if (tickedPath(s) && clearlyTest(norm(s))) {
out.push(s);
} else {
break;
}
rest = rest.slice(m[0].length);
let glued = false;
let g = OLDER_GLUE.exec(rest);
while (g) {
glued = true;
if (g[1] === undefined) loose = true;
rest = rest.slice(g[0].length);
g = OLDER_GLUE.exec(rest);
}
m = glued ? LEADING_SPAN.exec(rest) : null;
}
return out;
}
/**
* plan_version 1.9: the rest of each `Test first:` `File:` line (after `File:`
* 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[]}
*/
export function testFirstFileEntries(fields) {
if (!fields || !fields.has('Test first')) return [];
const out = [];
// Whether the line above is the current `File:` line or its continuation.
let open = false;
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());
open = true;
} else if (LIST_ITEM.test(line) || line.trim() === '') {
open = false;
} else if (open) {
const joined = wrapLine(out[out.length - 1], line);
if (joined === null) open = false;
else out[out.length - 1] = joined;
}
}
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. Each `File:` marker on the
* line opens a run: the backticked spans that stand right after it, separated
* only by commas or whitespace (olderFileRun), 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. Below 1.9
* the run also goes on past a note in brackets (`(new)`, `*(new)*`,
* `(existing ...)`) and the words `and`, `plus` and `+`, to a further path
* only when it is clearly a test, and a source path there ends it
* (olderFileRun; operator decisions 28 and 31, 13.10 and 14.10; review
* `bd2520b0`; review `6c31212c` for the guard).
*
* 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 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.
* @param {Map<string, {text: string}>} fields - parseStepFields(body)
* @param {string|null} [version] - the plan's plan_version
* @returns {string[]}
*/
export function testFirstFiles(fields, version) {
if (!fields || !fields.has('Test first')) return [];
const out = [];
if (usesFilesGrammar(version)) {
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')) {
for (const at of line.matchAll(TEST_FIRST_FILE_LINE)) {
for (const s of olderFileRun(line.slice(at.index + at[0].length))) {
const p = norm(s);
if (!out.includes(p)) out.push(p);
}
}
}
return out;
}
/**
* Whether a path counts as a test for this step: its own `Test first:` files
* (testFirstFiles), or a path that is clearly a test.
* @returns {(path: string) => boolean}
*/
function stepTestFile(f, version) {
const testFirst = testFirstFiles(f, version);
return (p) => testFirst.includes(p) || clearlyTest(p);
}
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
export function stepTestsOnlyFromFields(f, version) {
const text = f.has('Files') ? f.get('Files').text : null;
const files = text === null ? [] : usesFilesGrammar(version) ? entryPaths(text).files : fieldPaths(text);
return { testsOnly: files.length > 0 && files.every(stepTestFile(f, version)), files };
}
// ---- sections (fence-aware) ------------------------------------------------
/**
* Split text at headings of exactly `level` (#-count), ignoring headings inside
* fenced code blocks. Text before the first heading has title null.
* @returns {Array<{title: string|null, start: number, end: number, text: string}>}
* start/end are line indexes (end exclusive).
*/
export function splitSections(text, level = 2) {
const lines = typeof text === 'string' ? text.split('\n') : [];
const prefix = '#'.repeat(level) + ' ';
const fence = fenceTracker();
const out = [];
let cur = { title: null, start: 0 };
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (fence.feed(l)) continue;
if (l.startsWith(prefix)) {
out.push({ ...cur, end: i });
cur = { title: l.slice(prefix.length).trim(), start: i };
}
}
out.push({ ...cur, end: lines.length });
return out
.filter(s => s.end > s.start || s.title !== null)
.map(s => ({ ...s, text: lines.slice(s.start, s.end).join('\n') }));
}
/** First section of `level` whose title equals `title` (case-insensitive), or null. */
export function findSection(text, title, level = 2) {
const want = title.toLowerCase();
return splitSections(text, level).find(s => s.title && s.title.toLowerCase() === want) ?? null;
}
/** Every fenced block whose info string is `lang`, in order. */
export function fencedBlocks(text, lang) {
const out = [];
const lines = typeof text === 'string' ? text.split('\n') : [];
const fence = fenceTracker();
let cur = null;
for (const l of lines) {
const wasInside = fence.inside;
fence.feed(l);
if (!wasInside && fence.inside) {
cur = new RegExp(`^\\s{0,3}(\`{3,}|~{3,})\\s*${lang}\\s*$`).test(l) ? [] : null;
} else if (wasInside && !fence.inside) {
if (cur) out.push(cur.join('\n'));
cur = null;
} else if (cur) {
cur.push(l);
}
}
return out;
}
// ---- module map ------------------------------------------------------------
// Directives that are not node statements: the keyword must stand alone or be
// followed by whitespace, so a node id like `end-user` or `graph.mjs` is kept.
const DIRECTIVE = /^(graph|flowchart|subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)(\s|$)/;
// Directed and undirected links, after labels are stripped:
// --> ---> ==> ===> -.-> -..-> with an optional leading < (both ways);
// --- === -.- ~~~ (no direction: not a dependency).
const LINK = /\s*(<?-{2,}>|<?={2,}>|<?-\.+->|-{3,}|={3,}|-\.+-|~{3,})\s*/;
const NODE_ID = /^[A-Za-z0-9_][\w.\-/]*$/;
const ARROWISH = /-->|==>|-\.+->|<--|<==/;
function stripLabels(line) {
return line
.replace(/"[^"]*"/g, '')
.replace(/:::[\w-]+/g, '')
.replace(/\[\[[^\]]*\]\]|\[\([^\]]*\)\]|\[\/[^\]]*\/\]|\[\\[^\]]*\\\]|\[[^\]]*\]/g, '')
.replace(/\(\(\([^)]*\)\)\)|\(\([^)]*\)\)|\(\[[^\]]*\]\)|\([^)]*\)/g, '')
.replace(/\{\{[^}]*\}\}|\{[^}]*\}/g, '')
.replace(/>[^\]\s][^\]]*\]/g, '')
.replace(/\|[^|]*\|/g, '')
// link text: `-- text -->`, `-. text .->`, `== text ==>`
.replace(/--\s+[^-<>=]+?\s+(-{2,}>)/g, '$1')
.replace(/-\.\s+[^.<>]+?\s+\.(-+>)/g, '-.$1')
.replace(/==\s+[^=<>]+?\s+(={2,}>)/g, '$1');
}
/**
* Parse a mermaid flowchart into directed edges.
* Handles chains (a --> b --> c), `&` groups, `;`-separated statements,
* `<-->` (one edge each way), long arrows, labels and `:::class`.
* @returns {{edges: Array<[string, string]>, unparsed: string[]}}
* unparsed = statements that look like links but yielded no edge.
*/
export function parseMermaid(mermaid) {
const edges = [];
const unparsed = [];
if (typeof mermaid !== 'string') return { edges, unparsed };
for (const raw of mermaid.split(/\r?\n/)) {
for (const stmt0 of raw.split(';')) {
const stmt = stmt0.trim();
if (!stmt || stmt.startsWith('%%') || DIRECTIVE.test(stmt)) continue;
const parts = stripLabels(stmt).split(LINK).map(p => p.trim());
if (parts.length < 3) {
if (ARROWISH.test(stmt)) unparsed.push(stmt);
continue;
}
let ok = true;
const groups = [];
for (let i = 0; i < parts.length; i += 2) {
const ids = parts[i].split('&').map(s => s.trim());
if (ids.length === 0 || ids.some(id => !NODE_ID.test(id))) { ok = false; break; }
groups.push(ids);
}
if (!ok) { unparsed.push(stmt); continue; }
for (let i = 1, g = 0; i < parts.length; i += 2, g++) {
const link = parts[i];
const forward = link.endsWith('>');
const backward = link.startsWith('<');
if (!forward) continue; // undirected: not a dependency
for (const a of groups[g]) {
for (const b of groups[g + 1]) {
edges.push([a, b]);
if (backward) edges.push([b, a]);
}
}
}
}
}
return { edges, unparsed };
}
/** Directed edges [from, to] from mermaid flowchart text (see parseMermaid). */
export function parseMermaidEdges(mermaid) {
return parseMermaid(mermaid).edges;
}
/**
* Find one directed cycle in a module map (mermaid text or an edge list).
* Iterative depth-first search: a deep chain cannot overflow the stack.
* @returns {string[]|null} the cycle as a closed path (first === last), or null.
*/
export function findModuleCycle(input) {
const edges = Array.isArray(input) ? input : parseMermaidEdges(input);
const adj = new Map();
for (const [a, b] of edges) {
if (!adj.has(a)) adj.set(a, []);
if (!adj.has(b)) adj.set(b, []);
adj.get(a).push(b);
}
const state = new Map(); // unset = unvisited, 1 = on the path, 2 = done
for (const root of adj.keys()) {
if (state.has(root)) continue;
const path = [root];
const iters = [0];
state.set(root, 1);
while (path.length > 0) {
const n = path[path.length - 1];
const next = adj.get(n);
const i = iters[iters.length - 1];
if (i >= next.length) {
state.set(n, 2);
path.pop();
iters.pop();
continue;
}
iters[iters.length - 1] = i + 1;
const nx = next[i];
if (state.get(nx) === 1) return [...path.slice(path.indexOf(nx)), nx];
if (!state.has(nx)) {
state.set(nx, 1);
path.push(nx);
iters.push(0);
}
}
}
return null;
}