// 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`). 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.8'; // 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on. export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']); /** 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; } // ---- 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. */ export function fenceTracker() { let open = null; // { ch, len } return { feed(line) { const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/); 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, and in every plan measured (520 Files: fields in 182 plans, 11.10) * a field with backticked paths had no other path-like word beside them: * * - 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), and a backticked name * with no extension that stands alone in its entry (`Makefile`). Its descriptions are * not read: "covers pass/fail", "JSON, YAML" or a directory named in passing * is no path. An entry of its own with no backticks (", .gitignore") counts * by its first word, and only when that is clearly a file or a directory. * - 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/`) or a glob * (`src/*.mjs`); a parenthesised note is not read. A word with no slash and * no extension counts only alone in its entry and capitalised (`Makefile`); * "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-5. */ 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*]*)$|\/$/; const norm = (p) => p.replace(/\\/g, '/').replace(/^\.\//, ''); // 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); 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(tickedPath); const tickedField = isTicked.some(Boolean); const out = []; for (const raw of masked.split(/[,\n]/)) { const entry = raw.trim().replace(/^[-*]\s+/, ''); if (entry === '') continue; const inEntry = [...entry.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])); const ticked = inEntry.filter((i) => isTicked[i]); if (ticked.length) { out.push(...ticked.map((i) => spans[i])); continue; } // A backticked `Makefile` alone in its entry is a file (round 6). if (inEntry.length === 1 && /^\u0000\d+\u0000$/.test(entry) && BARE_NAME.test(spans[inEntry[0]])) { out.push(spans[inEntry[0]]); continue; } const words = entry.split(/\s+/).filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean); if (tickedField) { const first = plainWord(entry.split(/\s+/)[0]); if (!first.includes('\u0000') && isPathWord(first) && FILE_OR_DIR.test(first)) out.push(first); continue; } const found = words.filter(isPathWord); if (found.length === 0 && words.length === 1 && entry.split(/\s+/).length === 1 && BARE_NAME.test(words[0])) found.push(words[0]); out.push(...found); } return out.filter(Boolean).map(norm); } // 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__)\//; /** * 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. * @param {string} body - the step's section (sliceSteps) * @returns {{testsOnly: boolean, files: string[]}} */ export function stepTestsOnly(body) { return stepTestsOnlyFromFields(parseStepFields(body)); } /** * Whether a path counts as a test for this step: its own `Test first:` file, or * a path that is clearly a test. red-proof.mjs uses it on the step's diff. * @returns {(path: string) => boolean} */ export function stepTestFile(f) { const testFirst = f.has('Test first') ? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => norm(m[1].trim())) : []; return (p) => testFirst.includes(p) || (isTestPath(p) && (UNDER_TESTS_RE.test(p) || !(DOC_RE.test(p) || DATA_RE.test(p)))); } /** `stepTestsOnly` for a caller that already holds the step's parsed fields. */ export function stepTestsOnlyFromFields(f) { const files = f.has('Files') ? fieldPaths(f.get('Files').text) : []; return { testsOnly: files.length > 0 && files.every(stepTestFile(f)), 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; }