// 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';
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: ". 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: ".'));
} 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}
* `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;
}
// ---- 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;
}