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>
518 lines
20 KiB
JavaScript
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;
|
|
}
|