Round 9 (0f400d3..a10018f, WARN: 0 BLOCKER, 12 MAJOR): seven findings and three brief notes, by operator choice. Each finding began with a test that failed ona10018fon its assertion; suite 2230 (2228/0/2). - A check refused for its comment (a quote, $, backtick or backslash) keeps its command and its reason, and every reader refuses it out loud: a nested ## Verification bullet stays a criterion (PLAN_VERIFICATION_NOT_RUNNABLE) and a brief SC warns (BRIEF_SC_NOT_RUNNABLE). It was folded into a sub-point or read as prose, a regression froma10018f. Readers that took a command as runnable (the end-to-end lists) skip a refused one. - The verdict gate reads the last fenced block's info string and body from one reading, and exits 2 unless the info string is exactly json: a last block opened as ```json {x} held a BLOCKER the gate passed with exit 0 (also in 6.3.0). A last block never closed is exit 2 too. - One fence reader (findings-schema fencedBlocks) for the gate and every trailing-json reader: a fence closes only on a run at least as long as its opener, as in Markdown, so a four-backtick example of a json block is one block; CRLF and an indented fence read alike in the gate and the readers. - Files: a description runs on past a comma and onto the next line until a new entry starts (a list item on a new line, or a backticked path), and a plain path followed by a colon starts its description. Measured over all 939 Files: fields in the .md files under the marketplace and the test repo (560 of them in the 48 plan.md files outside worktrees): 0 read differently, tests-only changed in 0. - The test lock compares --cmd by its words, so other blanks between the same words are the step's command. - release-check decides "two files" on real paths: one review.md reached through a symlinked --repo is one file. - Brief notes: red proof's stopped control run has its own test (green at once, red with the branch deleted); the two tests that assumed jest is not installed run with a PATH that finds only git (red ona10018fwith a jest on PATH, green now); the Files: reader's doc states the measured counts (520 fields in 48 plan files at rounds 7-8, not "182 plans"; 560 now). Not in this round (operator 2026-10-11): a144e9ce, 1dc2e785, dc3a467e, 2fa88853, 26873fa5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
623 lines
26 KiB
JavaScript
623 lines
26 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 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), 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. 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:
|
|
* "src/cli.mjs: wires parse"): 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, or a
|
|
* backticked path. 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. "covers
|
|
* pass/fail", "JSON, YAML" or "v6.3.0 format" 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-7.
|
|
*/
|
|
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();
|
|
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);
|
|
// 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))));
|
|
|
|
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, or a backticked path (round 9).
|
|
let described = false;
|
|
for (const line of masked.split('\n')) {
|
|
if (LIST_ITEM.test(line)) described = false;
|
|
for (const raw of line.split(',')) {
|
|
const entry = raw.trim().replace(/^[-*]\s+/, '');
|
|
if (entry === '') continue;
|
|
const tokens = entry.split(/\s+/);
|
|
const first = /^\u0000(\d+)\u0000/.exec(entry);
|
|
if (first && isTicked[Number(first[1])]) described = false;
|
|
// A backticked capitalised name alone in its entry is a file (round 6).
|
|
if (first && first[0] === entry && BARE_NAME.test(spans[Number(first[1])])) {
|
|
out.push(spans[Number(first[1])]);
|
|
described = false;
|
|
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 (round 9).
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
const t = tokens[i];
|
|
if (t.includes('\u0000')) {
|
|
out.push(...[...t.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((n) => isTicked[n]).map((n) => spans[n]));
|
|
if (t.endsWith(':')) described = true;
|
|
continue;
|
|
}
|
|
if (DESCRIPTION_DASH.test(t)) described = true;
|
|
const w = plainWord(t);
|
|
if (described || !isFileWord(w)) continue;
|
|
if (w.includes('/') || 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);
|
|
}
|
|
|
|
// 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.
|
|
* @returns {(path: string) => boolean}
|
|
*/
|
|
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;
|
|
}
|