Round 8 (0f400d3..29b4ac9, BLOCK: 1 BLOCKER, 12 MAJOR, 1 MINOR). Each fix
began with a test that failed on 29b4ac9 (13 tests, all red there); suite
2221 (2219/0/2).
- BLOCKER: a backticked check whose trailing comment holds a quote, $,
backtick or backslash is refused, not dropped. The comment was never
screened, while the executor hands the Verify span as written to red
proof, the test lock and the done line inside --cmd "...", where a $(...)
runs and a quote ends the argument. trekplan's text and the reviewer's
NOT RUN table say so.
- A span that is only a comment is not-a-command, not a placeholder. The
dropComment text says the allowlist still refuses a word with a # in it.
- The criteria runner, comparison-gate and end-state-gate use isMain: they
did nothing and exited 0 under a symlinked path. The test now refuses any
process.argv[1] comparison outside lib/util/is-main.mjs.
- Files: in a field with backticked paths, a plain word counts only before
the entry's description (a dash, or a colon after the path); a name with
no slash only first in its entry or after and/or/+/&. "mocks
fs.readFileSync", "runs on Node.js 22.x" or "no change to package.json"
no longer make a tests-only step look like it builds something.
Lowercase makefile, rakefile and justfile are build files. Measured on the
520 Files: fields in 48 plans of the marketplace and the test repo:
3 fields read differently (two notes no longer read as files, one
duplicate name dropped), tests-only changed in 0.
- The test lock compares --cmd without its trailing comment, and the test
lock and the done line read a stop with stoppedBy, the one reading.
red-proof drops its second reading, spawnError.
- release-check reads a relative --review in --repo or, when only that one
exists, from the current directory as 6.3.0 did; two different files are
exit 2, never guessed. The runbook says so.
- One trailing-json reader: a closing fence may be longer than three
backticks, in the block reader and the verdict gate alike, and the review
validator and the synthesis digest use it, so three backticks inside a
JSON string no longer hide a BLOCKER from the remediation-step check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
600 lines
24 KiB
JavaScript
600 lines
24 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, 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), 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 the backticked path): 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"). Round 7: they
|
|
* were dropped, and a step that builds something read as tests-only. Round 8:
|
|
* "— mocks fs.readFileSync", "runs on Node.js 22.x" or "no change to
|
|
* package.json" 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 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 = [];
|
|
for (const raw of masked.split(/[,\n]/)) {
|
|
const entry = raw.trim().replace(/^[-*]\s+/, '');
|
|
if (entry === '') continue;
|
|
const tokens = entry.split(/\s+/);
|
|
// A backticked capitalised name alone in its entry is a file (round 6).
|
|
const alone = /^\u0000(\d+)\u0000$/.exec(entry);
|
|
if (alone && BARE_NAME.test(spans[Number(alone[1])])) { out.push(spans[Number(alone[1])]); 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).
|
|
let described = false;
|
|
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);
|
|
}
|
|
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;
|
|
}
|