diff --git a/archify.zip b/archify.zip index 52c08d7da..c24decc9b 100644 Binary files a/archify.zip and b/archify.zip differ diff --git a/archify/SKILL.md b/archify/SKILL.md index 7efc61bd7..8ba1dc96d 100644 --- a/archify/SKILL.md +++ b/archify/SKILL.md @@ -63,7 +63,7 @@ When ambiguous, run `node bin/archify.mjs guide "" --json`. Scenario p Read Mermaid for topology and meaning, then author fresh Archify JSON; do not mechanically render Mermaid styling. -- `flowchart` / `graph` → `workflow`, or `architecture` for a component map. +- `flowchart` / `graph` → `workflow`, or `architecture` for a component map. For the architecture component-map path, `node bin/archify.mjs import flowchart --json` deterministically imports the documented subset; see `references/mermaid-flowchart-import.md` for the supported syntax, target-mode selection, and diagnostic codes. - `sequenceDiagram` → `sequence`; participants become semantic participants and arrows become messages. - `stateDiagram` → `lifecycle`; states and transitions retain meaning, not Mermaid style. diff --git a/archify/bin/archify.mjs b/archify/bin/archify.mjs index fa2066d9b..06729ffca 100755 --- a/archify/bin/archify.mjs +++ b/archify/bin/archify.mjs @@ -2175,6 +2175,7 @@ function invalidProvenance(artifactPath, sidecar, reason, evidence = {}) { function usage() { return `Usage: + archify import flowchart [output.json] [--json] archify render [output.html] [--quality standard|showcase] [--repo-root path] archify compare architecture [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path] archify deliver [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path] @@ -6862,6 +6863,266 @@ async function commandValidate(args) { if (exitCode !== 0) process.exitCode = exitCode; } +function emitImportFailure(json, receipt, exitCode = 1) { + if (json) { + console.log(JSON.stringify(receipt, null, 2)); + } else { + console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + } + process.exit(exitCode); +} + +async function commandImport(args) { + // The output-commit safety runtime is loaded like the rest of the + // output-path runtime so an installed skill missing it reports a structured + // doctor/diagnostic failure instead of crashing the CLI at startup. + const { commitImportOutput, resolveOutputPath } = await import('../renderers/shared/output-path.mjs'); + + // Detect --json from the raw argument list before any positional validation, + // so missing/unsupported formats, unknown options, and missing inputs are + // reported through the schema-v1 receipt contract when JSON output is asked. + const json = args.includes('--json'); + const positional = []; + for (const arg of args) { + if (arg === '--json') continue; + if (arg.startsWith('--')) { + emitImportFailure(json, { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: `Unknown import option "${arg}".`, + diagnostics: [diagnostic({ + code: 'import/unknown-option', + message: `Unknown import option "${arg}".`, + subject: { option: arg }, + evidence: { source: { argument: arg } }, + supportedFixes: ['use "--json" if you want machine-readable output, otherwise remove the unknown option'], + })], + }); + } + positional.push(arg); + } + + const [format, inputPath, outputPath, ...extra] = positional; + + if (extra.length > 0) { + emitImportFailure(json, { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: `Unexpected argument "${extra[0]}".`, + diagnostics: [diagnostic({ + code: 'import/extra-argument', + message: `Unexpected argument "${extra[0]}".`, + subject: { argument: extra[0] }, + evidence: { source: { argument: extra[0] } }, + supportedFixes: ['use "archify import flowchart [output.json] [--json]"'], + })], + }); + } + + if (!format) { + emitImportFailure(json, { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: 'Missing import format.', + diagnostics: [diagnostic({ + code: 'import/missing-format', + message: 'Missing import format.', + subject: {}, + evidence: { usage: 'archify import flowchart [output.json] [--json]' }, + supportedFixes: ['use "flowchart" as the import format'], + })], + }); + } + + if (format !== 'flowchart') { + emitImportFailure(json, { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: `Unsupported import format "${format}".`, + diagnostics: [diagnostic({ + code: 'import/unsupported-format', + message: `Unsupported import format "${format}".`, + subject: { format }, + evidence: { source: { format } }, + supportedFixes: ['use "flowchart" as the import format'], + })], + }); + } + + if (!inputPath) { + emitImportFailure(json, { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: 'Missing input file.', + diagnostics: [diagnostic({ + code: 'import/missing-input', + message: 'Missing input file.', + subject: {}, + evidence: { usage: 'archify import flowchart [output.json] [--json]' }, + supportedFixes: ['provide a readable .mmd input file'], + })], + }); + } + + let source; + try { + source = fs.readFileSync(inputPath, 'utf8'); + } catch (error) { + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: 'Input could not be read.', + diagnostics: [diagnostic({ + code: 'input/read', + message: `Input could not be read: ${error.message}`, + subject: { input: inputPath }, + evidence: { reason: error.message }, + supportedFixes: ['provide one readable .mmd input file'], + })], + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + + if (outputPath) { + // Resolve the output through the same shared contract as deliver/compare: + // input-alias detection (including future-path aliases and hard links), + // symbolic-link cycle refusal, and the documented [output.json] extension + // contract — before any parsing or writing happens. + try { + resolveOutputPath({ + requestedOutput: outputPath, + requiredExtension: '.json', + inputPaths: [inputPath], + inputDescription: 'the Mermaid source', + }); + } catch (error) { + const diagnostics = error.archifyDiagnostics ?? [diagnostic({ + code: 'output/path-resolution', + message: error.message, + subject: { output: outputPath }, + evidence: { reason: error.message }, + supportedFixes: ['choose a safe output path and retry'], + })]; + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: error.message, + diagnostics, + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + } + + const { importFlowchart } = await import(pathToFileURL(path.join(skillRoot, 'importers', 'flowchart.mjs')).href); + const result = importFlowchart(source); + + if (!result.ok) { + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: result.diagnostics[0].message, + diagnostics: result.diagnostics, + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + + const irJson = JSON.stringify(result.ir, null, 2); + if (outputPath) { + try { + const commit = commitImportOutput(inputPath, outputPath, irJson + '\n'); + if (!commit.ok) { + // The output began aliasing the input after the preflight (for example + // a symlink swapped while the input parsed). Refuse instead of + // replacing the Mermaid source with the import result. + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: 'Output path aliases the input file.', + diagnostics: [diagnostic({ + code: 'input/output-alias', + message: `Output path "${outputPath}" resolves to the input file; writing it would replace the Mermaid source with the import result.`, + subject: { input: inputPath, output: outputPath }, + evidence: { input: path.resolve(inputPath), output: path.resolve(outputPath) }, + supportedFixes: ['choose a different output path so the Mermaid source is preserved'], + })], + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + } catch (error) { + if (error.archifyDiagnostics) { + // A commit-time path recheck hit a condition the shared contract + // diagnoses (for example a symbolic-link cycle swapped in after the + // preflight). Report that diagnostic instead of a generic write + // failure so the receipt names the actual contract violation. + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: error.message, + diagnostics: error.archifyDiagnostics, + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + const receipt = { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: false, + error: `Output could not be written: ${error.message}`, + diagnostics: [diagnostic({ + code: 'output/write', + message: `Output could not be written: ${error.message}`, + subject: { output: outputPath }, + evidence: { + ...(error.code ? { systemCode: error.code } : {}), + reason: error.message, + }, + supportedFixes: ['choose a writable output file path (the output must not be a directory)'], + })], + }; + if (json) console.log(JSON.stringify(receipt, null, 2)); + else console.error(formatDiagnostics(receipt.error, receipt.diagnostics)); + process.exit(1); + } + if (!json) console.error(`Imported ${result.ir.components.length} components, ${result.ir.connections.length} connections → ${outputPath}`); + } else if (!json) { + console.log(irJson); + } + + if (json) { + console.log(JSON.stringify(result.receipt, null, 2)); + } +} + const [command, ...args] = process.argv.slice(2); try { @@ -6872,6 +7133,9 @@ try { case 'help': console.log(usage()); break; + case 'import': + await commandImport(args); + break; case 'render': commandRender(args); break; diff --git a/archify/importers/flowchart.mjs b/archify/importers/flowchart.mjs new file mode 100644 index 000000000..714020df0 --- /dev/null +++ b/archify/importers/flowchart.mjs @@ -0,0 +1,953 @@ +/** + * Mermaid flowchart/graph importer for Archify. + * + * Parses a documented subset of Mermaid `flowchart` / `graph` syntax and + * produces typed Archify architecture IR. The importer treats Mermaid as + * source topology — nodes, edges, labels, and subgraph grouping — and never + * copies Mermaid layout, styling, or class definitions. + * + * Unsupported, ambiguous, or malformed syntax exits with a stable named + * diagnostic and source location; no node or edge is silently discarded. + */ + +// The label-width measurement must match the architecture validator's +// (render-architecture.mjs) so imported cells always fit their labels. +import { textUnits } from '../renderers/shared/utils.mjs'; + +// --- Types --------------------------------------------------------------- + +const NODE_SHAPES = [ + { open: '((', close: '))', type: 'cloud' }, + { open: '[(', close: ')]', type: 'database' }, + { open: '(', close: ')', type: 'backend' }, + { open: '[', close: ']', type: 'backend' }, + { open: '{', close: '}', type: 'security' }, + { open: '>', close: ']', type: 'external' }, + { open: '/', close: '\\', type: 'backend' }, +]; + +const EDGE_PATTERNS = [ + // Mermaid link length is controlled by adding extra repeated characters; + // the arrowhead at the end stays a single ">". "--->" and "---->" are + // still directed "solid" edges, and the same for "===>"/"====>" and + // dotted "-...->" variants. + { re: /^==[=]*>/, variant: 'emphasis' }, + { re: /^-\.+->/, variant: 'dashed' }, + { re: /^--[-]*>/, variant: 'solid' }, +]; + +const UNSUPPORTED_KEYWORDS = new Set([ + 'classDef', 'class', 'style', 'linkStyle', 'click', + 'interaction', 'default', '%%%', 'accDescr', 'accTitle', + 'flowchart-elk', 'elk', +]); + +const LAYOUT = { + CELL_W: 140, + CELL_H: 60, + GAP_X: 80, + GAP_Y: 80, + ORIGIN_X: 40, + ORIGIN_Y: 40, +}; + +// --- Diagnostics --------------------------------------------------------- + +function diag(code, message, line, column, extras = {}) { + return { + code, + severity: 'error', + message, + subject: { line, column, ...extras.subject }, + evidence: { source: { line, column }, ...extras.evidence }, + supportedFixes: extras.supportedFixes || [], + }; +} + +// Characters disallowed in XML 1.0 content (complement of #x9 | #xA | #xD | +// [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]). +const XML_INVALID_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uD800-\uDFFF\uFFFE\uFFFF]/u; + +function isBlank(text) { + return /^\s*$/u.test(text); +} + +// Validate a piece of user-authored text before it becomes IR. Returns a +// diagnostic if the text is blank/whitespace-only or contains an XML 1.0 +// disallowed character; otherwise returns null so the caller can use the text. +function validateLabelText(text, lineNo, startColumn, { code, kind, context }) { + if (isBlank(text)) { + return diag( + code, + `${kind} has no representable text; ${context} must contain at least one non-whitespace character.`, + lineNo, startColumn, + { + supportedFixes: [`provide a non-empty ${context}`], + }, + ); + } + const match = XML_INVALID_CHAR_RE.exec(text); + if (match) { + const cp = match[0].codePointAt(0).toString(16).toUpperCase(); + const display = cp.length > 4 ? `U+${cp}` : `U+${cp.padStart(4, '0')}`; + return diag( + 'import/xml-disallowed-character', + `${kind} contains ${display}, a character that cannot be represented in the delivered SVG; remove or replace it before importing.`, + lineNo, startColumn + match.index, + { + supportedFixes: [`replace ${display} in the ${context} with a representable character`], + }, + ); + } + return null; +} + +// --- Layout helpers ------------------------------------------------------ + +// The architecture renderer measures connection labels with this width so the +// SVG text mask, auto canvas, and layout reports agree. Keep the importer in +// sync so it can pre-position labels that would otherwise clip the left edge. +function connectionLabelWidth(label) { + return Math.max(30, textUnits(label) * 4.8 + 10); +} + +function portCenter(pos, side) { + const [x, y] = pos.pos; + const [w, h] = pos.size; + if (side === 'left') return [x, y + h / 2]; + if (side === 'right') return [x + w, y + h / 2]; + if (side === 'top') return [x + w / 2, y]; + if (side === 'bottom') return [x + w / 2, y + h]; + return [x + w / 2, y + h / 2]; +} + +function defaultEndpointSides(fromPos, toPos, isHorizontal) { + if (isHorizontal) { + return fromPos.pos[0] < toPos.pos[0] + ? { fromSide: 'right', toSide: 'left' } + : { fromSide: 'left', toSide: 'right' }; + } + return fromPos.pos[1] < toPos.pos[1] + ? { fromSide: 'bottom', toSide: 'top' } + : { fromSide: 'top', toSide: 'bottom' }; +} + +// The architecture viewBox is anchored at (0, 0) and only expands to the right +// and bottom. A connection label whose measured rect would start before x = 0 +// therefore fails the label-canvas-containment check. Pre-position such labels +// with an explicit labelAt so their left edge stays inside the canvas; the auto +// viewBox will expand right to contain the remainder of the label. +function safeLabelAt(label, fromPos, toPos, isHorizontal, labelDy) { + const { fromSide, toSide } = defaultEndpointSides(fromPos, toPos, isHorizontal); + const start = portCenter(fromPos, fromSide); + const end = portCenter(toPos, toSide); + const midX = (start[0] + end[0]) / 2; + const width = connectionLabelWidth(label); + const leftEdge = midX - width / 2; + if (leftEdge >= 0) return null; + const sourcePortY = start[1]; + const ly = sourcePortY - 10 + (labelDy || 0); + // Shift the label center right until the left edge has a 2px safety margin. + const safeX = width / 2 + 2; + return [Math.round(safeX), Math.round(ly)]; +} + +// --- Parser -------------------------------------------------------------- + +/** + * Parse Mermaid flowchart source into Archify architecture IR. + * + * @param {string} source — Mermaid flowchart source text. + * @returns {{ ok: true, ir: object } | { ok: false, diagnostics: array }} + */ +export function parseFlowchart(source) { + const lines = source.split('\n'); + const diagnostics = []; + + let direction = null; + let diagramType = null; + const components = new Map(); + const connections = []; + const boundaries = []; + const subgraphStack = []; + let subgraphCounter = 0; + + for (let lineNum = 0; lineNum < lines.length; lineNum += 1) { + const rawLine = lines[lineNum]; + const line = rawLine.trim(); + const lineNo = lineNum + 1; + + // Skip blank lines and comments. + if (line === '' || line.startsWith('%%')) continue; + + // First non-comment, non-blank line must declare the diagram type. + if (diagramType === null) { + const decl = line.match(/^(flowchart|graph)\s+(TB|TD|BT|LR|RL)\b/i); + if (!decl) { + diagnostics.push(diag( + 'import/flowchart-missing-declaration', + 'First non-comment line must declare "flowchart" or "graph" with a direction (TB, TD, BT, LR, RL).', + lineNo, 1, + { + supportedFixes: ['start the file with a line like "flowchart TD" or "graph LR"'], + }, + )); + return { ok: false, diagnostics }; + } + diagramType = decl[1].toLowerCase(); + direction = decl[2].toUpperCase(); + // A remainder after the direction (Mermaid allows ";" as a statement + // separator) would be silently dropped here — the declaration regex only + // matches the "flowchart " prefix. Dropping it silently loses + // topology, so reject the line unless only separators/whitespace remain. + const remainder = line.slice(decl[0].length).replace(/[;\s]+/g, ' ').trim(); + if (remainder) { + diagnostics.push(diag( + 'import/declaration-remainder', + `The declaration line contains statements after the direction ("${remainder}"); the importer processes one statement per line, so this topology would be dropped.`, + lineNo, decl[0].length + 1, + { + supportedFixes: ['move each statement after "flowchart " onto its own line'], + }, + )); + return { ok: false, diagnostics }; + } + continue; + } + + // Check for unsupported keywords. + const keyword = line.match(/^([A-Za-z]+)\b/); + if (keyword && UNSUPPORTED_KEYWORDS.has(keyword[1])) { + diagnostics.push(diag( + `import/unsupported-keyword-${keyword[1].toLowerCase()}`, + `Mermaid "${keyword[1]}" is not supported by the Archify flowchart importer. Styling and interaction directives are outside the supported subset.`, + lineNo, 1, + { + supportedFixes: [`remove the "${keyword[1]}" line; Archify does not import Mermaid styling or interaction directives`], + }, + )); + return { ok: false, diagnostics }; + } + + // The "direction" directive is only meaningful with per-region layout, + // which the importer does not provide; accepting it would invent nodes. + const dirDirective = line.match(/^direction\s+(TB|TD|BT|LR|RL)\b/i); + if (dirDirective) { + diagnostics.push(diag( + 'import/unsupported-direction-directive', + 'Mermaid "direction" is not supported by the Archify flowchart importer; the diagram-level direction applies to all regions.', + lineNo, 1, + { + supportedFixes: ['remove the "direction" line; declare the direction once on the first line, e.g. "flowchart TB"'], + }, + )); + return { ok: false, diagnostics }; + } + + // Subgraph start. + const subgraphMatch = line.match(/^subgraph\s+(.+)$/i); + if (subgraphMatch) { + subgraphCounter += 1; + // Mermaid subgraph declarations carry an optional authored id plus a + // title: "subgraph Title", "subgraph id [Title]", or + // 'subgraph id["Title"]'. Parse and store them separately: edges + // reference the authored id, while the boundary label must be the human + // title alone — keeping the raw "id [Title]" text as the label both + // corrupted the emitted topology and hid the authored identity from + // edge-endpoint resolution below. + const rest = subgraphMatch[1]; + const restStart = subgraphMatch.index + subgraphMatch[0].indexOf(rest); + let authoredId = null; + let label = rest; + let textStart = restStart; + const idBracket = rest.match(/^(?:([^\[\]]+?)\s*)?\[(.*)\]$/); + if (idBracket) { + authoredId = (idBracket[1] ?? '').trim() || null; + label = idBracket[2].replace(/^["']|["']$/g, ''); + const bracketOffset = idBracket[0].indexOf('['); + textStart = restStart + bracketOffset + 1; + if (idBracket[2].startsWith('"') || idBracket[2].startsWith("'")) { + textStart += 1; + } + } else if (rest.length >= 2 && rest.startsWith('"') && rest.endsWith('"')) { + label = rest.slice(1, -1); + textStart = restStart + 1; + } + const titleCheck = validateLabelText( + label, lineNo, textStart + 1, + { code: 'import/subgraph-empty-title', kind: 'Subgraph title', context: 'boundary title' }, + ); + if (titleCheck) { + diagnostics.push(titleCheck); + return { ok: false, diagnostics }; + } + const id = `sg${subgraphCounter}`; + const boundary = { kind: 'region', label, wraps: [], authoredId }; + boundaries.push(boundary); + subgraphStack.push({ id, boundary }); + continue; + } + + // Subgraph end. + if (line === 'end') { + if (subgraphStack.length === 0) { + diagnostics.push(diag( + 'import/flowchart-unbalanced-end', + '"end" without a matching "subgraph" declaration.', + lineNo, 1, + { + supportedFixes: ['remove the extra "end" or add a matching "subgraph" before it'], + }, + )); + return { ok: false, diagnostics }; + } + const closing = subgraphStack.pop(); + // A region that wrapped no nodes cannot be represented: the architecture + // schema requires boundaries[].wraps minItems: 1, so emitting it would + // produce IR that fails validation while the import reported ok. + if (closing.boundary.wraps.length === 0) { + diagnostics.push(diag( + 'import/empty-subgraph', + `Subgraph "${closing.boundary.label}" contains no nodes; every region must wrap at least one component.`, + lineNo, 1, + { + supportedFixes: [`declare at least one node inside subgraph "${closing.boundary.label}" or remove the empty subgraph`], + }, + )); + return { ok: false, diagnostics }; + } + continue; + } + + // Parse statement: nodes and/or edges. + const stmtResult = parseStatement(line, lineNo); + if (!stmtResult.ok) { + diagnostics.push(...stmtResult.diagnostics); + return { ok: false, diagnostics }; + } + + // Register components. A later explicit declaration refines an earlier + // implicit one (Mermaid uses the latest text); two conflicting explicit + // declarations are diagnosed instead of silently picking a winner. + for (const comp of stmtResult.components) { + const existing = components.get(comp.id); + if (!existing) { + components.set(comp.id, comp); + } else if (comp.explicit && !existing.explicit) { + existing.label = comp.label; + existing.type = comp.type; + existing.explicit = true; + } else if (comp.explicit && existing.explicit + && (comp.label !== existing.label || comp.type !== existing.type)) { + diagnostics.push(diag( + 'import/flowchart-conflicting-node-declaration', + `Node "${comp.id}" is declared twice with different explicit definitions ("${existing.label}" and "${comp.label}").`, + lineNo, 1, + { + supportedFixes: [`keep a single explicit declaration for node "${comp.id}" with the text it should have`], + }, + )); + return { ok: false, diagnostics }; + } + // Track subgraph membership. Mermaid nodes belong to every enclosing + // subgraph, so a node inside nested subgraphs is recorded in the wraps + // list of each ancestor boundary; recording only the innermost region + // would emit outer boundaries with empty wraps, which the architecture + // schema rejects (boundaries[].wraps minItems: 1). + for (const enclosing of subgraphStack) { + if (!enclosing.boundary.wraps.includes(comp.id)) { + enclosing.boundary.wraps.push(comp.id); + } + } + } + + // Register connections. + for (const conn of stmtResult.connections) { + connections.push(conn); + } + } + + // Check for unclosed subgraphs. + if (subgraphStack.length > 0) { + const last = subgraphStack[subgraphStack.length - 1]; + diagnostics.push(diag( + 'import/flowchart-unclosed-subgraph', + `Subgraph "${last.boundary.label}" was opened but never closed with "end".`, + lines.length, 1, + { + supportedFixes: ['add an "end" line after the last statement in the subgraph'], + }, + )); + return { ok: false, diagnostics }; + } + + if (diagramType === null) { + diagnostics.push(diag( + 'import/flowchart-empty-source', + 'No diagram declaration found in the source.', + 1, 1, + { + supportedFixes: ['start the file with a line like "flowchart TD"'], + }, + )); + return { ok: false, diagnostics }; + } + + // Build the final IR. + const componentArray = [...components.values()]; + if (componentArray.length === 0) { + diagnostics.push(diag( + 'import/flowchart-no-components', + 'The flowchart declares no nodes. At least one component is required.', + 1, 1, + { + supportedFixes: ['add at least one node definition, e.g. "A[Label]"'], + }, + )); + return { ok: false, diagnostics }; + } + + // Validate that all connection endpoints reference declared components. + for (const conn of connections) { + if (!components.has(conn.from)) { + diagnostics.push(diag( + 'import/flowchart-undefined-source', + `Edge references undefined source node "${conn.from}".`, + 1, 1, + { + supportedFixes: [`declare node "${conn.from}" before using it in an edge`], + }, + )); + } + if (!components.has(conn.to)) { + diagnostics.push(diag( + 'import/flowchart-undefined-target', + `Edge references undefined target node "${conn.to}".`, + 1, 1, + { + supportedFixes: [`declare node "${conn.to}" before using it in an edge`], + }, + )); + } + } + + // An edge endpoint that names a subgraph by its authored identity (id or + // title) would otherwise be registered as a new implicit component above — + // a fictitious service invented from the subgraph's name. Mermaid models + // edges to groups; the architecture subset does not, so reject the edge + // using only authored identities. The parser's internal synthetic ids + // (sgN) never leave the parser and are NOT reserved, so an authored node + // legitimately named "sg1" imports as an ordinary component. An endpoint + // that is also an explicitly declared node keeps the node: the explicit + // declaration is the authored identity there, not an invention. + const subgraphIdentities = new Set(); + for (const boundary of boundaries) { + subgraphIdentities.add(boundary.label); + if (boundary.authoredId) subgraphIdentities.add(boundary.authoredId); + } + for (const conn of connections) { + for (const endpoint of [conn.from, conn.to]) { + const comp = components.get(endpoint); + if (subgraphIdentities.has(endpoint) && !(comp && comp.explicit)) { + diagnostics.push(diag( + 'import/edge-references-subgraph', + `Edge endpoint "${endpoint}" is a subgraph; edges between subgraphs are outside the supported subset.`, + conn.line ?? 1, 1, + { + supportedFixes: [`connect the member nodes of subgraph "${endpoint}" directly instead of the subgraph itself`], + }, + )); + } + } + } + + if (diagnostics.length > 0) { + return { ok: false, diagnostics }; + } + + // Auto-layout: assign positions using a layered BFS from source nodes. + const isHorizontal = direction === 'LR' || direction === 'RL'; + const mirrored = direction === 'RL' || direction === 'BT'; + const positions = computeLayout(componentArray, connections, direction); + + const ir = { + schema_version: 1, + diagram_type: 'architecture', + meta: { + title: 'Imported Flowchart', + output: 'imported-flowchart.html', + }, + components: componentArray.map((c) => { + const obj = { id: c.id, type: c.type, label: c.label }; + if (c.sublabel) obj.sublabel = c.sublabel; + const p = positions.get(c.id); + if (p) { + obj.pos = p.pos; + obj.size = p.size; + } + return obj; + }), + connections: connections.map((c, i) => { + const conn = { + id: `edge-${i + 1}`, + from: c.from, + to: c.to, + }; + if (c.label) { + conn.label = c.label; + const fromPos = positions.get(c.from); + const toPos = positions.get(c.to); + // The Viewer anchors straight-route labels at the source port's y + // minus 10, so a vertical label shifts half a cell toward the target + // side of the gap to reach the route midpoint (mirrored for BT). + // Horizontal routes already anchor on the mid row; offsetting them + // toward the target made labels overlap the target component in + // layout validation. + if (!isHorizontal) { + conn.labelDy = mirrored ? -(LAYOUT.GAP_Y / 2 + 10) : LAYOUT.GAP_Y / 2 + 10; + } else if (fromPos && toPos && fromPos.pos[1] === toPos.pos[1]) { + // A straight horizontal label wider than the gap between its + // endpoint cells overlaps both components (label rect spans the + // route midpoint). Move it below the route — half a cell plus label + // height and margin clears the 60px row — so the import output can + // pass the advertised validate handoff instead of failing it. + const labelWidth = Math.ceil(textUnits(c.label) * 6.6); + const gap = toPos.pos[0] > fromPos.pos[0] + ? toPos.pos[0] - (fromPos.pos[0] + fromPos.size[0]) + : fromPos.pos[0] - (toPos.pos[0] + toPos.size[0]); + if (labelWidth > gap) { + conn.labelDy = LAYOUT.CELL_H / 2 + 14 + 10; + } + } + // Edge labels wider than the available left margin can clip the viewBox + // left edge because the auto canvas only expands right/bottom. Use an + // explicit labelAt when the default placement would overflow. + if (fromPos && toPos) { + const labelAt = safeLabelAt(c.label, fromPos, toPos, isHorizontal, conn.labelDy || 0); + if (labelAt) conn.labelAt = labelAt; + } + } + if (c.variant && c.variant !== 'solid') conn.variant = c.variant; + return conn; + }), + }; + + if (boundaries.length > 0) { + ir.boundaries = boundaries.map((b) => ({ + kind: 'region', + label: b.label, + wraps: b.wraps, + })); + } + + return { ok: true, ir }; +} + +// --- Statement parser --------------------------------------------------- + +// Merge a component occurrence into a statement's local component list with +// the same precedence rules the cross-statement merge applies: a later +// explicit declaration refines an earlier implicit one, and two conflicting +// explicit declarations are a conflict diagnostic instead of a silent +// first-occurrence win. Returns the conflict diagnostic, if any. +function mergeStatementComponent(components, comp, lineNo) { + const existing = components.find((c) => c.id === comp.id); + if (!existing) { + components.push(comp); + return null; + } + if (comp.explicit && !existing.explicit) { + existing.label = comp.label; + existing.type = comp.type; + existing.explicit = true; + return null; + } + if (comp.explicit && existing.explicit + && (comp.label !== existing.label || comp.type !== existing.type)) { + return diag( + 'import/flowchart-conflicting-node-declaration', + `Node "${comp.id}" is declared twice with different explicit definitions ("${existing.label}" and "${comp.label}").`, + lineNo, 1, + { + supportedFixes: [`keep a single explicit declaration for node "${comp.id}" with the text it should have`], + }, + ); + } + return null; +} + +function parseStatement(line, lineNo) { + const components = []; + const connections = []; + let pos = 0; + let lastNode = null; + + while (pos < line.length) { + // Skip whitespace. + while (pos < line.length && /\s/.test(line[pos])) pos += 1; + if (pos >= line.length) break; + + // Try to parse an edge first (if we already have a lastNode). + if (lastNode !== null) { + const edge = parseEdge(line, pos, lineNo); + if (edge) { + if (edge.ok === false) return { ok: false, diagnostics: edge.diagnostics }; + pos = edge.nextPos; + // After the edge, try to parse a label. + while (pos < line.length && /\s/.test(line[pos])) pos += 1; + + let label = edge.label || null; + if (!label && pos < line.length && line[pos] === '|') { + const labelEnd = line.indexOf('|', pos + 1); + if (labelEnd === -1) { + return { + ok: false, + diagnostics: [diag( + 'import/flowchart-unclosed-edge-label', + 'Edge label opened with "|" but never closed.', + lineNo, pos + 1, + { supportedFixes: ['close the edge label with a trailing "|"'] }, + )], + }; + } + const rawLabel = line.slice(pos + 1, labelEnd); + const labelCheck = validateLabelText( + rawLabel, lineNo, pos + 2, + { code: 'import/flowchart-empty-edge-label', kind: 'Edge label', context: 'relationship label' }, + ); + if (labelCheck) return { ok: false, diagnostics: [labelCheck] }; + label = rawLabel; + pos = labelEnd + 1; + while (pos < line.length && /\s/.test(line[pos])) pos += 1; + } + + // Now parse the target node. + const target = parseNode(line, pos, lineNo); + if (!target.ok) return target; + pos = target.nextPos; + + const targetConflict = mergeStatementComponent(components, target.node, lineNo); + if (targetConflict) return { ok: false, diagnostics: [targetConflict] }; + + connections.push({ + from: lastNode, + to: target.node.id, + label, + variant: edge.variant, + line: lineNo, + }); + lastNode = target.node.id; + continue; + } + + // Mermaid's open links — solid "---" / "----" and dotted "-.-" / + // "-..-" — carry no arrowhead, so remapping them to a directed + // connection would silently change their meaning. The negative + // lookaheads keep long directed arrows like "--->" and "-...->" out of + // the open-link match; those are handled by parseEdge first. + const openLink = line.slice(pos).match(/^(---+(?![->])|-\.+-(?![-.>]))/); + if (openLink) { + return { + ok: false, + diagnostics: [diag( + 'import/unsupported-edge-syntax', + `Mermaid open link "${openLink[1]}" carries no arrowhead and is not supported: Archify connections always carry an arrowhead, so this edge cannot be imported without changing its meaning.`, + lineNo, pos + 1, + { + supportedFixes: ['use "-->" for a directed edge, "-.->" for a dotted edge, or "==>" for an emphasized edge'], + }, + )], + }; + } + } + + // Parse a node. + const nodeResult = parseNode(line, pos, lineNo); + if (!nodeResult.ok) return nodeResult; + pos = nodeResult.nextPos; + + const nodeConflict = mergeStatementComponent(components, nodeResult.node, lineNo); + if (nodeConflict) return { ok: false, diagnostics: [nodeConflict] }; + + lastNode = nodeResult.node.id; + } + + return { ok: true, components, connections }; +} + +function parseNode(line, pos, lineNo) { + // Read the node ID. Hyphens are allowed, but a hyphen that is the start + // of an edge operator (-->, -...->, ====>, ---, -.-, etc.) must not be + // consumed into the ID; otherwise unspaced edges like "A-->B" are parsed + // as a node id "A--" followed by an unclosed shape. + const idMatch = line.slice(pos).match(/^([A-Za-z](?:[A-Za-z0-9_]|-(?![-.=>]))*)/); + if (!idMatch) { + return { + ok: false, + diagnostics: [diag( + 'import/flowchart-invalid-node-id', + `Expected a node identifier at this position but found "${line.slice(pos, pos + 20).trim()}".`, + lineNo, pos + 1, + { supportedFixes: ['use an identifier starting with a letter, containing only letters, digits, hyphens, or underscores'] }, + )], + }; + } + const id = idMatch[1]; + pos += id.length; + + // Check for a shape/label definition. + let label = id; + let type = 'backend'; + let explicit = false; + + for (const shape of NODE_SHAPES) { + if (line.slice(pos).startsWith(shape.open)) { + const contentStart = pos + shape.open.length; + let closeIdx; + + // Handle quoted labels: B["text with ] inside"] + if (line[contentStart] === '"') { + const quoteEnd = line.indexOf('"', contentStart + 1); + if (quoteEnd === -1) { + return { + ok: false, + diagnostics: [diag( + 'import/flowchart-unclosed-quote', + `Node "${id}" has an open quote but no closing quote.`, + lineNo, contentStart + 1, + { supportedFixes: ['close the quoted label with a trailing "'] }, + )], + }; + } + // After the closing quote, expect the shape close. + const afterQuote = quoteEnd + 1; + if (!line.slice(afterQuote).startsWith(shape.close)) { + return { + ok: false, + diagnostics: [diag( + 'import/flowchart-unclosed-node-shape', + `Node "${id}" has an open shape "${shape.open}" but no closing "${shape.close}" after the quoted label.`, + lineNo, afterQuote + 1, + { supportedFixes: [`close the shape with "${shape.close}" after the quoted label`] }, + )], + }; + } + const text = line.slice(contentStart + 1, quoteEnd); + const textCheck = validateLabelText( + text, lineNo, contentStart + 2, + { code: 'import/flowchart-empty-label', kind: `Node "${id}" label`, context: 'component label' }, + ); + if (textCheck) return { ok: false, diagnostics: [textCheck] }; + label = text; + explicit = true; + type = shape.type; + pos = afterQuote + shape.close.length; + } else { + closeIdx = line.indexOf(shape.close, contentStart); + if (closeIdx === -1) { + return { + ok: false, + diagnostics: [diag( + 'import/flowchart-unclosed-node-shape', + `Node "${id}" has an open shape "${shape.open}" but no closing "${shape.close}".`, + lineNo, pos + 1, + { supportedFixes: [`close the shape with "${shape.close}"`] }, + )], + }; + } + const text = line.slice(contentStart, closeIdx); + const textCheck = validateLabelText( + text, lineNo, contentStart + 1, + { code: 'import/flowchart-empty-label', kind: `Node "${id}" label`, context: 'component label' }, + ); + if (textCheck) return { ok: false, diagnostics: [textCheck] }; + label = text; + explicit = true; + type = shape.type; + pos = closeIdx + shape.close.length; + } + break; + } + } + + return { + ok: true, + node: { id, type, label, explicit }, + nextPos: pos, + }; +} + +function parseEdge(line, pos, lineNo) { + for (const pattern of EDGE_PATTERNS) { + const match = pattern.re.exec(line.slice(pos)); + if (match) { + return { ok: true, variant: pattern.variant, nextPos: pos + match[0].length }; + } + } + + // Check for -- text --> pattern. + const labeledArrow = line.slice(pos).match(/^--\s+([^>-]+?)\s+-->/d); + if (labeledArrow) { + const label = labeledArrow[1]; + const labelStart = pos + labeledArrow.indices[1][0] + 1; + const labelCheck = validateLabelText( + label, lineNo, labelStart, + { code: 'import/flowchart-empty-edge-label', kind: 'Edge label', context: 'relationship label' }, + ); + if (labelCheck) return { ok: false, diagnostics: [labelCheck] }; + return { ok: true, variant: 'solid', label, labelStart, nextPos: pos + labeledArrow[0].length }; + } + + // Check for -. text .-> pattern. + const dottedLabeled = line.slice(pos).match(/^-\.\s+([^>.]+?)\s+\.->/d); + if (dottedLabeled) { + const label = dottedLabeled[1]; + const labelStart = pos + dottedLabeled.indices[1][0] + 1; + const labelCheck = validateLabelText( + label, lineNo, labelStart, + { code: 'import/flowchart-empty-edge-label', kind: 'Edge label', context: 'relationship label' }, + ); + if (labelCheck) return { ok: false, diagnostics: [labelCheck] }; + return { ok: true, variant: 'dashed', label, labelStart, nextPos: pos + dottedLabeled[0].length }; + } + + return null; +} + +// --- Auto-layout --------------------------------------------------------- + +function computeLayout(components, connections, direction) { + const isHorizontal = direction === 'LR' || direction === 'RL'; + const { CELL_W, CELL_H, GAP_X, GAP_Y, ORIGIN_X, ORIGIN_Y } = LAYOUT; + + // Label-aware cell sizing: the architecture validator rejects any component + // whose measured label (textUnits(label) * 6.6) is wider than the component + // plus 8px, so a fixed 140px cell fails the validation handoff for long + // labels. Size every cell at least wide enough for its preserved label + // (+4px measurement margin), then advance columns/rows by the measured + // widths so no two cells overlap. + + // Build adjacency and compute in-degree. + const ids = components.map((c) => c.id); + const inDegree = new Map(ids.map((id) => [id, 0])); + for (const conn of connections) { + inDegree.set(conn.to, (inDegree.get(conn.to) || 0) + 1); + } + + // BFS from source nodes (in-degree 0) to assign depth layers. First + // assignment wins: relaxing an already-layered node via a cycle or back + // edge (the previous longest-path relaxation) relocates it to a deeper + // column without re-queuing it, which strands unrelated nodes on the same + // row between a straight route's endpoints — A --> B; B --> C; C --> B put + // C between A and B, and the straight A --> B route then crossed C's cell + // (clean-flow/edge-through-node) even though the import reported ok. + const depth = new Map(); + const queue = ids.filter((id) => (inDegree.get(id) || 0) === 0); + for (const id of queue) depth.set(id, 0); + + let head = 0; + while (head < queue.length) { + const current = queue[head++]; + const currentDepth = depth.get(current); + for (const conn of connections) { + if (conn.from === current && !depth.has(conn.to)) { + depth.set(conn.to, currentDepth + 1); + queue.push(conn.to); + } + } + } + + // Any nodes not reached by BFS get depth 0. + for (const id of ids) { + if (!depth.has(id)) depth.set(id, 0); + } + + // Group nodes by depth layer. + const layers = new Map(); + for (const id of ids) { + const d = depth.get(id); + if (!layers.has(d)) layers.set(d, []); + layers.get(d).push(id); + } + + const maxDepth = Math.max(...depth.values()); + // RL/BT mirror the depth axis so the declared direction is preserved. + const mirrorDepth = direction === 'RL' || direction === 'BT'; + const positions = new Map(); + const widths = new Map(components.map((c) => [c.id, Math.max( + CELL_W, + Math.ceil(textUnits(c.label) * 6.6 - 8) + 4, + )])); + + if (isHorizontal) { + // LR/RL: depth = column, index within layer = row. Columns advance by the + // widest cell in the column so a widened label never overlaps the column + // to its right. + const columnMax = new Map(); + for (const [d, layerIds] of layers) { + const dCoord = mirrorDepth ? maxDepth - d : d; + columnMax.set(dCoord, Math.max( + columnMax.get(dCoord) ?? 0, + ...layerIds.map((id) => widths.get(id)), + )); + } + const columnX = new Map(); + let accX = ORIGIN_X; + for (let dc = 0; dc <= maxDepth; dc += 1) { + columnX.set(dc, accX); + accX += (columnMax.get(dc) ?? 0) + GAP_X; + } + for (const [d, layerIds] of layers) { + const dCoord = mirrorDepth ? maxDepth - d : d; + for (let i = 0; i < layerIds.length; i++) { + positions.set(layerIds[i], { + pos: [columnX.get(dCoord), ORIGIN_Y + i * (CELL_H + GAP_Y)], + size: [widths.get(layerIds[i]), CELL_H], + }); + } + } + return positions; + } + + // TD/BT: depth = row, index within layer = column. Each visual row advances + // by the actual cell widths so widened labels never overlap the next cell. + for (const [d, layerIds] of layers) { + const dCoord = mirrorDepth ? maxDepth - d : d; + let rowX = ORIGIN_X; + for (let i = 0; i < layerIds.length; i++) { + const id = layerIds[i]; + positions.set(id, { + pos: [rowX, ORIGIN_Y + dCoord * (CELL_H + GAP_Y)], + size: [widths.get(id), CELL_H], + }); + rowX += widths.get(id) + GAP_X; + } + } + + return positions; +} + +// --- Public API for CLI -------------------------------------------------- + +/** + * Parse a Mermaid flowchart file and return either typed IR or diagnostics. + * This is the function called by the CLI `import` command. + */ +export function importFlowchart(source) { + const result = parseFlowchart(source); + if (!result.ok) return result; + + return { + ok: true, + ir: result.ir, + receipt: { + schemaVersion: 1, + command: 'import', + source: 'mermaid-flowchart', + ok: true, + components: result.ir.components.length, + connections: result.ir.connections.length, + ...(result.ir.boundaries ? { boundaries: result.ir.boundaries.length } : {}), + }, + }; +} diff --git a/archify/references/mermaid-flowchart-import.md b/archify/references/mermaid-flowchart-import.md new file mode 100644 index 000000000..dca9f97ca --- /dev/null +++ b/archify/references/mermaid-flowchart-import.md @@ -0,0 +1,168 @@ +# Mermaid flowchart import contract + +Read this reference before running `archify import flowchart`. The importer +turns a documented subset of Mermaid `flowchart` / `graph` source into typed +architecture IR (schema v1). It treats Mermaid as source topology — nodes, +edges, labels, and subgraph grouping — and never copies Mermaid layout, +styling, or class definitions. + +## Target-mode selection + +`archify import flowchart` always produces **architecture** IR: it imports a +component map (components, connections, region boundaries). For a process +view (approval gates, runbooks, CI/CD), author fresh `workflow` JSON by hand +per `SKILL.md` — the deterministic importer does not infer swimlanes or +process phases. After import, continue through the normal gates: + +```bash +node bin/archify.mjs import flowchart input.mmd imported.json --json +node bin/archify.mjs validate architecture imported.json --quality showcase --json +node bin/archify.mjs deliver architecture imported.json output.html --quality showcase --json +``` + +An imported IR passes the same validation and delivery gates as hand-authored +JSON; the final standalone HTML is produced by `deliver`, not by the importer. + +## Supported subset + +### Declarations + +The first non-comment, non-blank line must declare the diagram type and +direction. `flowchart` and `graph` are equivalent. All four directions are +honored, including mirrored depth placement for `RL` and `BT`: + +```mermaid +flowchart TD +``` + +`TB` (alias `TD`) lays sources above targets, `BT` below, `LR` left-to-right, +and `RL` right-to-left. `%%` comments and blank lines are ignored. + +### Node declarations + +`id[Label]` declares a component. The shape maps to an Archify +`componentType`; a bare id (used directly in an edge) defaults to `backend` +with the id as its label. A later explicit declaration updates an earlier +implicit one (Mermaid uses the latest text); two different explicit +declarations for the same id are rejected rather than silently resolved. + +| Mermaid shape | Archify `componentType` | +| --- | --- | +| `id((Text))` | `cloud` | +| `id[(Text)]` | `database` | +| `id(Text)` | `backend` | +| `id[Text]` | `backend` | +| `id{Text}` | `security` | +| `id>Text]` | `external` | +| `id/Text\\` | `backend` | + +Quoted labels (`id["Text with ] inside"]`) preserve brackets and surrounding +whitespace verbatim. Label text is imported as-is; HTML/markdown inside labels +is never interpreted. Explicit labels that are empty or contain only +whitespace are rejected with `import/flowchart-empty-label` instead of falling +back to the node id, because a blank label is not representable in Archify. + +### Edge declarations + +| Mermaid form | Imported connection | +| --- | --- | +| `-->` | directed (default variant) | +| `-.->`, `-..->` | directed, `variant: "dashed"` | +| `==>` | directed, `variant: "emphasis"` | +| `-- Text -->`, `-. Text .->` | directed with `label: "Text"` | +| `A -->\|Text\| B` | directed with `label: "Text"` | + +Longer directed arrows are also preserved as their base variant: `--->` and +`---->` are `solid`, `-.-->` and `-...->` are `dashed`, and `===>` and `====>` +are `emphasis`. + +Open links — solid `---` / `----` and dotted `-.-` / `-..-` — are **not** +supported: they carry no arrowhead, and Archify connections always carry an +arrowhead, so remapping them would change their meaning. They exit non-zero +with `import/unsupported-edge-syntax`. + +Edge labels must contain at least one non-whitespace character; a blank or +whitespace-only edge label exits with `import/flowchart-empty-edge-label`. +Labels, node labels, and subgraph titles must also be free of XML 1.0 +disallowed characters (for example U+0000); any such character is rejected with +`import/xml-disallowed-character` so that the delivered SVG remains well-formed. + +### Subgraphs + +`subgraph Label` … `end` becomes an architecture `boundaries` region whose +`wraps` lists the component ids declared inside it. Nested subgraphs are +tracked: a component declared inside nested subgraphs is recorded in the +`wraps` list of every enclosing region, so no region is emitted empty. The +diagram-level direction applies to every region. The Mermaid `direction` +directive inside a subgraph is rejected with +`import/unsupported-direction-directive` instead of inventing components. + +## Failure contract + +Unsupported, ambiguous, or malformed syntax exits non-zero, prints a receipt +(`--json`) or a human-readable diagnostic, and never writes the output file. +Diagnostics carry a stable `code`, `subject.line`/`subject.column`, concrete +`evidence`, and executable `supportedFixes`: + +```text +$ node bin/archify.mjs import flowchart unsupported-open-link.mmd --json +{ + "schemaVersion": 1, + "command": "import", + "source": "mermaid-flowchart", + "ok": false, + "error": "Mermaid open link \"---\" (and dotted forms like \"-.-\") is not supported: ...", + "diagnostics": [ + { + "code": "import/unsupported-edge-syntax", + "severity": "error", + "message": "...", + "subject": { "line": 2, "column": 8 }, + "evidence": { "source": { "line": 2, "column": 8 } }, + "supportedFixes": [ + "use \"-->\" for a directed edge, \"-.->\" for a dotted edge, or \"==>\" for an emphasized edge" + ] + } + ] +} +``` + +Importer diagnostic codes (all prefixed `import/`): + +- `import/flowchart-missing-declaration` — first line is not a typed declaration. +- `import/flowchart-empty-source` — no declaration found at all. +- `import/flowchart-no-components` — no nodes declared. +- `import/flowchart-invalid-node-id` — expected a node identifier. +- `import/flowchart-unclosed-node-shape` / `import/flowchart-unclosed-quote` — shape or label not closed. +- `import/flowchart-unclosed-edge-label` — `|` label not closed. +- `import/flowchart-unbalanced-end` / `import/flowchart-unclosed-subgraph` — `subgraph`/`end` mismatch. +- `import/flowchart-undefined-source` / `import/flowchart-undefined-target` — edge endpoint never declared. +- `import/flowchart-empty-label` — a node shape or quoted label is empty or contains only whitespace. +- `import/flowchart-empty-edge-label` — an edge label is empty or contains only whitespace. +- `import/flowchart-conflicting-node-declaration` — same id declared twice with different explicit text/shape. +- `import/xml-disallowed-character` — a label or title contains a character (for example U+0000) that cannot be represented in the delivered SVG. +- `import/unsupported-edge-syntax` — open link `---` / `-.-` / `-..-` (arrowless). +- `import/unsupported-direction-directive` — Mermaid `direction` directive. +- `import/unsupported-keyword-*` — styling/interaction directives (`classDef`, `style`, `click`, …). + +## Runnable example + +```bash +cat > /tmp/api-flow.mmd <<'EOF' +flowchart LR + Web[Web App] --> API(API Server) + API -->|reads| DB[(PostgreSQL)] + API --> Cache[(Redis Cache)] + subgraph Edge + Web + end +EOF +node bin/archify.mjs import flowchart /tmp/api-flow.mmd /tmp/api-flow.json --json +# → {"ok": true, "components": 4, "connections": 3, ...} +node bin/archify.mjs validate architecture /tmp/api-flow.json --quality showcase --json +node bin/archify.mjs deliver architecture /tmp/api-flow.json /tmp/api-flow.html --quality showcase --json +``` + +Regression fixtures live in `test/fixtures/flowchart/` and cover valid, +malformed, unsupported, and adversarial inputs +(`test/flowchart-import.test.mjs`). diff --git a/archify/renderers/shared/output-path.mjs b/archify/renderers/shared/output-path.mjs index 372249d62..e7ee4fdce 100644 --- a/archify/renderers/shared/output-path.mjs +++ b/archify/renderers/shared/output-path.mjs @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { containedBy, @@ -438,3 +439,68 @@ export function resolveOutputPath({ source, }; } + +/** + * Commit the import result through a non-following atomic candidate/rename. + * + * The aliasing preflight runs before parsing; the output path can change while + * the input parses (e.g. an output symlink re-pointed at the input). The + * candidate is created with O_CREAT|O_EXCL in the output's directory — never + * at the output path itself — and rename(2) replaces a symlink instead of + * following it, so no swap between the preflight and the commit can make this + * write reach the Mermaid source through a symlink. The alias recheck at the + * commit point reports the same `input/output-alias` condition as the + * preflight instead of silently replacing a symlink that now resolves to the + * input. + * + * @param {string} inputPath + * @param {string} outputPath + * @param {string} data + * @returns {{ ok: true } | { ok: false, reason: 'input/output-alias' }} + * @throws {OutputPathError} when an output path on a symbolic-link cycle + * cannot be proven non-aliasing (`output/symlink-cycle`; the caller maps + * `archifyDiagnostics` into its receipt). + * @throws {Error} when the output cannot be written (propagated to the CLI's + * `output/write` receipt handling); the candidate file is removed first — + * including when opening, writing, or fsync-ing it fails. + */ +export function commitImportOutput(inputPath, outputPath, data) { + // Same shared aliasing contract as resolveOutputPath: identical paths, + // symlinks resolving to the input, hard links sharing the input's inode, + // and future-path aliases. A symbolic-link cycle is not provably + // non-aliasing, so pathsAlias throws instead of returning false. + if (pathsAlias(inputPath, outputPath)) { + return { ok: false, reason: 'input/output-alias' }; + } + const resolved = path.resolve(outputPath); + const candidate = path.join( + path.dirname(resolved), + `.archify-import-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + let fd; + try { + fd = fs.openSync(candidate, 'wx'); + try { + fs.writeFileSync(fd, data); + fs.fsyncSync(fd); + } finally { + // Best-effort close: the data is already durable via fsync, and a close + // failure must not mask the original write/fsync error below. + try { fs.closeSync(fd); } catch { /* best effort */ } + } + } catch (error) { + // The candidate exists only if openSync succeeded; the forced removal is + // a no-op otherwise. Without this a failed open-past-creation, write, or + // fsync would leak one candidate file per run (the rename path below + // cleans up only itself). + try { fs.rmSync(candidate, { force: true }); } catch { /* best effort */ } + throw error; + } + try { + fs.renameSync(candidate, resolved); + } catch (error) { + try { fs.rmSync(candidate, { force: true }); } catch { /* best effort */ } + throw error; + } + return { ok: true }; +} diff --git a/archify/test/fixtures/flowchart/adversarial-injection.mmd b/archify/test/fixtures/flowchart/adversarial-injection.mmd new file mode 100644 index 000000000..08ad7548c --- /dev/null +++ b/archify/test/fixtures/flowchart/adversarial-injection.mmd @@ -0,0 +1,2 @@ +flowchart TD + A[""] --> B["]; malicious(); //"] diff --git a/archify/test/fixtures/flowchart/malformed-conflicting-redeclaration.mmd b/archify/test/fixtures/flowchart/malformed-conflicting-redeclaration.mmd new file mode 100644 index 000000000..004ba456b --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-conflicting-redeclaration.mmd @@ -0,0 +1,3 @@ +flowchart LR + A[One] + A[Two] diff --git a/archify/test/fixtures/flowchart/malformed-conflicting-same-statement.mmd b/archify/test/fixtures/flowchart/malformed-conflicting-same-statement.mmd new file mode 100644 index 000000000..5ab49fb37 --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-conflicting-same-statement.mmd @@ -0,0 +1,2 @@ +flowchart TD + A[One] --> A[Two] diff --git a/archify/test/fixtures/flowchart/malformed-no-declaration.mmd b/archify/test/fixtures/flowchart/malformed-no-declaration.mmd new file mode 100644 index 000000000..7af920b2b --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-no-declaration.mmd @@ -0,0 +1 @@ +A[Node A] --> B[Node B] diff --git a/archify/test/fixtures/flowchart/malformed-unbalanced-end.mmd b/archify/test/fixtures/flowchart/malformed-unbalanced-end.mmd new file mode 100644 index 000000000..6a8004e80 --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-unbalanced-end.mmd @@ -0,0 +1,3 @@ +flowchart TD + A[Node A] --> B[Node B] +end diff --git a/archify/test/fixtures/flowchart/malformed-unclosed-shape.mmd b/archify/test/fixtures/flowchart/malformed-unclosed-shape.mmd new file mode 100644 index 000000000..0c2eab2be --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-unclosed-shape.mmd @@ -0,0 +1,3 @@ +flowchart TD + A[Unclosed Label + B[Node B] diff --git a/archify/test/fixtures/flowchart/malformed-unclosed-subgraph.mmd b/archify/test/fixtures/flowchart/malformed-unclosed-subgraph.mmd new file mode 100644 index 000000000..aefddf690 --- /dev/null +++ b/archify/test/fixtures/flowchart/malformed-unclosed-subgraph.mmd @@ -0,0 +1,3 @@ +flowchart TD + subgraph MyGroup + A[Node A] --> B[Node B] diff --git a/archify/test/fixtures/flowchart/unsupported-classDef.mmd b/archify/test/fixtures/flowchart/unsupported-classDef.mmd new file mode 100644 index 000000000..9e3b7a3aa --- /dev/null +++ b/archify/test/fixtures/flowchart/unsupported-classDef.mmd @@ -0,0 +1,3 @@ +flowchart TD + A[Node A] --> B[Node B] + classDef highlight fill:#ff0000 diff --git a/archify/test/fixtures/flowchart/unsupported-dotted-open-link.mmd b/archify/test/fixtures/flowchart/unsupported-dotted-open-link.mmd new file mode 100644 index 000000000..ec4f737e0 --- /dev/null +++ b/archify/test/fixtures/flowchart/unsupported-dotted-open-link.mmd @@ -0,0 +1,2 @@ +flowchart LR + A[One] -.- B[Two] diff --git a/archify/test/fixtures/flowchart/unsupported-open-link.mmd b/archify/test/fixtures/flowchart/unsupported-open-link.mmd new file mode 100644 index 000000000..8c1f85a0d --- /dev/null +++ b/archify/test/fixtures/flowchart/unsupported-open-link.mmd @@ -0,0 +1,2 @@ +flowchart LR + A[One] --- B[Two] diff --git a/archify/test/fixtures/flowchart/unsupported-style.mmd b/archify/test/fixtures/flowchart/unsupported-style.mmd new file mode 100644 index 000000000..cd737146a --- /dev/null +++ b/archify/test/fixtures/flowchart/unsupported-style.mmd @@ -0,0 +1,3 @@ +flowchart TD + A[Node A] --> B[Node B] + style A fill:#f9f,stroke:#333,stroke-width:4px diff --git a/archify/test/fixtures/flowchart/unsupported-subgraph-direction.mmd b/archify/test/fixtures/flowchart/unsupported-subgraph-direction.mmd new file mode 100644 index 000000000..eb9f460be --- /dev/null +++ b/archify/test/fixtures/flowchart/unsupported-subgraph-direction.mmd @@ -0,0 +1,5 @@ +flowchart LR + subgraph API + direction TB + A[One] --> B[Two] + end diff --git a/archify/test/fixtures/flowchart/valid-chained.mmd b/archify/test/fixtures/flowchart/valid-chained.mmd new file mode 100644 index 000000000..c5a2e18df --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-chained.mmd @@ -0,0 +1,2 @@ +flowchart TD + A[User] --> B[Auth] --> C[API] --> D[(DB)] diff --git a/archify/test/fixtures/flowchart/valid-direction-bt.mmd b/archify/test/fixtures/flowchart/valid-direction-bt.mmd new file mode 100644 index 000000000..ebc9c4d80 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-direction-bt.mmd @@ -0,0 +1,3 @@ +flowchart BT + A[Source] --> B[Target] + B -->|retry| C[Gateway] diff --git a/archify/test/fixtures/flowchart/valid-direction-rl.mmd b/archify/test/fixtures/flowchart/valid-direction-rl.mmd new file mode 100644 index 000000000..17dc81a05 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-direction-rl.mmd @@ -0,0 +1,2 @@ +flowchart RL + A[Source] --> B[Target] diff --git a/archify/test/fixtures/flowchart/valid-labeled-edges.mmd b/archify/test/fixtures/flowchart/valid-labeled-edges.mmd new file mode 100644 index 000000000..b8ee4362b --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-labeled-edges.mmd @@ -0,0 +1,5 @@ +flowchart TD + A[Client] -->|HTTPS request| B[Load Balancer] + B --> C[API Server] + C -->|SQL query| D[(PostgreSQL)] + C -.->|cache miss| E[(Redis)] diff --git a/archify/test/fixtures/flowchart/valid-labeled-subgraph.mmd b/archify/test/fixtures/flowchart/valid-labeled-subgraph.mmd new file mode 100644 index 000000000..222cc8d31 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-labeled-subgraph.mmd @@ -0,0 +1,7 @@ +flowchart LR + Web[Web App] --> API(API Server) + API -->|reads| DB[(PostgreSQL)] + API --> Cache[(Redis Cache)] + subgraph Edge + Web + end diff --git a/archify/test/fixtures/flowchart/valid-long-labels.mmd b/archify/test/fixtures/flowchart/valid-long-labels.mmd new file mode 100644 index 000000000..8ec0b9d0a --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-long-labels.mmd @@ -0,0 +1,6 @@ +flowchart LR + subgraph Platform + A[Customer subscription management service] + end + B[Backend] -->|RPC| A + B[Backend] --> C[(PostgreSQL)] diff --git a/archify/test/fixtures/flowchart/valid-nested-subgraphs.mmd b/archify/test/fixtures/flowchart/valid-nested-subgraphs.mmd new file mode 100644 index 000000000..ae77ba405 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-nested-subgraphs.mmd @@ -0,0 +1,6 @@ +flowchart TD + subgraph Outer + subgraph Inner + A[Node] + end + end diff --git a/archify/test/fixtures/flowchart/valid-redeclared-labels.mmd b/archify/test/fixtures/flowchart/valid-redeclared-labels.mmd new file mode 100644 index 000000000..cb58eb709 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-redeclared-labels.mmd @@ -0,0 +1,4 @@ +flowchart LR + A --> B + A[Named source] + B[Named target] diff --git a/archify/test/fixtures/flowchart/valid-same-statement-redeclare.mmd b/archify/test/fixtures/flowchart/valid-same-statement-redeclare.mmd new file mode 100644 index 000000000..a7697d378 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-same-statement-redeclare.mmd @@ -0,0 +1,2 @@ +flowchart LR + A --> B --> B[Named bee] diff --git a/archify/test/fixtures/flowchart/valid-simple.mmd b/archify/test/fixtures/flowchart/valid-simple.mmd new file mode 100644 index 000000000..fd172eb38 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-simple.mmd @@ -0,0 +1,4 @@ +flowchart TD + A[API Server] --> B[(PostgreSQL)] + A --> C[(Redis Cache)] + B --> D[Analytics Worker] diff --git a/archify/test/fixtures/flowchart/valid-subgraph.mmd b/archify/test/fixtures/flowchart/valid-subgraph.mmd new file mode 100644 index 000000000..cd80b2fc1 --- /dev/null +++ b/archify/test/fixtures/flowchart/valid-subgraph.mmd @@ -0,0 +1,12 @@ +flowchart LR + subgraph Frontend + A[Web App] + B[Mobile App] + end + subgraph Backend + C[API Server] + D[(Database)] + end + A --> C + B --> C + C --> D diff --git a/archify/test/flowchart-import.test.mjs b/archify/test/flowchart-import.test.mjs new file mode 100644 index 000000000..0105183d1 --- /dev/null +++ b/archify/test/flowchart-import.test.mjs @@ -0,0 +1,1029 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parseFlowchart, importFlowchart } from '../importers/flowchart.mjs'; +import { commitImportOutput, OutputPathError } from '../renderers/shared/output-path.mjs'; +import { SaxesParser } from 'saxes'; +import { extractSvgs } from './helpers/xml.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const skillRoot = path.resolve(__dirname, '..'); +const fixturesDir = path.join(__dirname, 'fixtures', 'flowchart'); + +function readFixture(name) { + return fs.readFileSync(path.join(fixturesDir, name), 'utf8'); +} + +// --- Valid imports ------------------------------------------------------- + +test('valid simple flowchart imports into typed architecture IR', () => { + const result = parseFlowchart(readFixture('valid-simple.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + assert.equal(result.ir.schema_version, 1); + assert.equal(result.ir.diagram_type, 'architecture'); + assert.equal(result.ir.components.length, 4); + assert.equal(result.ir.connections.length, 3); + const labels = result.ir.components.map((c) => c.label); + assert.ok(labels.includes('API Server')); + assert.ok(labels.includes('PostgreSQL')); + assert.ok(labels.includes('Redis Cache')); + assert.ok(labels.includes('Analytics Worker')); +}); + +test('valid subgraph flowchart maps subgraphs to boundaries', () => { + const result = parseFlowchart(readFixture('valid-subgraph.mmd')); + assert.ok(result.ok); + assert.equal(result.ir.components.length, 4); + assert.equal(result.ir.connections.length, 3); + assert.ok(result.ir.boundaries, 'Expected boundaries from subgraphs'); + assert.equal(result.ir.boundaries.length, 2); + const boundaryLabels = result.ir.boundaries.map((b) => b.label); + assert.ok(boundaryLabels.includes('Frontend')); + assert.ok(boundaryLabels.includes('Backend')); + const frontend = result.ir.boundaries.find((b) => b.label === 'Frontend'); + assert.ok(frontend.wraps.includes('A')); + assert.ok(frontend.wraps.includes('B')); + const backend = result.ir.boundaries.find((b) => b.label === 'Backend'); + assert.ok(backend.wraps.includes('C')); + assert.ok(backend.wraps.includes('D')); +}); + +test('nested subgraphs record membership in every enclosing region', () => { + const result = parseFlowchart(readFixture('valid-nested-subgraphs.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + assert.equal(result.ir.boundaries.length, 2); + const outer = result.ir.boundaries.find((b) => b.label === 'Outer'); + const inner = result.ir.boundaries.find((b) => b.label === 'Inner'); + assert.ok(outer, 'Expected an Outer boundary'); + assert.ok(inner, 'Expected an Inner boundary'); + assert.ok(outer.wraps.includes('A'), 'Outer must record A so its wraps list is not empty'); + assert.ok(inner.wraps.includes('A'), 'Inner must record A'); +}); + +test('valid labeled edges preserve labels in connections', () => { + const result = parseFlowchart(readFixture('valid-labeled-edges.mmd')); + assert.ok(result.ok); + assert.equal(result.ir.connections.length, 4); + const labeledConn = result.ir.connections.find((c) => c.label === 'HTTPS request'); + assert.ok(labeledConn, 'Expected a connection labeled "HTTPS request"'); + const sqlConn = result.ir.connections.find((c) => c.label === 'SQL query'); + assert.ok(sqlConn, 'Expected a connection labeled "SQL query"'); + const cacheConn = result.ir.connections.find((c) => c.label === 'cache miss'); + assert.ok(cacheConn, 'Expected a connection labeled "cache miss"'); + assert.equal(cacheConn.variant, 'dashed'); +}); + +test('valid chained edges create multiple connections from one line', () => { + const result = parseFlowchart(readFixture('valid-chained.mmd')); + assert.ok(result.ok); + assert.equal(result.ir.components.length, 4); + assert.equal(result.ir.connections.length, 3); + assert.equal(result.ir.connections[0].from, 'A'); + assert.equal(result.ir.connections[0].to, 'B'); + assert.equal(result.ir.connections[1].from, 'B'); + assert.equal(result.ir.connections[1].to, 'C'); + assert.equal(result.ir.connections[2].from, 'C'); + assert.equal(result.ir.connections[2].to, 'D'); +}); + +test('a later explicit node declaration updates the earlier implicit one', () => { + const result = parseFlowchart(readFixture('valid-redeclared-labels.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + assert.equal(result.ir.components.length, 2); + const a = result.ir.components.find((c) => c.id === 'A'); + const b = result.ir.components.find((c) => c.id === 'B'); + assert.equal(a.label, 'Named source'); + assert.equal(b.label, 'Named target'); +}); + +test('a later explicit declaration inside the same statement still wins', () => { + const result = parseFlowchart(readFixture('valid-same-statement-redeclare.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + const b = result.ir.components.find((c) => c.id === 'B'); + assert.equal(b.label, 'Named bee', 'The later explicit declaration must not be dropped within one statement'); +}); + +test('an implicit self-reference refined later in the same statement keeps the label', () => { + const result = parseFlowchart('flowchart LR\n A --> A[Label]'); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + const a = result.ir.components.find((c) => c.id === 'A'); + assert.equal(a.label, 'Label', 'A --> A[Label] must import the explicit label, not the bare id'); +}); + +test('conflicting explicit declarations within one statement exit non-zero', () => { + const result = parseFlowchart(readFixture('malformed-conflicting-same-statement.mmd')); + assert.ok(!result.ok, 'Expected A[One] --> A[Two] to be rejected'); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-conflicting-node-declaration')); +}); + +test('RL direction places sources to the right of their targets', () => { + const result = parseFlowchart(readFixture('valid-direction-rl.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + const a = result.ir.components.find((c) => c.id === 'A'); + const b = result.ir.components.find((c) => c.id === 'B'); + assert.ok(a.pos[0] > b.pos[0], `RL: source x=${a.pos[0]} must exceed target x=${b.pos[0]}`); +}); + +test('BT direction places sources below their targets', () => { + const result = parseFlowchart(readFixture('valid-direction-bt.mmd')); + assert.ok(result.ok, `Expected ok, got diagnostics: ${JSON.stringify(result.diagnostics)}`); + const a = result.ir.components.find((c) => c.id === 'A'); + const b = result.ir.components.find((c) => c.id === 'B'); + assert.ok(a.pos[1] > b.pos[1], `BT: source y=${a.pos[1]} must exceed target y=${b.pos[1]}`); + const labeled = result.ir.connections.find((c) => c.label === 'retry'); + assert.ok(labeled, 'Expected the labeled B→C connection'); + assert.ok(labeled.labelDy < 0, 'BT labels shift upward toward the route midpoint'); +}); + +test('LR and TD layouts keep their original orientation', () => { + const lr = parseFlowchart('flowchart LR\n A[Source] --> B[Target]'); + assert.ok(lr.ok); + const aLr = lr.ir.components.find((c) => c.id === 'A'); + const bLr = lr.ir.components.find((c) => c.id === 'B'); + assert.ok(aLr.pos[0] < bLr.pos[0], 'LR: source must sit left of target'); + const td = parseFlowchart('flowchart TD\n A[Source] --> B[Target]'); + assert.ok(td.ok); + const aTd = td.ir.components.find((c) => c.id === 'A'); + const bTd = td.ir.components.find((c) => c.id === 'B'); + assert.ok(aTd.pos[1] < bTd.pos[1], 'TD: source must sit above target'); +}); + +test('all component types are valid Archify componentType values', () => { + const result = parseFlowchart(readFixture('valid-subgraph.mmd')); + assert.ok(result.ok); + const validTypes = ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external']; + for (const comp of result.ir.components) { + assert.ok(validTypes.includes(comp.type), `Component ${comp.id} has invalid type "${comp.type}"`); + } +}); + +test('all component ids match the Archify id pattern', () => { + const result = parseFlowchart(readFixture('valid-simple.mmd')); + assert.ok(result.ok); + const idPattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + for (const comp of result.ir.components) { + assert.match(comp.id, idPattern, `Component id "${comp.id}" does not match pattern`); + } +}); + +// --- Malformed sources --------------------------------------------------- + +test('unclosed subgraph exits non-zero with a stable diagnostic', () => { + const result = parseFlowchart(readFixture('malformed-unclosed-subgraph.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-unclosed-subgraph')); +}); + +test('unclosed node shape exits non-zero with a stable diagnostic', () => { + const result = parseFlowchart(readFixture('malformed-unclosed-shape.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-unclosed-node-shape')); +}); + +test('missing diagram declaration exits non-zero with a stable diagnostic', () => { + const result = parseFlowchart(readFixture('malformed-no-declaration.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-missing-declaration')); +}); + +test('unbalanced end exits non-zero with a stable diagnostic', () => { + const result = parseFlowchart(readFixture('malformed-unbalanced-end.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-unbalanced-end')); +}); + +test('conflicting explicit redeclarations exit non-zero instead of silently picking one', () => { + const result = parseFlowchart(readFixture('malformed-conflicting-redeclaration.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/flowchart-conflicting-node-declaration')); +}); + +// --- Unsupported sources -------------------------------------------------- + +test('classDef directive exits non-zero with a stable named diagnostic', () => { + const result = parseFlowchart(readFixture('unsupported-classDef.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/unsupported-keyword-classdef')); +}); + +test('style directive exits non-zero with a stable named diagnostic', () => { + const result = parseFlowchart(readFixture('unsupported-style.mmd')); + assert.ok(!result.ok); + assert.ok(result.diagnostics.some((d) => d.code === 'import/unsupported-keyword-style')); +}); + +test('open link "---" exits non-zero instead of becoming a dashed edge', () => { + const result = parseFlowchart(readFixture('unsupported-open-link.mmd')); + assert.ok(!result.ok, 'Expected the open link "---" to be rejected'); + assert.ok(result.diagnostics.some((d) => d.code === 'import/unsupported-edge-syntax')); +}); + +test('dotted open link "-." exits non-zero with the unsupported-edge diagnostic', () => { + const result = parseFlowchart(readFixture('unsupported-dotted-open-link.mmd')); + assert.ok(!result.ok, 'Expected the dotted open link "-." to be rejected'); + const diag = result.diagnostics.find((d) => d.code === 'import/unsupported-edge-syntax'); + assert.ok(diag, `Expected import/unsupported-edge-syntax, got: ${JSON.stringify(result.diagnostics)}`); + assert.ok(!result.diagnostics.some((d) => d.code === 'import/flowchart-invalid-node-id'), + 'The rejection must not surface as an invalid-node-id parse error'); + assert.ok(diag.message.includes('"-.-"'), 'The diagnostic should name the offending open-link form'); +}); + +test('subgraph "direction" directive exits non-zero instead of inventing components', () => { + const result = parseFlowchart(readFixture('unsupported-subgraph-direction.mmd')); + assert.ok(!result.ok, 'Expected the subgraph direction directive to be rejected'); + assert.ok(result.diagnostics.some((d) => d.code === 'import/unsupported-direction-directive')); +}); + +test('CLI import command exits non-zero for the open link with a stable diagnostic', () => { + const cli = path.join(skillRoot, 'bin', 'archify.mjs'); + const fixture = path.join(fixturesDir, 'unsupported-open-link.mmd'); + const result = spawnSync(process.execPath, [cli, 'import', 'flowchart', fixture, '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.notEqual(result.status, 0, 'Expected non-zero exit for the open link "---"'); + const receipt = JSON.parse(result.stdout.trim()); + assert.equal(receipt.ok, false); + assert.ok(receipt.diagnostics.some((d) => d.code === 'import/unsupported-edge-syntax')); +}); + +test('CLI import command emits a schema-v1 receipt for a missing input in --json mode', () => { + const cli = path.join(skillRoot, 'bin', 'archify.mjs'); + const result = spawnSync(process.execPath, [cli, 'import', 'flowchart', '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.notEqual(result.status, 0, 'Expected non-zero exit for missing input'); + const receipt = JSON.parse(result.stdout.trim()); + assert.equal(receipt.schemaVersion, 1); + assert.equal(receipt.command, 'import'); + assert.equal(receipt.ok, false); + assert.ok(receipt.diagnostics.some((d) => d.code === 'import/missing-input')); +}); + +test('CLI import command emits a schema-v1 receipt for an unsupported format in --json mode', () => { + const cli = path.join(skillRoot, 'bin', 'archify.mjs'); + const result = spawnSync(process.execPath, [cli, 'import', 'not-a-format', 'input.mmd', '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.notEqual(result.status, 0, 'Expected non-zero exit for unsupported format'); + const receipt = JSON.parse(result.stdout.trim()); + assert.equal(receipt.ok, false); + assert.ok(receipt.diagnostics.some((d) => d.code === 'import/unsupported-format')); +}); + +test('CLI import command emits a schema-v1 receipt for an unknown option in --json mode', () => { + const cli = path.join(skillRoot, 'bin', 'archify.mjs'); + const result = spawnSync(process.execPath, [cli, 'import', 'flowchart', 'input.mmd', '--bogus', '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.notEqual(result.status, 0, 'Expected non-zero exit for unknown option'); + const receipt = JSON.parse(result.stdout.trim()); + assert.equal(receipt.ok, false); + assert.ok(receipt.diagnostics.some((d) => d.code === 'import/unknown-option')); +}); + +test('unspaced directed edge "A-->B" is parsed rather than mangled into a node id', () => { + const result = parseFlowchart('flowchart LR\nA-->B\n'); + assert.ok(result.ok, `Expected ok, got: ${JSON.stringify(result.diagnostics)}`); + assert.ok(result.ir.connections.some((c) => c.from === 'A' && c.to === 'B')); +}); + +test('spaced open link "A --- B" is rejected with unsupported-edge-syntax', () => { + const result = parseFlowchart('flowchart LR\nA --- B\n'); + assert.ok(!result.ok, 'Expected the open link to be rejected'); + const diag = result.diagnostics.find((d) => d.code === 'import/unsupported-edge-syntax'); + assert.ok(diag, `Expected unsupported-edge-syntax, got: ${JSON.stringify(result.diagnostics)}`); +}); + +test('non-spaced open link "A---B" is rejected rather than silently omitted', () => { + const result = parseFlowchart('flowchart LR\nA---B\n'); + assert.ok(!result.ok, 'Expected the open link to be rejected'); + const diag = result.diagnostics.find((d) => d.code === 'import/unsupported-edge-syntax'); + assert.ok(diag, `Expected unsupported-edge-syntax, got: ${JSON.stringify(result.diagnostics)}`); + // The edge must not be silently dropped. + assert.ok(!result.ir || result.ir.connections.length === 0, + 'A rejected open link must not leave a connection'); +}); + +test('long directed arrows are mapped to their edge variant', () => { + const solid = parseFlowchart('flowchart LR\nA--->B\n'); + assert.ok(solid.ok, `Expected long solid arrow to pass, got: ${JSON.stringify(solid.diagnostics)}`); + // The default "solid" variant is omitted from the IR to keep it compact. + assert.equal(solid.ir.connections[0].variant || 'solid', 'solid'); + + const dashed = parseFlowchart('flowchart LR\nA-...->B\n'); + assert.ok(dashed.ok, `Expected long dotted arrow to pass, got: ${JSON.stringify(dashed.diagnostics)}`); + assert.equal(dashed.ir.connections[0].variant, 'dashed'); + + const emphasis = parseFlowchart('flowchart LR\nA====>B\n'); + assert.ok(emphasis.ok, `Expected long thick arrow to pass, got: ${JSON.stringify(emphasis.diagnostics)}`); + assert.equal(emphasis.ir.connections[0].variant, 'emphasis'); +}); + +test('node ids with internal hyphens are preserved when not starting an edge', () => { + const result = parseFlowchart('flowchart LR\nA-B --> B-C\n'); + assert.ok(result.ok, `Expected ok, got: ${JSON.stringify(result.diagnostics)}`); + assert.ok(result.ir.components.some((c) => c.id === 'A-B')); + assert.ok(result.ir.components.some((c) => c.id === 'B-C')); + assert.ok(result.ir.connections.some((c) => c.from === 'A-B' && c.to === 'B-C')); +}); + +test('every imported valid fixture passes showcase layout validation', () => { + const cli = path.join(skillRoot, 'bin', 'archify.mjs'); + const fixtures = fs.readdirSync(fixturesDir).filter((f) => f.startsWith('valid-') && f.endsWith('.mmd')); + assert.ok(fixtures.length >= 8, `Expected the full valid fixture set, found: ${fixtures.join(', ')}`); + for (const name of fixtures) { + const fixture = path.join(fixturesDir, name); + const tmpOut = path.join(os.tmpdir(), `archify-import-${name.replace(/\.mmd$/, '')}-${Date.now()}.json`); + const imported = spawnSync(process.execPath, [cli, 'import', 'flowchart', fixture, tmpOut, '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.equal(imported.status, 0, `${name}: import failed: ${imported.stderr}`); + const validated = spawnSync(process.execPath, [cli, 'validate', 'architecture', tmpOut, '--quality', 'showcase', '--json'], { + encoding: 'utf8', + stdio: 'pipe', + }); + assert.equal(validated.status, 0, `${name}: showcase validation failed: ${validated.stdout}`); + fs.unlinkSync(tmpOut); + } +}); + +// --- Adversarial sources ------------------------------------------------- + +test('adversarial HTML injection in node labels is preserved as text, not interpreted', () => { + const result = parseFlowchart(readFixture('adversarial-injection.mmd')); + assert.ok(result.ok); + const labels = result.ir.components.map((c) => c.label); + // The label should contain the raw text including the script tag, not interpret it. + assert.ok(labels.some((l) => l.includes('