// lib/parsers/plan-schema.mjs // Plan schema parser — heading shape, per-step fields, ceremony, design part. // // The canonical step heading is `### Step N: ` (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) { 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) * @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); const rest = line.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, closed: false }); 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). const last = entries[entries.length - 1]; if (last.closed) return; const joined = wrapLine(last.entry, rest); if (joined === null) last.closed = true; 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; }