voyage/lib/parsers/plan-schema.mjs
Kjell Tore Guttormsen bef9c5b3ac
fix: the findings of the release review of 6.3.0
The review of v6.2.0..f669b87 (BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR), each
fixed behind a test that failed first, or, for the two missing tests, a test
shown to fail on a stub of the branch it covers.

- Review steps appended by /trekexecute Phase 7.2 are held to the step rules
  on any plan version: plan-validator --steps-from <N> (on a plan below 1.8 a
  template review step validated and was executed).
- The guard refuses where no one can be asked also when its rules will not
  load; the wave merge path names that refusal instead of MERGE_REFUSED.
  Tests for the classifier-error refusal and post-approval-log's own check.
- release-check: the version files may change only in their version after
  the review (JSON compared without it), README diff lines are read inside
  hunks only, an unreadable diff is exit 2, and npm test runs without
  NODE_TEST_CONTEXT.
- What counts as a step of only tests: a note in parentheses is not a path,
  and a data file under specs/ is not a test. End-to-end: rides on the last
  step that builds behaviour (validator hint, trekplan, template).
- A removed line is cited as <rev>:<path>:<line> (findings-schema hint,
  trekreview re-ask).
- skill-lint counts a trailing newline as no line; regression-check takes
  commandEnv from lib/util/command-env.mjs; CLAUDE.md names four mode-read
  exemptions.
- /security-review, older than v6.2.0: the criteria runner refuses git
  --output, which writes a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:12 +02:00

510 lines
19 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`). 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 field names. Each entry (comma- or line-separated, a sub-bullet's
* `-` dropped) gives its backticked spans, or else its first word: a plain
* path beside a backticked one is still a path (independent review, order 10).
* A parenthesised note is not an entry: "`a.test.mjs` (new: covers x, y)"
* names one path, not "y)" too (release review of 6.3.0, 2026-10-10).
*/
function fieldPaths(text) {
const out = [];
for (const raw of String(text ?? '').replace(/\([^()\n]*\)/g, ' ').split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const spans = [...entry.matchAll(/`([^`\n]+)`/g)].map((m) => m[1].trim());
if (spans.length > 0) out.push(...spans);
else out.push(entry.split(/\s+/)[0]);
}
return out.filter(Boolean);
}
// A document is never the test here: `docs/specs/api.md` sits under a `specs/`
// folder, which lib/util/test-path.mjs reads as tests for the floor guard.
// Nor is a data file under a `spec/` or `specs/` folder (`api/specs/openapi.yaml`
// is an API spec); under tests/ it 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) {
const f = parseStepFields(body);
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
const testFirst = f.has('Test first')
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => m[1].trim())
: [];
const isTest = (p) => testFirst.includes(p) ||
(isTestPath(p) && !DOC_RE.test(p) && !(DATA_RE.test(p) && !UNDER_TESTS_RE.test(p)));
return { testsOnly: files.length > 0 && files.every(isTest), 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;
}