import { canonicalPlayExportName, isDefinePlayCall, listPlayFileExports, playExportNamesForMessage, PLAY_DEFAULT_EXPORT, } from './play-exports'; import { astArray, isAstNode, parsePlaySourceForAnalysis, type AstNode, } from './ts-ast'; export type PlayDocflowNodeKind = | 'action' | 'decision' | 'dataset' | 'play' | 'conceptual'; /** * The valid `type:"…"` values a `// @mermaid-node` annotation may carry, in the * order the author should think about them (the concrete work kinds first, the * presentation-only kind last). The single source of truth for both the runtime * validator ({@link nodeKind}) and the "unsupported type" diagnostic, so the * error message can never enumerate a set that drifts from what is accepted. */ export const PLAY_DOCFLOW_NODE_KINDS = [ 'action', 'decision', 'dataset', 'play', 'conceptual', ] as const satisfies readonly PlayDocflowNodeKind[]; export type PlayDocflowNode = { id: string; label: string; kind: PlayDocflowNodeKind; /** * No statement in this play runs this box — the author said so with * `class sketch`. See {@link SKETCH_CLASS}. * * Deliberately NOT a `kind`. What a box IS (an action, a decision, a dataset) * and whether code binds it are two different questions, and folding the * second into the first was wrong in a way the tests caught immediately: a * sketched diamond stopped being a decision, so it lost its shape on the * canvas and the branch-label lint stopped checking its arms. Absent rather * than `false` when bound, so the JSON a bound diagram hashes to is byte for * byte what it was before sketches existed. */ sketch?: true; }; /** * Which arm of a conditional a drawn decision edge IS. * * The runtime's own two-valued vocabulary for a `runIf` (ADR 0019): the cell * record says `branch: 'run' | 'else'`, and this is the same token on the * diagram's side of the join. Deliberately NOT the arm's label — a label is the * author's prose ("fit 65 or better", "nicht gefunden") and says nothing about * polarity in any language. */ export type PlayDocflowArm = 'run' | 'else'; export const PLAY_DOCFLOW_ARMS = [ 'run', 'else', ] as const satisfies readonly PlayDocflowArm[]; export type PlayDocflowEdge = { from: string; to: string; label?: string; /** * The conditional arm this edge is, when the author recorded it. * * ABSENT — never `null` — when unrecorded, and that is load-bearing rather * than stylistic. `docflow` is whole-object serialized into * `playStaticPipelineContractHash` (`src/lib/plays/artifact-storage.ts`), * which is part of the immutable artifact storage key, and the canonicalizer * there drops `undefined` but HASHES `null`. Emitting `arm: null` on an * unannotated edge would change the contract hash of every diagrammed play * ever published and force a republish. Omission is what keeps this additive. */ arm?: PlayDocflowArm; }; /** * A Mermaid `subgraph … end` region. When an edge connects it to a dataset * node, it models that dataset's per-row loop; its members represent the * per-row column work. See `docs/play-syntax-spec.md`. `memberIds` records the * innermost subgraph for nested regions. */ export type PlayDocflowSubgraph = { id: string; label: string; memberIds: string[]; }; export type PlayDocflowBinding = { nodeId: string; line: number; label?: string; kind?: PlayDocflowNodeKind; /** Symbolic values read by this business node. Never an arbitrary JS expression. */ inputs?: string[]; /** Symbolic values produced or changed by this business node. */ outputs?: string[]; /** Whether the contract was authored, safely inferred, or still needs help. */ ioConfidence?: 'explicit' | 'inferred' | 'ambiguous'; /** * `arm:"run"` / `arm:"else"` — this node is that arm of the decision above it. * * Recorded on the annotation because the annotation is the only place the two * halves of the join meet: a `@mermaid-node` binds a DIAGRAM id to the SOURCE * statement directly beneath it, so the author writing it is the one person * who knows both which drawn arm this is and which side of the `runIf` the * code under it implements. Projected onto the incoming decision edge by * {@link attachBindingsToBlocks}; the edge is what readers resolve against. */ arm?: PlayDocflowArm; }; export type PlayDocflow = { direction: 'LR' | 'RL' | 'TB' | 'TD' | 'BT'; nodes: PlayDocflowNode[]; edges: PlayDocflowEdge[]; bindings: PlayDocflowBinding[]; /** Authoring syntax used by the source file. */ syntax?: 'mermaid'; /** Normalized Mermaid text, ready to pass directly to a Mermaid renderer. */ mermaidSource?: string; /** * Mermaid `subgraph` loop regions. Optional for persisted graphs that have * no authored regions. */ subgraphs?: PlayDocflowSubgraph[]; /** Mermaid directives accepted by the parser but not applied by React Flow. */ ignoredDirectives?: string[]; }; export type PlayDocflowParseResult = { docflow: PlayDocflow | null; errors: string[]; }; export type ParsePlayDocflowOptions = { /** * Which exported play's diagram to return. Defaults to the file's default * export. Aliases resolve: a file ending `export default scalar` answers to * both `scalar` and `default`. */ exportName?: string | null; }; /** One `@mermaid` block, already bound to the export it describes. */ export type PlayDocflowBlock = { /** Canonical export name — `default` for an unnamed block. */ exportName: string; docflow: PlayDocflow; }; export type PlayDocflowFileParseResult = { blocks: PlayDocflowBlock[]; /** * Every `// @mermaid-node` binding in the file, across all blocks. Runtime * instrumentation wraps all of them: two exports' statements are disjoint, so * instrumenting both is correct for whichever one actually runs, and it means * the bundler never has to know which export it is building. */ bindings: PlayDocflowBinding[]; errors: string[]; }; export type PlayDocflowLintIssue = { code: | 'docflow_branch_labels_required' | 'docflow_branch_requires_decision' | 'docflow_direction_not_top_down' | 'docflow_layout_complexity' | 'docflow_topology_invalid' | 'docflow_io_ambiguous' | 'docflow_input_not_found' | 'docflow_output_not_found' | 'docflow_label_counts_rows' | 'docflow_directive_ignored'; severity: 'error' | 'warning'; message: string; path?: string; hint?: string; }; export class PlayDocflowCompileError extends Error { readonly diagnostics: string[]; constructor(diagnostics: string[]) { super(diagnostics.join(' ')); this.name = 'PlayDocflowCompileError'; this.diagnostics = diagnostics; } } const DOCFLOW_MAX_NODES = 12; const DOCFLOW_MAX_BRANCHES = 3; const DOCFLOW_MAX_LABEL_LENGTH = 48; // A node label names the thing; the runtime counts it. A label that bakes a // magnitude in ("8k seed rows", "10,000 rows", "500 leads") goes stale the first // time the input changes, and then the canvas shows the authored number beside // the live one — the same quantity in two voices. Two shapes are detectable // without guessing at prose: // 1. a scale-suffixed magnitude — `8k`, `20K`, `1.5M` // 2. a number attached to a counted noun — `10,000 rows`, `500 leads` // Deliberately NOT matched: bare numerals inside names ("SOC 2 signals", // "Series B", "V2 pipeline"), where the digit is part of the thing's name // rather than a count of it. const LABEL_SCALE_MAGNITUDE = /\b\d[\d,.]*\s*[kKmM]\b/; const LABEL_COUNTED_NOUN = /\b\d[\d,]*\s*(rows?|leads?|records?|contacts?|companies|accounts?|items?|results?|seeds?|emails?|domains?|profiles?)\b/i; /** The magnitude fragment a node label bakes in, or null when the label is * count-free. Exported so the lint's message can quote the exact fragment and * so tests pin the detector independently of the lint plumbing. */ export function docflowLabelCountFragment(label: string): string | null { return ( LABEL_SCALE_MAGNITUDE.exec(label)?.[0]?.trim() ?? LABEL_COUNTED_NOUN.exec(label)?.[0]?.trim() ?? null ); } const FLOW_START = /^\s*(?:flowchart|graph)\s+(LR|RL|TB|TD|BT)\s*$/i; const MERMAID_NODE = /^\s*\/\/\s*@mermaid-node\s+([A-Za-z][\w-]*)(?:\s+(.*))?$/; const ATTRIBUTE = /([A-Za-z][\w-]*)\s*:\s*"([^"\n]*)"/y; const MERMAID_NODE_ATTRIBUTES = ['label', 'type', 'in', 'out', 'arm'] as const; const MERMAID_NODE_ID = /^[A-Za-z][\w-]*/; /** * `class a,b,c sketch` — the author declaring that no statement runs these boxes. * * Every other box in a diagram must point at a statement in this play's source, * because that is what makes the diagram a claim about the code rather than a * picture of it. Some boxes honestly cannot: a cascade whose legs live in a * sibling module, a loop over a provider list, the outcome boxes hanging off a * decision. Those boxes still belong on the canvas — they name the real route — * and this is how the author says so out loud, so the reader is told "sketch" * instead of being shown a box that looks misconfigured. * * It is mermaid's own `class` statement rather than a Deepline directive, so the * block stays a diagram any mermaid renderer can draw. Class names other than * `sketch` remain styling this dashboard does not apply, exactly as before. */ const SKETCH_CLASS = /^\s*class\s+([A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*)\s+sketch\s*;?\s*$/; /** * Mermaid's node shapes, longest opener FIRST. * * The order is the contract, not a formatting choice: the parser takes the * first opener that matches at the cursor, so every multi-character opener has * to precede the single-character one it starts with. `([` was missing from * this list while `[(` was present, so `proxy(["Fall back to a growth proxy"])` * — mermaid's stadium shape, and a shape this file's own SHAPE_KINDS docstring * already names — matched the bare `(`, took its label as everything up to the * next `)`, and rendered the node with its own source syntax as the label: * * ["Fall back to a growth proxy"] * * brackets and quotes included, on the canvas, in a shipped prebuilt. The fix * belongs here rather than in a renderer that strips brackets: a label may * legitimately contain one, and a canvas that launders bad parses cannot tell * anyone the parse was bad. */ const MERMAID_NODE_SHAPES = [ ['[[', ']]'], ['[(', ')]'], ['([', '])'], ['{{', '}}'], ['((', '))'], ['[/', '/]'], ['[\\', '\\]'], ['[/', '\\]'], ['[\\', '/]'], ['[', ']'], ['{', '}'], ['(', ')'], ['>', ']'], ] as const; // `subgraph `, `subgraph ["Label"]`, or `subgraph [Label]`. The // label bracket is optional; when present the quotes are stripped by the caller. const SUBGRAPH_OPEN = /^\s*subgraph\s+([A-Za-z][\w-]*)\s*(?:\[(.*)\])?\s*$/i; const SUBGRAPH_END = /^\s*end\s*$/i; const MERMAID_EDGE_CONNECTOR = /^\s*(?:-->|-\.->|==>|---)(?:\|([^|]+)\|)?/; const DOCFLOW_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/; const IDENTIFIER = /[A-Za-z_$][\w$]*/g; const JS_WORDS = new Set([ 'async', 'await', 'const', 'else', 'false', 'if', 'let', 'new', 'null', 'return', 'true', 'undefined', 'var', ]); function parseAttributes( source: string, line: number, errors: string[], ): Record | null { const attributes: Record = {}; const validAttributes = MERMAID_NODE_ATTRIBUTES; let cursor = 0; while (cursor < source.length) { cursor += /^\s*/.exec(source.slice(cursor))?.[0].length ?? 0; if (cursor >= source.length) break; ATTRIBUTE.lastIndex = cursor; const match = ATTRIBUTE.exec(source); if (!match) { const fragment = source.slice(cursor).split(/\s+/)[0] ?? source.slice(cursor); errors.push( `Docflow \`// @mermaid-node\` annotation on line ${line} has malformed attribute ${JSON.stringify(fragment)}. Use key:"value" pairs. Valid attributes: ${validAttributes.map((name) => `"${name}"`).join(', ')}.`, ); return null; } const name = match[1]!; if (!(validAttributes as readonly string[]).includes(name)) { errors.push( `Docflow \`// @mermaid-node\` annotation on line ${line} has unknown attribute "${name}". Valid attributes: ${validAttributes.map((option) => `"${option}"`).join(', ')}.`, ); return null; } if (Object.prototype.hasOwnProperty.call(attributes, name)) { errors.push( `Docflow \`// @mermaid-node\` annotation on line ${line} repeats attribute "${name}". Write each attribute once.`, ); return null; } attributes[name] = match[2]!; cursor = ATTRIBUTE.lastIndex; } return attributes; } function nodeKind( value: string | undefined, ): PlayDocflowNodeKind | null | undefined { if (!value) return undefined; return (PLAY_DOCFLOW_NODE_KINDS as readonly string[]).includes(value) ? (value as PlayDocflowNodeKind) : null; } /** * `undefined` when unwritten, `null` when written as something that is not an * arm. The two are different answers and the caller must not collapse them: an * unwritten attribute is a play that says nothing, a misspelled one is a play * whose author tried to say something and must be told it did not land. */ function nodeArm(value: string | undefined): PlayDocflowArm | null | undefined { if (!value) return undefined; return (PLAY_DOCFLOW_ARMS as readonly string[]).includes(value) ? (value as PlayDocflowArm) : null; } /** * Shapes that carry their kind in the drawing, so an author who never writes a * `type:"…"` attribute still gets the right node. Mermaid's own vocabulary * decides these: `{…}` is the decision rhombus everywhere, and `[[…]]` is the * subroutine box — the shape whose entire meaning in flowchart notation is "a * call into a process defined elsewhere", which is exactly what `ctx.runPlay` * is. Every other shape stays `action` unless the annotation names a kind, * because `[(…)]` (dataset) and `([…])` (conceptual) each have a binding * contract the drawing alone cannot establish. */ const SHAPE_KINDS: Record = { '{': 'decision', '[[': 'play', }; type ParsedEdgeNode = { id: string; label: string; kind: PlayDocflowNodeKind; }; type ParsedMermaidNode = ParsedEdgeNode & { length: number; shaped: boolean; /** * The shape never closed — `foo["…` with no `"]`. * * Carried rather than returned as `null`, because `null` here used to mean * "no node starts at this offset", and an unterminated shape was folded into * the same answer: the declaration vanished, the id survived as a bare edge * endpoint, and the node reached the canvas labelled with its own id. A parse * that could not finish must say so, not shrug. */ unterminated?: { open: string; close: string }; }; /** * Openers a parsed label must never still begin with. * * A label that survives parsing with its own delimiters attached * (`["Fall back to a growth proxy"]`) is a parse that fell through to raw text * — the shape was not recognised, so the "label" is really source. That shipped * to the canvas once; it is an error now. */ const MERMAID_LABEL_LOOKS_LIKE_SOURCE = /^(?:\[|\{|\(|>)/; function parseMermaidNodeAtStart(source: string): ParsedMermaidNode | null { const leadingWhitespace = /^\s*/.exec(source)?.[0].length ?? 0; const idMatch = MERMAID_NODE_ID.exec(source.slice(leadingWhitespace)); if (!idMatch) return null; const id = idMatch[0]; let cursor = leadingWhitespace + id.length; cursor += /^\s*/.exec(source.slice(cursor))?.[0].length ?? 0; const shape = MERMAID_NODE_SHAPES.find(([open]) => source.startsWith(open, cursor), ); if (!shape) { return { id, label: id, kind: 'action', length: cursor, shaped: false }; } const [open, close] = shape; const labelStart = cursor + open.length; // Mermaid's quoted labels may contain the same punctuation that closes the // surrounding shape: `step["Validate [required] fields"]` is ordinary // Mermaid, not a nested node. `indexOf(close)` used to stop at the bracket in // `required]`, accept the declaration, and silently ship the truncated label // `"Validate [required` to the React Flow canvas. When a label opens with a // quote, only a shape closer after its matching (unescaped) quote may end it. const openingQuote = source[labelStart]; let labelEnd = -1; if (openingQuote === '"' || openingQuote === "'") { for (let index = labelStart + 1; index < source.length; index += 1) { if (source[index] === '\\') { index += 1; continue; } if (source[index] !== openingQuote) continue; const whitespace = /^\s*/.exec(source.slice(index + 1))?.[0].length ?? 0; const candidate = index + 1 + whitespace; if (source.startsWith(close, candidate)) { labelEnd = candidate; break; } } } else { labelEnd = source.indexOf(close, labelStart); } if (labelEnd < 0) { return { id, label: id, kind: 'action', length: cursor + open.length, shaped: false, unterminated: { open, close }, }; } const rawLabel = source.slice(labelStart, labelEnd).trim(); const label = rawLabel.length >= 2 && ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) ? rawLabel.slice(1, -1) : rawLabel; return { id, label: label || id, kind: SHAPE_KINDS[open] ?? 'action', length: labelEnd + close.length, shaped: true, }; } function parseShapedMermaidNodes(fragment: string): ParsedMermaidNode[] { const nodes: ParsedMermaidNode[] = []; let cursor = 0; while (cursor < fragment.length) { const identifier = /[A-Za-z][\w-]*/.exec(fragment.slice(cursor)); if (!identifier) break; const start = cursor + (identifier.index ?? 0); const parsed = parseMermaidNodeAtStart(fragment.slice(start)); if (parsed?.shaped || parsed?.unterminated) nodes.push(parsed); cursor = start + Math.max(parsed?.length ?? identifier[0].length, 1); } return nodes; } function parseEdgeChain( line: string, ): { nodes: ParsedEdgeNode[]; labels: Array } | null { let remaining = line; const first = parseMermaidNodeAtStart(remaining); if (!first) return null; const nodes: ParsedEdgeNode[] = [first]; const labels: Array = []; remaining = remaining.slice(first.length); while (remaining.trim()) { const connector = MERMAID_EDGE_CONNECTOR.exec(remaining); if (!connector) return null; remaining = remaining.slice(connector[0].length); const next = parseMermaidNodeAtStart(remaining); if (!next) return null; labels.push(connector[1]?.trim() || undefined); nodes.push(next); remaining = remaining.slice(next.length); } return nodes.length > 1 ? { nodes, labels } : null; } function parseContractPaths( value: string | undefined, attribute: 'in' | 'out', nodeId: string, line: number, errors: string[], ): string[] | undefined { if (value === undefined) return undefined; const paths = value .split(',') .map((path) => path.trim()) .filter(Boolean); if (paths.some((path) => !DOCFLOW_PATH.test(path))) { errors.push( `Docflow annotation "${nodeId}" on line ${line} has an invalid ${attribute}:"…" contract. Use comma-separated identifiers or property paths only — e.g. the assigned const name (out:"result"). Prose labels belong in the diagram box, not here.`, ); return undefined; } return [...new Set(paths)]; } function inferBindingIo( statement: string, ): Pick { const returned = /^return\s+(.+?);?\s*$/.exec(statement.trim()); if (returned) { const inputs = new Set(); const expression = returned[1]!.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, ''); IDENTIFIER.lastIndex = 0; for (const match of expression.matchAll(IDENTIFIER)) { const identifier = match[0]!; const index = match.index ?? 0; if (JS_WORDS.has(identifier) || expression[index - 1] === '.') continue; inputs.add(identifier); } return { inputs: [...inputs], ioConfidence: 'inferred', }; } const assignment = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+);?\s*$/.exec( statement.trim(), ); if (!assignment || assignment[2]!.includes('await')) { return { ioConfidence: 'ambiguous' }; } const output = assignment[1]!; const expression = assignment[2]!; const inputs = new Set(); IDENTIFIER.lastIndex = 0; for (const match of expression.matchAll(IDENTIFIER)) { const identifier = match[0]!; const index = match.index ?? 0; const previous = expression[index - 1] ?? ''; const next = expression[index + identifier.length] ?? ''; // Function names, object-property keys, and language words are not values // flowing into this node. This deliberately keeps inference narrow. if ( JS_WORDS.has(identifier) || identifier === output || previous === '.' || next === ':' || /^\s*\(/.test(expression.slice(index + identifier.length)) ) { continue; } inputs.add(identifier); } return { inputs: [...inputs], outputs: [output], ioConfidence: 'inferred', }; } function rootPath(path: string): string { return path.split('.')[0]!; } function levenshtein(left: string, right: string): number { const row = Array.from({ length: right.length + 1 }, (_, index) => index); for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { let previous = row[0]!; row[0] = leftIndex; for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { const current = row[rightIndex]!; row[rightIndex] = Math.min( row[rightIndex - 1]! + 1, row[rightIndex]! + 1, previous + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1), ); previous = current; } } return row[right.length]!; } const MERMAID_BLOCK = /\/\*\*\s*@mermaid(?:\s|\r?\n)([\s\S]*?)\*\//g; /** * What a block header may name: an export name, or the play's own kebab-case * name. Hyphens are in the set for the second — `@mermaid name-to-linkedin-url- * waterfall` is the header worth writing, and while it was identifier-only the * whole line silently became the diagram's first line, which surfaced as * "Docflow must start with `flowchart`" and named nothing. */ const BLOCK_EXPORT_HEADER = /^[A-Za-z_$][\w$-]*$/; /** Strips the JSDoc `*` gutter and trims, leaving the authored block text. */ function cleanBlockBody(raw: string): string { return raw .split(/\r?\n/) .map((line) => (/^\s*\*/.test(line) ? line.replace(/^\s*\* ?/, '') : line)) .join('\n') .trim(); } /** * Splits `/** @mermaid contact-to-phone-waterfall` into the play it names and * the diagram itself. * * The name is the first token after the tag, the same place `// @mermaid-node * ` puts its target, so one rule covers both halves of the grammar. It is * only a name when it stands alone on its line: `@mermaid flowchart TD` has a * remainder, so it stays what it always was — the first line of the diagram. * `flowchart`/`graph` alone are the diagram too; nobody exports a play by * those names, and reading them as one would turn a working file into an * "unknown export" error. */ function splitBlockExportHeader(body: string): { exportName: string | null; diagram: string; } { const newlineIndex = body.indexOf('\n'); const firstLine = ( newlineIndex < 0 ? body : body.slice(0, newlineIndex) ).trim(); if ( !BLOCK_EXPORT_HEADER.test(firstLine) || /^(?:flowchart|graph)$/i.test(firstLine) ) { return { exportName: null, diagram: body }; } return { exportName: firstLine, diagram: newlineIndex < 0 ? '' : body.slice(newlineIndex + 1).trim(), }; } type ParsedDocflowBlockGraph = { direction: PlayDocflow['direction']; nodes: Map; edges: PlayDocflowEdge[]; subgraphs: Map; ignoredDirectives: string[]; /** Ids a `class … sketch` line declared to run no statement. */ sketchIds: Set; }; type ParsedDocflowBlock = ParsedDocflowBlockGraph & { /** The export name as authored, before canonicalization. */ authoredExportName: string | null; /** Canonical export name, or null when the authored name resolved to none. */ exportName: string | null; syntax: NonNullable; mermaidSource: string; bindings: PlayDocflowBinding[]; }; /** How a block is named in a diagnostic. */ function blockLabel(block: { authoredExportName: string | null }): string { return block.authoredExportName ? `@mermaid ${block.authoredExportName}` : '@mermaid'; } /** * Parses one block's flowchart into nodes, edges and subgraph regions. Returns * null when the block does not open with a direction, which is the one error * that makes the rest of the block unreadable. */ function parseDocflowBlockGraph( mermaidSource: string, errors: string[], ): ParsedDocflowBlockGraph | null { const docLines = mermaidSource .split(/\r?\n/) .filter((line) => line.trim() && !line.trim().startsWith('%%')); const direction = FLOW_START.exec(docLines[0] ?? '')?.[1]?.toUpperCase() as | PlayDocflow['direction'] | undefined; if (!direction) { errors.push( 'Docflow must start with `flowchart` or `graph` and direction `LR`, `RL`, `TB`, `TD`, or `BT`.', ); return null; } const nodes = new Map(); const edges: PlayDocflowEdge[] = []; // Subgraph ids are region containers, not regular nodes. Track them so node // materialization can skip an id that names a subgraph, and so an edge // endpoint naming a subgraph stays a legal reference. Pre-scan the ids first: // an edge on an earlier line may reference a subgraph declared later. const subgraphs = new Map(); const ignoredDirectives: string[] = []; // Boxes the author declared to be a sketch, via mermaid's own // `class a,b sketch`. See {@link SKETCH_CLASS}. const sketchIds = new Set(); const subgraphStack: PlayDocflowSubgraph[] = []; const subgraphIds = new Set(); for (const line of docLines.slice(1)) { const opened = SUBGRAPH_OPEN.exec(line); if (opened) subgraphIds.add(opened[1]!); } // Ids that were DECLARED with a shape somewhere, as opposed to merely named // as an edge endpoint. A node nothing ever declares reaches the canvas // labelled with its own id, which is never what an author meant. const declaredIds = new Set(); const addNodes = (fragment: string, line: string): boolean => { let sawDeclaration = false; for (const node of parseShapedMermaidNodes(fragment)) { const { id, label, kind } = node; if (node.unterminated) { sawDeclaration = true; errors.push( `Docflow node "${id}" opens with \`${node.unterminated.open}\` but never closes with \`${node.unterminated.close}\`: ${line.trim()}`, ); continue; } sawDeclaration = true; // A shaped node carrying a subgraph's id is a real collision — a region // and a node cannot share an id. A bare edge endpoint (unshaped) naming a // subgraph is a legal reference and never reaches here. if (subgraphIds.has(id)) { errors.push( `Docflow subgraph "${id}" collides with a node of the same id.`, ); continue; } // The label kept its own delimiters, so the shape was not recognised and // this "label" is really source text. Loud, because the alternative is a // canvas card reading `["Fall back to a growth proxy"]`. if (MERMAID_LABEL_LOOKS_LIKE_SOURCE.test(label)) { errors.push( `Docflow node "${id}" has an unreadable label \`${label}\` — the shape around it is not one this parser knows, so its own syntax became the label. Supported shapes: ${MERMAID_NODE_SHAPES.map(([open, close]) => `${open}…${close}`).join(', ')}. Line: ${line.trim()}`, ); continue; } declaredIds.add(id); const existing = nodes.get(id); // An id already materialized as a bare edge endpoint carries its own id as // a placeholder label; the real declaration upgrades it rather than // colliding with it. if (existing && existing.label === existing.id && label !== id) { existing.label = label; existing.kind = kind; } else if (existing && existing.label !== label) { errors.push(`Docflow node "${id}" has conflicting labels.`); } else if (!existing) { nodes.set(id, { id, label, kind }); // Innermost subgraph claims membership of nodes declared inside it. subgraphStack[subgraphStack.length - 1]?.memberIds.push(id); } } return sawDeclaration; }; for (const line of docLines.slice(1)) { const opened = SUBGRAPH_OPEN.exec(line); if (opened) { const id = opened[1]!; const rawLabel = opened[2]?.trim() ?? ''; const label = rawLabel.length >= 2 && ((rawLabel.startsWith('"') && rawLabel.endsWith('"')) || (rawLabel.startsWith("'") && rawLabel.endsWith("'"))) ? rawLabel.slice(1, -1) : rawLabel; const subgraph: PlayDocflowSubgraph = { id, label: label || id, memberIds: [], }; subgraphs.set(id, subgraph); subgraphStack.push(subgraph); continue; } if (SUBGRAPH_END.test(line)) { if (subgraphStack.length === 0) { errors.push( 'Docflow has an `end` with no open `subgraph` above it. Every `end` closes exactly one `subgraph`.', ); } subgraphStack.pop(); continue; } let declaredOnThisLine = false; const isDirective = /^\s*(?:direction|classDef|class|style|linkStyle|click)\b/i.test(line); if (isDirective) { const sketch = SKETCH_CLASS.exec(line); if (sketch) { for (const id of sketch[1]!.split(',')) { const trimmed = id.trim(); if (trimmed) sketchIds.add(trimmed); } } else { ignoredDirectives.push(line.trim()); } } if (!isDirective) { declaredOnThisLine = addNodes(line, line); } const chain = parseEdgeChain(line); if (!chain) { if (!isDirective && !declaredOnThisLine) { // Mermaid the parser could not consume at all: no edge, no node // declaration, not a directive it knowingly ignores. It used to be // skipped in silence, so a typo'd arrow or a stray token simply removed // part of the diagram and nothing said so. errors.push( `Docflow line is not an edge, a node declaration, or a directive this parser understands: ${line.trim()}`, ); } continue; } addNodes(line, line); for (const node of chain.nodes) { // An edge endpoint may name a subgraph id; keep the edge but do not // materialize a regular node for the region. if (subgraphIds.has(node.id)) continue; const existing = nodes.get(node.id); if (!existing) { nodes.set(node.id, node); subgraphStack[subgraphStack.length - 1]?.memberIds.push(node.id); } else if (existing.label === existing.id && node.label !== node.id) { existing.label = node.label; existing.kind = node.kind; } } for (let index = 0; index < chain.nodes.length - 1; index += 1) { edges.push({ from: chain.nodes[index]!.id, to: chain.nodes[index + 1]!.id, ...(chain.labels[index] ? { label: chain.labels[index] } : {}), }); } } if (subgraphStack.length > 0) { errors.push( `Docflow subgraph${subgraphStack.length === 1 ? '' : 's'} ${subgraphStack .map((subgraph) => `"${subgraph.id}"`) .join( ', ', )} ${subgraphStack.length === 1 ? 'is' : 'are'} never closed with \`end\`. Everything below an unclosed subgraph is silently drawn inside it.`, ); } // An id that only ever appeared as an edge endpoint has no label of its own, // so the canvas would print its id. Subgraph ids are exempt: naming a region // in an edge is the documented way to wire one up. for (const node of nodes.values()) { if (declaredIds.has(node.id) || subgraphIds.has(node.id)) continue; errors.push( `Docflow node "${node.id}" is referenced by an edge but never declared with a shape and label, so the canvas would show its id. Declare it once, e.g. \`${node.id}["What this step does"]\`.`, ); } for (const id of sketchIds) { const node = nodes.get(id); if (!node) { errors.push( subgraphs.has(id) ? `Docflow \`class ${id} sketch\` names a subgraph. A subgraph is a region, not a box, and never binds code — drop it from the class line.` : `Docflow \`class ${id} sketch\` names "${id}", which this diagram does not draw.`, ); continue; } node.sketch = true; } return { direction, nodes, edges, subgraphs, ignoredDirectives, sketchIds }; } function materializeDocflow(block: ParsedDocflowBlock): PlayDocflow { return { direction: block.direction, nodes: [...block.nodes.values()], edges: block.edges, bindings: block.bindings, syntax: block.syntax, mermaidSource: block.mermaidSource, ...(block.subgraphs.size ? { subgraphs: [...block.subgraphs.values()] } : {}), ...(block.ignoredDirectives.length ? { ignoredDirectives: block.ignoredDirectives } : {}), }; } /** * Parses EVERY `@mermaid` block in a file and binds each to the export it * describes. One file can carry one play or several; a block names its export * in its header and an unnamed block means the default export, so every diagram * written before per-export blocks existed keeps meaning exactly what it meant. */ export function parsePlayDocflowFile( sourceCode: string, ): PlayDocflowFileParseResult { const errors: string[] = []; const rawBlocks: Array<{ syntax: NonNullable; body: string; }> = []; MERMAID_BLOCK.lastIndex = 0; for (const match of sourceCode.matchAll(MERMAID_BLOCK)) { rawBlocks.push({ syntax: 'mermaid', body: cleanBlockBody(match[1]!) }); } if (rawBlocks.length === 0) return { blocks: [], bindings: [], errors }; // Ids whose annotation was written but refused. They are NOT unbound boxes: // the author already has one actionable error about them, and telling them to // "bind it" on the next line is telling them to do what they just did. const rejectedNodeIds = new Set(); const bindings = parseDocflowBindings(sourceCode, errors, rejectedNodeIds); const parsedBlocks: ParsedDocflowBlock[] = []; for (const raw of rawBlocks) { const { exportName, diagram } = splitBlockExportHeader(raw.body); const graph = parseDocflowBlockGraph(diagram, errors); if (!graph) continue; parsedBlocks.push({ ...graph, authoredExportName: exportName, exportName: null, syntax: raw.syntax, mermaidSource: diagram, bindings: [], }); } resolveBlockExports(sourceCode, parsedBlocks, errors); attachBindingsToBlocks( sourceCode, parsedBlocks, bindings, errors, rejectedNodeIds, ); return { blocks: parsedBlocks .filter((block) => block.exportName !== null) .map((block) => ({ exportName: block.exportName!, docflow: materializeDocflow(block), })), bindings, errors, }; } /** * Resolves each block's `@mermaid ` header to a canonical export name, * defaulting an unnamed block to the default export. A header naming an export * the file does not define is an error listing what it does define — never a * quietly ignored diagram. */ function resolveBlockExports( sourceCode: string, blocks: ParsedDocflowBlock[], errors: string[], ): void { // The export list costs an AST parse, so only pay for it when a header could // change the answer. An undiagrammed play, and a play with the one unnamed // block every existing diagram uses, never reach this. const fileExports = blocks.some((block) => block.authoredExportName !== null) ? listPlayFileExports(sourceCode) : null; const claimed = new Map(); for (const block of blocks) { if (block.authoredExportName === null) { block.exportName = PLAY_DEFAULT_EXPORT; } else if (fileExports === null) { // Source acorn could not parse: the TypeScript diagnostics own that // failure, so take the header at face value rather than inventing a // second, more confusing error on top of it. block.exportName = block.authoredExportName; } else { const canonical = canonicalPlayExportName( block.authoredExportName, fileExports, ); if (!canonical) { const available = playExportNamesForMessage(fileExports); errors.push( `Docflow block \`${blockLabel(block)}\` names an export this file does not define. ` + (available.length ? `Exported plays: ${available.map((name) => `"${name}"`).join(', ')}.` : 'This file exports no play.'), ); continue; } block.exportName = canonical; } const existing = claimed.get(block.exportName); if (existing) { errors.push( `Docflow blocks \`${blockLabel(existing)}\` and \`${blockLabel(block)}\` both describe export "${block.exportName}". One diagram per export.`, ); block.exportName = null; continue; } claimed.set(block.exportName, block); } } /** * Routes each `// @mermaid-node` annotation to the block that declares its node * id. An id declared by no block, or by two, is an error rather than a guess: * annotations are file-scoped text and only the diagrams say which play a node * belongs to. */ type PlayExportSourceRange = { exportName: string; start: number; end: number; }; function unwrapExportExpression(node: AstNode | null): AstNode | null { let current = node; while ( current && (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression' || current.type === 'TSTypeAssertion' || current.type === 'TSNonNullExpression' || current.type === 'ParenthesizedExpression') ) { current = isAstNode(current.expression) ? current.expression : null; } return current; } /** Source extent of each exported definePlay call, keyed by canonical export. */ function playExportSourceRanges(sourceCode: string): PlayExportSourceRange[] { const ast = parsePlaySourceForAnalysis(sourceCode); const fileExports = listPlayFileExports(sourceCode); if (!ast || !fileExports) return []; const declarations = new Map(); const namedExports = new Map(); let defaultExpression: AstNode | null = null; for (const statement of astArray(ast.body)) { const recordDeclarations = (declaration: AstNode, exported: boolean) => { for (const declarator of astArray(declaration.declarations)) { const id = isAstNode(declarator.id) ? declarator.id : null; const name = id?.type === 'Identifier' && typeof id.name === 'string' ? id.name : null; if (!name) continue; declarations.set( name, isAstNode(declarator.init) ? declarator.init : null, ); if (exported) namedExports.set(name, name); } }; if (statement.type === 'VariableDeclaration') { recordDeclarations(statement, false); } else if (statement.type === 'ExportDefaultDeclaration') { defaultExpression = isAstNode(statement.declaration) ? statement.declaration : null; } else if (statement.type === 'TSExportAssignment') { defaultExpression = isAstNode(statement.expression) ? statement.expression : null; } else if (statement.type === 'ExportNamedDeclaration') { if ( isAstNode(statement.declaration) && statement.declaration.type === 'VariableDeclaration' ) { recordDeclarations(statement.declaration, true); } for (const specifier of astArray(statement.specifiers)) { const local = isAstNode(specifier.local) ? specifier.local : null; const exported = isAstNode(specifier.exported) ? specifier.exported : null; if ( local?.type === 'Identifier' && typeof local.name === 'string' && exported?.type === 'Identifier' && typeof exported.name === 'string' ) { namedExports.set(exported.name, local.name); } } } } const resolveCall = ( expression: AstNode | null, seen = new Set(), ): AstNode | null => { const unwrapped = unwrapExportExpression(expression); if (!unwrapped) return null; if (isDefinePlayCall(unwrapped)) return unwrapped; if ( unwrapped.type !== 'Identifier' || typeof unwrapped.name !== 'string' || seen.has(unwrapped.name) ) { return null; } seen.add(unwrapped.name); return resolveCall(declarations.get(unwrapped.name) ?? null, seen); }; const defaultCall = resolveCall(defaultExpression); const ranges: PlayExportSourceRange[] = []; for (const fileExport of fileExports) { const call = fileExport.name === PLAY_DEFAULT_EXPORT ? defaultCall : resolveCall( declarations.get(namedExports.get(fileExport.name) ?? '') ?? null, ); if ( call && typeof call.start === 'number' && typeof call.end === 'number' ) { ranges.push({ exportName: fileExport.name, start: call.start, end: call.end, }); } } return ranges; } /** * Moves each `arm:"…"` from the annotation that declared it onto the drawn edge * it is about, and refuses every shape where that edge is not unambiguous. * * The annotation is node-scoped because that is where the author is standing; * the FACT is edge-scoped, because "which arm is this" is a question about the * line from the diamond, not about the box it lands in. A box two decisions both * point at has two answers and gets none. * * Runs after node kinds are applied, so `type:"decision"` written on the diamond * is already visible here. Errors are hard — `parsePlayDocflow` returns a null * docflow when any is pushed — because a MISPLACED arm claim is worse than no * arm claim: the reader is told the run took the arm it did not take, in a * surface whose entire job is to be believed. */ function projectRecordedArms( block: ParsedDocflowBlock, errors: string[], ): void { const declaredBy = new Map(); for (const binding of block.bindings) { if (!binding.arm) continue; const incoming = block.edges.filter( (edge) => edge.to === binding.nodeId && block.nodes.get(edge.from)?.kind === 'decision', ); if (incoming.length === 0) { errors.push( `Docflow annotation "${binding.nodeId}" declares arm:"${binding.arm}" but no decision points at it. Put arm:"…" on the node a decision's labelled edge leads to.`, ); continue; } if (incoming.length > 1) { errors.push( `Docflow annotation "${binding.nodeId}" declares arm:"${binding.arm}" but ${incoming.length} decisions point at it, so the arm names no single edge. Give each decision its own arm node.`, ); continue; } const edge = incoming[0]!; // One decision, one meaning per token. Two arms both claiming `run` is the // exact defect the recorded identity exists to make impossible, so it is // refused at the source rather than resolved by precedence downstream. const key = `${edge.from}\u0000${binding.arm}`; const already = declaredBy.get(key); if (already !== undefined) { errors.push( `Decision "${edge.from}" has two arms declaring arm:"${binding.arm}" ("${already}" and "${binding.nodeId}"). A conditional has one run arm and one else arm.`, ); continue; } declaredBy.set(key, binding.nodeId); edge.arm = binding.arm; } } function attachBindingsToBlocks( sourceCode: string, blocks: ParsedDocflowBlock[], bindings: readonly PlayDocflowBinding[], errors: string[], rejectedNodeIds: ReadonlySet, ): void { const lineStarts = sourceLineStartsForDocflow(sourceCode); const exportRanges = playExportSourceRanges(sourceCode); for (const binding of bindings) { let owners = blocks.filter((block) => block.nodes.has(binding.nodeId)); if (owners.length === 0) { errors.push( `Docflow annotation "${binding.nodeId}" does not name a graph node.`, ); continue; } if (owners.length > 1) { const lineStart = lineStarts[binding.line - 1]; if (lineStart !== undefined) { const scoped = owners.filter((owner) => exportRanges.some( (range) => range.exportName === owner.exportName && lineStart >= range.start && lineStart < range.end, ), ); if (scoped.length === 1) owners = scoped; } } if (owners.length > 1) { errors.push( `Docflow annotation "${binding.nodeId}" names a node in ${owners.map((owner) => `\`${blockLabel(owner)}\``).join(' and ')}, and its enclosing play could not identify one owner. Put the annotation inside the definePlay handler for the export it describes.`, ); continue; } const owner = owners[0]!; if ( owner.bindings.some((candidate) => candidate.nodeId === binding.nodeId) ) { errors.push( `Docflow node "${binding.nodeId}" has more than one code binding.`, ); continue; } owner.bindings.push(binding); } for (const block of blocks) { for (const binding of block.bindings) { const node = block.nodes.get(binding.nodeId)!; if (binding.label) node.label = binding.label; // A box cannot be both a sketch and a bound statement. Silently letting // one win renders a real, traceable step as scenery — or the reverse — and // the author who wrote both has no way to see which they got. if (block.sketchIds.has(binding.nodeId)) { errors.push( `Docflow box "${binding.nodeId}" is declared \`class ${binding.nodeId} sketch\` and also bound by \`// @mermaid-node ${binding.nodeId}\` on line ${binding.line}. It is one or the other: drop it from the class line, or drop the annotation.`, ); continue; } if (binding.kind) node.kind = binding.kind; } projectRecordedArms(block, errors); const bound = new Set(block.bindings.map((binding) => binding.nodeId)); for (const node of block.nodes.values()) { if ( node.sketch || node.kind === 'conceptual' || bound.has(node.id) || rejectedNodeIds.has(node.id) ) continue; errors.push( `Docflow box "${node.id}" in \`${blockLabel(block)}\` points at nothing, so the canvas can only draw it and say nothing about it. ` + `Either put \`// @mermaid-node ${node.id}\` above the statement it runs, or — if this play has no such statement, because the work happens in another module or inside a loop — add it to a \`class ${node.id} sketch\` line in the block to declare it a sketch.`, ); } } } function sourceLineStartsForDocflow(sourceCode: string): number[] { const starts = [0]; for (let index = 0; index < sourceCode.length; index += 1) { if (sourceCode[index] === '\n') starts.push(index + 1); } return starts; } /** * Parses the intentionally small authoring surface. Mermaid remains an export * target; this owns the stable IDs and bindings that make the diagram truthful. * * Returns the diagram for ONE export (the default unless asked otherwise), so a * caller analyzing `batch` never sees the block that describes `scalar`. Errors * stay file-wide: a broken block is broken whichever export you asked about. */ export function parsePlayDocflow( sourceCode: string, options: ParsePlayDocflowOptions = {}, ): PlayDocflowParseResult { const parsed = parsePlayDocflowFile(sourceCode); const requested = options.exportName?.trim() || PLAY_DEFAULT_EXPORT; const selected = parsed.blocks.find((block) => block.exportName === requested) ?? // `scalar` and `default` name the same play in `export default scalar`, and // the registry addresses that play as `scalar`. Only worth an AST parse // when a block exists and the caller asked for something else. (requested !== PLAY_DEFAULT_EXPORT && parsed.blocks.length > 0 ? parsed.blocks.find( (block) => block.exportName === canonicalPlayExportName( requested, listPlayFileExports(sourceCode) ?? [], ), ) : undefined); return { docflow: parsed.errors.length ? null : (selected?.docflow ?? null), errors: parsed.errors, }; } /** * Collects every `// @mermaid-node` annotation in the file. Blocks route these * to themselves afterwards; the annotations themselves are file-scoped text. */ function parseDocflowBindings( sourceCode: string, errors: string[], rejectedNodeIds: Set, ): PlayDocflowBinding[] { const lines = sourceCode.split(/\r?\n/); const bindings: PlayDocflowBinding[] = []; for (let index = 0; index < lines.length; index += 1) { MERMAID_NODE.lastIndex = 0; const mermaidMatch = MERMAID_NODE.exec(lines[index]!) ?? null; if (!mermaidMatch) continue; // Whatever this annotation names, the author has now written it down. Every // `continue` below is a rejection, and a rejected id must not come back as // an unbound box — see `rejectedNodeIds` in `parsePlayDocflowFile`. const annotatedId = mermaidMatch[1]?.trim(); if (annotatedId) rejectedNodeIds.add(annotatedId); const attributes = parseAttributes( mermaidMatch[2] ?? '', index + 1, errors, ); if (!attributes) continue; const id = (annotatedId ?? attributes.id)?.trim(); if (!id) { errors.push(`Docflow annotation on line ${index + 1} requires id:"…".`); continue; } rejectedNodeIds.add(id); const kind = nodeKind(attributes.type); if (attributes.type && !kind) { errors.push( `Docflow annotation "${id}" has unsupported type "${attributes.type}". Valid types: ${PLAY_DOCFLOW_NODE_KINDS.map((nodeType) => `"${nodeType}"`).join(', ')}.`, ); continue; } const arm = nodeArm(attributes.arm); if (attributes.arm && !arm) { errors.push( `Docflow annotation "${id}" has unsupported arm "${attributes.arm}". Valid arms: ${PLAY_DOCFLOW_ARMS.map((token) => `"${token}"`).join(', ')}. The arm names which side of the conditional this node is, not what the edge is labelled.`, ); continue; } let nextLine = index + 1; while ( nextLine < lines.length && (!lines[nextLine]!.trim() || lines[nextLine]!.trim().startsWith('//')) ) nextLine += 1; if (nextLine === lines.length) { errors.push( `Docflow annotation "${id}" must be followed by executable code.`, ); continue; } const inputs = parseContractPaths( attributes.in, 'in', id, index + 1, errors, ); const outputs = parseContractPaths( attributes.out, 'out', id, index + 1, errors, ); if ( (attributes.in !== undefined && !inputs) || (attributes.out !== undefined && !outputs) ) { continue; } const inferred = inputs || outputs ? { ...(inputs ? { inputs } : {}), ...(outputs ? { outputs } : {}), ioConfidence: 'explicit' as const, } : inferBindingIo(lines[nextLine]!); rejectedNodeIds.delete(id); bindings.push({ nodeId: id, line: nextLine + 1, ...(attributes.label ? { label: attributes.label } : {}), ...(kind ? { kind } : {}), // Spread, never `arm: arm ?? undefined` — see `PlayDocflowEdge.arm`. An // explicit `undefined` key survives `JSON.stringify` round-trips in some // callers and would reintroduce the hash drift this omission avoids. ...(arm ? { arm } : {}), ...inferred, }); } return bindings; } /** * Enforces the small graph grammar the dashboard can render clearly. Syntax * errors are owned by {@link parsePlayDocflow}; these diagnostics are about * semantic topology and presentation complexity. */ export function lintPlayDocflow( docflow: PlayDocflow, sourceCode?: string, ): PlayDocflowLintIssue[] { const issues: PlayDocflowLintIssue[] = []; // Mermaid subgraphs are region endpoints rather than ordinary nodes. Until // their nested membership is projected into this lightweight graph, applying // the one-root/reachability check to them reports every valid region member as // a second root. Plain Mermaid diagrams have no such ambiguity and should get // the same useful connected-topology validation as the legacy syntax. const enforceConnectedTopology = docflow.syntax !== 'mermaid' || (docflow.subgraphs?.length ?? 0) === 0; const byId = new Map(docflow.nodes.map((node) => [node.id, node])); const outgoing = new Map( docflow.nodes.map((node) => [node.id, [] as PlayDocflowEdge[]]), ); const indegree = new Map(docflow.nodes.map((node) => [node.id, 0])); const sourceIdentifiers = new Set(); if (sourceCode) { const executableSource = sourceCode .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, ''); IDENTIFIER.lastIndex = 0; for (const match of executableSource.matchAll(IDENTIFIER)) { sourceIdentifiers.add(match[0]!); } } for (const edge of docflow.edges) { outgoing.get(edge.from)?.push(edge); indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1); } if (docflow.direction !== 'TB' && docflow.direction !== 'TD') { issues.push({ code: 'docflow_direction_not_top_down', severity: 'warning', message: `Docflow direction ${docflow.direction} is normalized to a top-down dashboard layout.`, path: 'docflow.direction', hint: 'Author the document as `flowchart TD` so source and rendered direction agree.', }); } if (docflow.ignoredDirectives?.length) { issues.push({ code: 'docflow_directive_ignored', severity: 'warning', message: `Docflow contains Mermaid directives the dashboard does not apply: ${docflow.ignoredDirectives.join('; ')}.`, path: 'docflow.mermaidSource', hint: 'Remove these directives. The dashboard currently owns node, edge, and top-down layout styling.', }); } const roots = docflow.nodes.filter((node) => indegree.get(node.id) === 0); if (enforceConnectedTopology && roots.length !== 1) { issues.push({ code: 'docflow_topology_invalid', severity: 'error', message: `Docflow must have one entry node; found ${roots.length}.`, path: 'docflow.edges', hint: 'Connect every authored node beneath one clear starting node.', }); } else if (enforceConnectedTopology) { const reachable = new Set(); const pending = [roots[0]!.id]; while (pending.length > 0) { const current = pending.pop()!; if (reachable.has(current)) continue; reachable.add(current); for (const edge of outgoing.get(current) ?? []) pending.push(edge.to); } const unreachable = docflow.nodes.filter((node) => !reachable.has(node.id)); if (unreachable.length > 0) { issues.push({ code: 'docflow_topology_invalid', severity: 'error', message: `Docflow contains unreachable nodes: ${unreachable.map((node) => node.id).join(', ')}.`, path: 'docflow.edges', hint: 'Connect or remove each orphaned presentation node.', }); } } for (const node of docflow.nodes) { const branches = outgoing.get(node.id) ?? []; if (branches.length > 1 && node.kind !== 'decision') { issues.push({ code: 'docflow_branch_requires_decision', severity: 'error', message: `Docflow node "${node.id}" branches ${branches.length} ways but is not a decision.`, path: `docflow.nodes.${node.id}`, hint: 'Insert a decision node for the branch so the split is explicit in the UI.', }); } if (node.kind === 'decision' && branches.length > 1) { const labels = branches.map((edge) => edge.label?.trim() ?? ''); if ( labels.some((label) => !label) || new Set(labels).size !== labels.length ) { issues.push({ code: 'docflow_branch_labels_required', severity: 'error', message: `Decision "${node.id}" must give every branch a unique label.`, path: `docflow.nodes.${node.id}`, hint: 'Use `decision -->|outcome| next` for every decision edge.', }); } } if (branches.length > DOCFLOW_MAX_BRANCHES) { issues.push({ code: 'docflow_layout_complexity', severity: 'error', message: `Docflow node "${node.id}" has ${branches.length} branches; the dashboard supports at most ${DOCFLOW_MAX_BRANCHES} readable branches from one node.`, path: `docflow.nodes.${node.id}`, hint: 'Split this choice into smaller named decisions.', }); } if (node.label.length > DOCFLOW_MAX_LABEL_LENGTH) { issues.push({ code: 'docflow_layout_complexity', severity: 'warning', message: `Docflow node "${node.id}" has a ${node.label.length}-character label; labels over ${DOCFLOW_MAX_LABEL_LENGTH} characters are truncated in the graph.`, path: `docflow.nodes.${node.id}.label`, hint: 'Move detail into code or a conceptual node and keep the card title short.', }); } // A label names the thing; the runtime counts it. Warning, not error: this // reads prose, so a legitimate name that happens to carry a magnitude // should not be able to hard-fail `plays check`. const countFragment = docflowLabelCountFragment(node.label); if (countFragment) { issues.push({ code: 'docflow_label_counts_rows', severity: 'warning', message: `Docflow node "${node.id}" labels a count ("${countFragment}") in "${node.label}"; row counts come from the run, so an authored number goes stale as soon as the input changes.`, path: `docflow.nodes.${node.id}.label`, hint: 'Name what the node IS ("Seed rows"), not how many it holds — the canvas already shows the live count beside the node.', }); } } // Loop members name the columns of the dataset they annotate, not the // statement's assigned variable. Their outputs are validated against the // dataset's real computed columns by the preflight loop-completeness check, // so the assigned-name agreement rule does not apply to them. const subgraphMemberIds = new Set( (docflow.subgraphs ?? []).flatMap((subgraph) => subgraph.memberIds), ); for (const binding of docflow.bindings) { const node = byId.get(binding.nodeId); if (!node) continue; if (binding.ioConfidence === 'ambiguous' && node.kind === 'action') { issues.push({ code: 'docflow_io_ambiguous', severity: 'warning', message: `Could not determine what "${binding.nodeId}" reads or changes.`, path: `docflow.nodes.${binding.nodeId}`, hint: 'Add in:"…" and out:"…", or annotate a clearer assignment.', }); } if (!sourceCode) continue; const boundLine = sourceCode.split(/\r?\n/)[binding.line - 1]?.trim() ?? ''; const assignedName = /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/.exec( boundLine, )?.[1]; for (const path of binding.inputs ?? []) { const root = rootPath(path); if (sourceIdentifiers.has(root)) continue; const suggestion = [...sourceIdentifiers] .filter((candidate) => candidate.length > 1) .sort( (left, right) => levenshtein(root, left) - levenshtein(root, right), )[0]; issues.push({ code: 'docflow_input_not_found', severity: 'error', message: `Input "${path}" is not in scope for docflow node "${binding.nodeId}".`, path: `docflow.nodes.${binding.nodeId}.in`, ...(suggestion && levenshtein(root, suggestion) <= 3 ? { hint: `Did you mean "${suggestion}"?` } : { hint: 'Use an identifier or property path that exists in this play.', }), }); } for (const path of binding.outputs ?? []) { const root = rootPath(path); if (root === '$output') continue; if ( assignedName && root !== assignedName && !subgraphMemberIds.has(binding.nodeId) ) { issues.push({ code: 'docflow_output_not_found', severity: 'error', message: `Output "${path}" does not match the assigned variable "${assignedName}" for docflow node "${binding.nodeId}" — out: names bind to the variable the annotated statement assigns, so write out:"${assignedName}" (the node id can stay "${binding.nodeId}").`, path: `docflow.nodes.${binding.nodeId}.out`, hint: `The node id and the out: name are independent: the id labels the diagram box, out: must be the assigned variable/column name.`, }); continue; } if (sourceIdentifiers.has(root)) continue; issues.push({ code: 'docflow_output_not_found', severity: 'error', message: `Output "${path}" is not declared by docflow node "${binding.nodeId}".`, path: `docflow.nodes.${binding.nodeId}.out`, hint: 'Use an identifier or property path that exists in this play.', }); } } if (docflow.nodes.length > DOCFLOW_MAX_NODES) { issues.push({ code: 'docflow_layout_complexity', severity: 'warning', message: `Docflow has ${docflow.nodes.length} nodes; more than ${DOCFLOW_MAX_NODES} makes the default graph hard to scan.`, path: 'docflow.nodes', hint: 'Keep the primary business path here and move supporting explanation into concise conceptual nodes.', }); } const remainingIndegree = new Map(indegree); const pending = docflow.nodes .filter((node) => remainingIndegree.get(node.id) === 0) .map((node) => node.id); let visited = 0; while (pending.length > 0) { const current = pending.pop()!; visited += 1; for (const edge of outgoing.get(current) ?? []) { const nextIndegree = (remainingIndegree.get(edge.to) ?? 0) - 1; remainingIndegree.set(edge.to, nextIndegree); if (nextIndegree === 0) pending.push(edge.to); } } if (enforceConnectedTopology && visited !== byId.size) { issues.push({ code: 'docflow_layout_complexity', severity: 'warning', message: 'Docflow contains a feedback loop, which renders as a secondary dashed path.', path: 'docflow.edges', hint: 'Label feedback edges clearly and keep the main forward path acyclic.', }); } return issues; }