voyage/lib/parsers/plan-schema.mjs
Kjell Tore Guttormsen ed87cc668d
fix: round 2 of the release review of 6.3.0
Round 2 (v6.2.0..0d044f6, BLOCK: 2 BLOCKER, 11 MAJOR, 1 MINOR), each fixed
behind a test that failed first.

- The git runner checks only words /bin/sh passes through unchanged (no
  quote, backslash, $, glob or brace) and refuses --output and its
  abbreviations: round 1's --output refusal was bypassed by quoting.
- fieldPaths: backticked paths are kept as written, parentheses and all; in
  plain text every path-like word counts, and a parenthesised note does not.
- plan-validator --steps-from reads the plan's ## Verification for an
  appended End-to-end step, and warns instead of failing under --soft.
- regression-check prints progress_status, the value progress.json takes;
  /trekexecute copies it.
- release-check: a plugin in a subdirectory is checked in its own directory,
  the suite is not run when a cheaper check already makes the answer exit 2,
  and a bad argument or a crash is exit 2.
- Skill routing looks a skill up among skills only, and builds the corpus
  vectors once.
- /trekexecute Phase 7.2 says plainly that remediation steps in the review
  template's own form stop with a re-plan; making them runnable is a later
  order (operator 2026-10-10).

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

518 lines
20 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, in order. A backticked span is a path as written,
* parentheses and all (`app/(auth)/x.sh`). In the plain text around the spans
* a parenthesised note is not a path ("(new: covers x, y)"), and every word
* that looks like a path is one ("tests/a.test.mjs + src/b.mjs" names both);
* an entry (comma- or line-separated) with no such word gives its first word,
* so a bare `Makefile` is still a path. Release review of 6.3.0, rounds 1-2.
*/
const PATH_WORD = /^(?:[\w@.-]+\/)+[\w@.-]+$|^[\w@-][\w@.-]*\.[A-Za-z0-9]+$/;
function fieldPaths(text) {
const spans = [];
const masked = String(text ?? '').replace(/`([^`\n]+)`/g, (_, p) => `\u0000${spans.push(p.trim()) - 1}\u0000`);
const out = [];
for (const raw of masked.replace(/\([^()\n]*\)/g, ' ').split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const found = [];
for (const word of entry.split(/\s+/)) {
const span = /^\u0000(\d+)\u0000$/.exec(word);
if (span) found.push(spans[Number(span[1])]);
else if (PATH_WORD.test(word.replace(/[.;:]+$/, ''))) found.push(word.replace(/[.;:]+$/, ''));
}
out.push(...(found.length > 0 ? found : [entry.split(/\s+/)[0]]));
}
return out.filter((p) => p && !p.includes('\u0000'));
}
// 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;
}