qgraphflow 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.codex-plugin/plugin.json +26 -0
- package/.cursor-plugin/plugin.json +9 -0
- package/.qoder-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/THIRD_PARTY_NOTICES.md +190 -0
- package/bin/qgraphflow.mjs +17 -0
- package/docs/clients.de.md +83 -0
- package/docs/clients.es.md +83 -0
- package/docs/clients.ja.md +83 -0
- package/docs/clients.md +83 -0
- package/docs/clients.pt.md +83 -0
- package/docs/clients.ru.md +83 -0
- package/docs/clients.zh-CN.md +83 -0
- package/docs/readme/README.de.md +262 -0
- package/docs/readme/README.es.md +262 -0
- package/docs/readme/README.ja.md +262 -0
- package/docs/readme/README.pt.md +262 -0
- package/docs/readme/README.ru.md +262 -0
- package/docs/readme/README.zh-CN.md +264 -0
- package/examples/order-flow.graph.json +94 -0
- package/package.json +61 -0
- package/skills/q-flow/SKILL.md +69 -0
- package/skills/q-flow/agents/openai.yaml +5 -0
- package/skills/q-flow/assets/layout-dist/ELK-LICENSE.md +264 -0
- package/skills/q-flow/assets/layout-dist/worker.mjs +24 -0
- package/skills/q-flow/assets/viewer/package.json +22 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +43 -0
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +21 -0
- package/skills/q-flow/assets/viewer/src/diagrams/class.js +52 -0
- package/skills/q-flow/assets/viewer/src/diagrams/dataflow.js +19 -0
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +41 -0
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +174 -0
- package/skills/q-flow/assets/viewer/src/diagrams/er.js +34 -0
- package/skills/q-flow/assets/viewer/src/diagrams/flowchart.js +37 -0
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +28 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +38 -0
- package/skills/q-flow/assets/viewer/src/diagrams/state.js +91 -0
- package/skills/q-flow/assets/viewer/src/diagrams/usecase.js +28 -0
- package/skills/q-flow/assets/viewer/src/edge-routing.js +596 -0
- package/skills/q-flow/assets/viewer/src/export-svg.js +90 -0
- package/skills/q-flow/assets/viewer/src/graph-validation.js +286 -0
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +1314 -0
- package/skills/q-flow/assets/viewer/src/i18n.js +14 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +55 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +164 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +12 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +28 -0
- package/skills/q-flow/assets/viewer/src/radix-colors.js +47 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +140 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +208 -0
- package/skills/q-flow/assets/viewer/src/session-graph.js +43 -0
- package/skills/q-flow/assets/viewer/src/text-layout.js +126 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +158 -0
- package/skills/q-flow/assets/viewer-dist/index.html +291 -0
- package/skills/q-flow/references/acceptance.md +11 -0
- package/skills/q-flow/references/evidence-sources.md +38 -0
- package/skills/q-flow/references/graph-common.md +54 -0
- package/skills/q-flow/references/graph-schema.md +214 -0
- package/skills/q-flow/references/guided-intake.md +100 -0
- package/skills/q-flow/references/types/architecture.md +41 -0
- package/skills/q-flow/references/types/class.md +40 -0
- package/skills/q-flow/references/types/dataflow.md +41 -0
- package/skills/q-flow/references/types/deployment.md +37 -0
- package/skills/q-flow/references/types/er.md +36 -0
- package/skills/q-flow/references/types/flowchart.md +47 -0
- package/skills/q-flow/references/types/sequence.md +74 -0
- package/skills/q-flow/references/types/state.md +44 -0
- package/skills/q-flow/references/types/usecase.md +39 -0
- package/skills/q-flow/references/viewer-development.md +258 -0
- package/skills/q-flow/references/visual-contract.md +54 -0
- package/skills/q-flow/scripts/compile-layout.mjs +565 -0
- package/skills/q-flow/scripts/compile-sequence.mjs +112 -0
- package/skills/q-flow/scripts/generate-viewer.mjs +126 -0
- package/skills/q-flow/scripts/validate-graph.mjs +278 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { minimumNodeSize } from '../assets/viewer/src/layout-measure.js';
|
|
2
|
+
import { sequenceHeaderHeight } from '../assets/viewer/src/diagrams/sequence.js';
|
|
3
|
+
import { operandId, operandEdges, fragmentDepth, fragmentHeadingLayout } from '../assets/viewer/src/sequence-fragments.js';
|
|
4
|
+
import { createEdgeRoutes } from '../assets/viewer/src/edge-routing.js';
|
|
5
|
+
import { sequenceMessageLabel, sequenceExecutions } from '../assets/viewer/src/sequence-executions.js';
|
|
6
|
+
import { edgeLabelLayout, layoutText } from '../assets/viewer/src/text-layout.js';
|
|
7
|
+
import { LAYOUT_LIMITS, LAYOUT_TARGETS } from '../assets/viewer/src/layout-spacing.js';
|
|
8
|
+
|
|
9
|
+
export function compileSequence(input, candidate) {
|
|
10
|
+
const graph = structuredClone(input), groups = graph.groups ?? [], edges = new Map(graph.edges.map(edge => [edge.id, edge]));
|
|
11
|
+
// Without declared order, participants stand in the order the messages first reach them, initiator leftmost.
|
|
12
|
+
const firstSeen = new Map();
|
|
13
|
+
for (const edge of [...graph.edges].sort((a, b) => a.order - b.order)) for (const id of [edge.source, edge.target]) if (!firstSeen.has(id)) firstSeen.set(id, firstSeen.size);
|
|
14
|
+
const ordered = graph.layout?.participantOrder ?? [...graph.nodes].sort((a, b) => (a.layout?.order ?? 0) - (b.layout?.order ?? 0) || (firstSeen.get(a.id) ?? Infinity) - (firstSeen.get(b.id) ?? Infinity) || (a.id < b.id ? -1 : 1)).map(node => node.id);
|
|
15
|
+
const nodes = ordered.map(id => graph.nodes.find(node => node.id === id));
|
|
16
|
+
const spacing = LAYOUT_TARGETS.nodeGap + candidate * 8, gap = LAYOUT_LIMITS.labelGap + candidate * 4;
|
|
17
|
+
const guardText = (group, operand) => group.kind === 'par' ? operand.label : `${group.kind === 'loop' ? `${group.loop.min}..${group.loop.max} ` : ''}[${operand.guard}]`;
|
|
18
|
+
const guardLayout = (group, operand) => layoutText(guardText(group, operand), LAYOUT_TARGETS.labelWidth, 14, 20);
|
|
19
|
+
const bodyLayout = operand => layoutText(operand.body, LAYOUT_TARGETS.labelWidth, 16, 24);
|
|
20
|
+
let x = 32;
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
node.size = minimumNodeSize(node, 'sequence', graph.meta.locale); node.position = { x, y: 48 };
|
|
23
|
+
x += node.size.width + spacing;
|
|
24
|
+
}
|
|
25
|
+
// Difference constraints on the message's actual span; moving the suffix keeps
|
|
26
|
+
// unrelated neighboring gaps unchanged. Longer spans reuse existing space.
|
|
27
|
+
const constraints = [...graph.edges].sort((a, b) => Math.abs(ordered.indexOf(a.source) - ordered.indexOf(a.target)) - Math.abs(ordered.indexOf(b.source) - ordered.indexOf(b.target)) || a.order - b.order);
|
|
28
|
+
const selfCounts = new Map();
|
|
29
|
+
for (const edge of constraints) {
|
|
30
|
+
const left = Math.min(ordered.indexOf(edge.source), ordered.indexOf(edge.target));
|
|
31
|
+
let right = Math.max(ordered.indexOf(edge.source), ordered.indexOf(edge.target));
|
|
32
|
+
const label = edgeLabelLayout(sequenceMessageLabel(graph, edge));
|
|
33
|
+
let required = label.width + 64;
|
|
34
|
+
if (left === right) {
|
|
35
|
+
const ordinal = selfCounts.get(edge.source) ?? 0; selfCounts.set(edge.source, ordinal + 1);
|
|
36
|
+
right++; required += 48 + ordinal * 24;
|
|
37
|
+
if (right === nodes.length) continue;
|
|
38
|
+
}
|
|
39
|
+
const center = node => node.position.x + node.size.width / 2;
|
|
40
|
+
const extra = Math.max(0, required - (center(nodes[right]) - center(nodes[left])));
|
|
41
|
+
for (let i = right; i < nodes.length; i++) nodes[i].position.x += extra;
|
|
42
|
+
}
|
|
43
|
+
// Provisional order-preserving times let the shared router measure actual
|
|
44
|
+
// prefixes, activation offsets, wrapped labels and self calls.
|
|
45
|
+
for (const edge of graph.edges) edge.route = { messageY: 200 + edge.order * 100 };
|
|
46
|
+
const routes = createEdgeRoutes(graph), executions = sequenceExecutions(graph);
|
|
47
|
+
const owned = new Set(groups.flatMap(group => (group.operands ?? []).flatMap(operand => operand.edgeIds)));
|
|
48
|
+
const range = group => (group.operands ?? []).flatMap((operand, i) => operandEdges(group, operand, i, groups)).map(id => edges.get(id).order);
|
|
49
|
+
const eventOrder = event => event.edge ? event.edge.order : Math.min(Infinity, ...range(event.group));
|
|
50
|
+
const sorted = events => events.sort((a, b) => eventOrder(a) - eventOrder(b) || ((a.edge ?? a.group).id < (b.edge ?? b.group).id ? -1 : 1));
|
|
51
|
+
// Fragments enclose only their messages and children. A bounded local gutter
|
|
52
|
+
// lets active lifelines continue beside complete, wrapped conditions.
|
|
53
|
+
const spanPoints = group => (group.operands ?? []).flatMap((operand, i) => operandEdges(group, operand, i, groups)).map(id => routes.get(id)).flatMap(route => [...route.points, { x: route.labelBox.x }, { x: route.labelBox.x + route.labelBox.width }]);
|
|
54
|
+
// A fragment without messages of its own stays under the span it comments on: the
|
|
55
|
+
// nearest ancestor's messages, or the whole conversation at top level.
|
|
56
|
+
const anchorPoints = group => {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (let item = group; item && !seen.has(item.id); item = groups.find(other => other.id === item.parentId)) {
|
|
59
|
+
seen.add(item.id);
|
|
60
|
+
const points = spanPoints(item);
|
|
61
|
+
if (points.length) return points;
|
|
62
|
+
}
|
|
63
|
+
return [...routes.values()].flatMap(route => route.points);
|
|
64
|
+
};
|
|
65
|
+
const tagRoom = 56; // kind tag plus its inset, kept clear of activation bars
|
|
66
|
+
for (const group of [...groups].sort((a, b) => fragmentDepth(b, groups) - fragmentDepth(a, groups) || a.id.localeCompare(b.id))) {
|
|
67
|
+
if (!group.operands) throw new Error(`Sequence group ${group.id} needs explicit operands before automatic layout`);
|
|
68
|
+
const points = anchorPoints(group);
|
|
69
|
+
const children = groups.filter(child => child.parentId === group.id);
|
|
70
|
+
const textWidth = Math.max(fragmentHeadingLayout({ ...group, size: undefined }).width + 16, ...group.operands.flatMap(operand => [guardLayout(group, operand).width, bodyLayout(operand).width]));
|
|
71
|
+
const ys = group.operands.flatMap((operand, i) => operandEdges(group, operand, i, groups)).map(id => edges.get(id).route.messageY);
|
|
72
|
+
const top = Math.min(...ys), bottom = Math.max(...ys);
|
|
73
|
+
const bars = executions.filter(bar => !ys.length || (bar.y <= bottom && bar.y + bar.height >= top)).sort((a, b) => a.x - b.x);
|
|
74
|
+
// Messages leave an activation at its edge; the gutter starts at the bar itself.
|
|
75
|
+
const anchorLeft = points.length ? Math.min(...points.map(point => point.x)) : nodes[0].position.x;
|
|
76
|
+
const localLeft = Math.min(anchorLeft, ...bars.filter(bar => bar.x < anchorLeft && bar.x + bar.width >= anchorLeft - 8).map(bar => bar.x)) - textWidth - 40;
|
|
77
|
+
const frameLeft = Math.min(localLeft, ...children.map(child => child.position.x - 32));
|
|
78
|
+
let right = Math.max(frameLeft + textWidth + 120, ...points.map(point => point.x + 32), ...children.map(child => child.position.x + child.size.width + 32));
|
|
79
|
+
for (const bar of bars) if (bar.x < right + 8 && bar.x + bar.width > right - tagRoom - 16) right = Math.max(right, bar.x + bar.width + tagRoom);
|
|
80
|
+
group.position = { x: frameLeft, y: 0 }; group.size = { width: Math.ceil(right - frameLeft), height: 0 };
|
|
81
|
+
}
|
|
82
|
+
function message(edge, cursor) {
|
|
83
|
+
const label = routes.get(edge.id).labelBox, self = edge.source === edge.target;
|
|
84
|
+
const y = Math.ceil(cursor + (self ? Math.max(16, label.height / 2 - 15) : label.height + 6));
|
|
85
|
+
edge.route = { messageY: y };
|
|
86
|
+
return y + (self ? Math.max(36, 15 + label.height / 2) : 6) + gap;
|
|
87
|
+
}
|
|
88
|
+
function eventsAt(events, cursor) {
|
|
89
|
+
for (const event of sorted(events)) cursor = event.edge ? message(event.edge, cursor) : fragment(event.group, cursor);
|
|
90
|
+
return cursor;
|
|
91
|
+
}
|
|
92
|
+
function fragment(group, top) {
|
|
93
|
+
group.position.y = top;
|
|
94
|
+
let cursor = top + Math.max(36, fragmentHeadingLayout(group).height + 20) + gap;
|
|
95
|
+
for (const [i, operand] of group.operands.entries()) {
|
|
96
|
+
cursor += guardLayout(group, operand).height + 16;
|
|
97
|
+
if (operand.body) cursor += bodyLayout(operand).height + 16;
|
|
98
|
+
const children = groups.filter(child => child.parentId === group.id && child.parentOperandId === operandId(operand, i));
|
|
99
|
+
cursor = eventsAt([...operand.edgeIds.map(id => ({ edge: edges.get(id) })), ...children.map(group => ({ group }))], cursor);
|
|
100
|
+
cursor += gap;
|
|
101
|
+
}
|
|
102
|
+
group.size.height = cursor - top + 16;
|
|
103
|
+
return top + group.size.height + gap;
|
|
104
|
+
}
|
|
105
|
+
const bottom = eventsAt([...graph.edges.filter(edge => !owned.has(edge.id)).map(edge => ({ edge })), ...groups.filter(group => !group.parentId).map(group => ({ group }))], 48 + Math.max(...nodes.map(sequenceHeaderHeight)) + LAYOUT_LIMITS.labelGap);
|
|
106
|
+
const messages = [...graph.edges].sort((a, b) => a.order - b.order);
|
|
107
|
+
for (let i = 1; i < messages.length; i++) if (messages[i].route.messageY <= messages[i - 1].route.messageY) throw new Error(`Sequence fragment ranges interleave at ${messages[i].id}; retain message order and correct operand ownership`);
|
|
108
|
+
const shift = Math.max(0, 32 - Math.min(...groups.map(group => group.position.x), ...nodes.map(node => node.position.x)));
|
|
109
|
+
for (const item of [...nodes, ...groups]) item.position.x += shift;
|
|
110
|
+
for (const node of graph.nodes) node.size.height = bottom + 32 - node.position.y;
|
|
111
|
+
return graph;
|
|
112
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { diagramTypeOf, graphsOf, printCompositionReview, readAndValidateGraph, verifySourceEvidence, layoutComposition } from './validate-graph.mjs';
|
|
9
|
+
import { compileGraphLayout } from './compile-layout.mjs';
|
|
10
|
+
import { requireDiagramQuality } from '../assets/viewer/src/layout-quality.js';
|
|
11
|
+
import { pageWithGraph } from '../assets/viewer/src/session-graph.js';
|
|
12
|
+
import { SVG_FILE, diagramSvgFiles } from '../assets/viewer/src/export-svg.js';
|
|
13
|
+
|
|
14
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const shellPath = path.resolve(scriptDir, '../assets/viewer-dist/index.html');
|
|
16
|
+
const PAGE_OUTPUTS = ['index.html', 'graph.json'];
|
|
17
|
+
|
|
18
|
+
// Every output is staged first; the old ones and the stale ones (removed by this run) move into the staging backup,
|
|
19
|
+
// and any failure puts all of them back.
|
|
20
|
+
export function writeOutputs(outputDir, contents, stale = []) {
|
|
21
|
+
const names = Object.keys(contents);
|
|
22
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
23
|
+
const staging = fs.mkdtempSync(path.join(outputDir, '.qgraphflow-')), backedUp = [], installed = [];
|
|
24
|
+
let keepBackup = false;
|
|
25
|
+
try {
|
|
26
|
+
for (const name of names) fs.writeFileSync(path.join(staging, name), contents[name]);
|
|
27
|
+
for (const name of [...names, ...stale]) if (fs.existsSync(path.join(outputDir, name))) {
|
|
28
|
+
fs.renameSync(path.join(outputDir, name), path.join(staging, `${name}.backup`)); backedUp.push(name);
|
|
29
|
+
}
|
|
30
|
+
for (const name of names) { fs.renameSync(path.join(staging, name), path.join(outputDir, name)); installed.push(name); }
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const rollbackErrors = [];
|
|
33
|
+
for (const name of installed) try { fs.unlinkSync(path.join(outputDir, name)); } catch (failure) { rollbackErrors.push(failure); }
|
|
34
|
+
for (const name of backedUp) try { fs.renameSync(path.join(staging, `${name}.backup`), path.join(outputDir, name)); } catch (failure) { rollbackErrors.push(failure); }
|
|
35
|
+
if (rollbackErrors.length) { keepBackup = true; throw new AggregateError([error, ...rollbackErrors], `Output rollback failed; original backups retained in ${staging}`); }
|
|
36
|
+
throw error;
|
|
37
|
+
} finally { if (!keepBackup) fs.rmSync(staging, { recursive: true, force: true }); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every view is compiled before anything is judged, so one run names every failing view instead of the first one only.
|
|
41
|
+
// A single failure is rethrown untouched; several are combined, their diagnostics concatenated and candidates keyed by type.
|
|
42
|
+
export async function compileViews(graphs, compile) {
|
|
43
|
+
const compiled = [], failures = [];
|
|
44
|
+
for (const graph of graphs) {
|
|
45
|
+
try { compiled.push(await compile(graph)); } catch (error) { failures.push({ graph, error }); }
|
|
46
|
+
}
|
|
47
|
+
if (failures.length === 1) throw failures[0].error;
|
|
48
|
+
if (failures.length) {
|
|
49
|
+
const phase = failures.some(({ error }) => error.phases?.semantic?.status === 'failed') ? 'semantic' : 'geometry';
|
|
50
|
+
throw Object.assign(new Error(failures.map(({ error }) => error.message).join('\n\n')), {
|
|
51
|
+
phases: { semantic: { status: phase === 'semantic' ? 'failed' : 'passed' }, geometry: { status: phase === 'semantic' ? 'not-checked' : 'failed' }, rendering: { status: 'not-checked' } },
|
|
52
|
+
diagnostics: failures.flatMap(({ error }) => error.diagnostics ?? []),
|
|
53
|
+
candidates: Object.fromEntries(failures.filter(({ error }) => error.candidates).map(({ graph, error }) => [diagramTypeOf(graph), error.candidates])),
|
|
54
|
+
failedViews: failures.map(({ graph }) => diagramTypeOf(graph))
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return compiled;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const USAGE = `Usage: node generate-viewer.mjs <graph.json> <output-directory> [options]
|
|
61
|
+
--repo-root <dir> verify every node source (file, line range, symbol) against this working tree
|
|
62
|
+
--layout auto|preserve auto (default) computes positions; preserve keeps authored geometry under the same gate
|
|
63
|
+
--force replace existing index.html / graph.json / diagram*.svg in the output directory (needs approval)
|
|
64
|
+
--verbose print the full receipt (layout candidates, folds, diagnostics) instead of one summary line
|
|
65
|
+
-h, --help this text
|
|
66
|
+
Writes index.html, graph.json and one SVG per view (diagram.svg, or diagram-<n>-<type>.svg for a collection).
|
|
67
|
+
Success prints one JSON line; failure prints the failing elements with rule, measurement and remediation.`;
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const { positionals: positional, values } = parseArgs({ allowPositionals: true, options: { force: { type: 'boolean' }, 'repo-root': { type: 'string' }, layout: { type: 'string', default: 'auto' }, verbose: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false } } });
|
|
71
|
+
const force = values.force;
|
|
72
|
+
if (values.help) { console.log(USAGE); process.exit(0); }
|
|
73
|
+
if (positional.length !== 2) {
|
|
74
|
+
console.error(USAGE);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [inputPath, outputArg] = positional;
|
|
79
|
+
const outputDir = path.resolve(outputArg);
|
|
80
|
+
if (outputDir === path.parse(outputDir).root || outputDir === os.homedir()) throw new Error('Refusing broad output directory');
|
|
81
|
+
const input = readAndValidateGraph(inputPath, { inputOnly: true });
|
|
82
|
+
const sourceEvidence = verifySourceEvidence(input, values['repo-root']);
|
|
83
|
+
const warnings = printCompositionReview(input, { print: false }); // already printed by the input validation step
|
|
84
|
+
if (!fs.existsSync(shellPath)) throw new Error(`Viewer shell missing: ${shellPath}`);
|
|
85
|
+
|
|
86
|
+
const inputAbsolute = path.resolve(inputPath);
|
|
87
|
+
// Every SVG named by this tool is either rewritten or, when this run no longer produces it, removed: both need --force.
|
|
88
|
+
const svgs = fs.existsSync(outputDir) ? fs.readdirSync(outputDir).filter(name => SVG_FILE.test(name)).sort() : [];
|
|
89
|
+
const existing = [...PAGE_OUTPUTS.filter(name => {
|
|
90
|
+
const outputPath = path.join(outputDir, name);
|
|
91
|
+
return fs.existsSync(outputPath) && path.resolve(outputPath) !== inputAbsolute;
|
|
92
|
+
}), ...svgs];
|
|
93
|
+
if (existing.length && !force) throw new Error(`Refusing to overwrite: ${existing.join(', ')}; rerun with --force after approval`);
|
|
94
|
+
const shell = fs.readFileSync(shellPath, 'utf8');
|
|
95
|
+
if (!shell.includes('__CODEGRAPH_FLOW_DATA__')) throw new Error('Viewer shell data marker is missing');
|
|
96
|
+
const compiled = await compileViews(graphsOf(input), item => compileGraphLayout(item, { layout: values.layout }));
|
|
97
|
+
const quality = compiled.map(item => requireDiagramQuality(item.graph));
|
|
98
|
+
const graph = Array.isArray(input.diagrams) ? { ...input, diagrams: compiled.map(item => item.graph) } : compiled[0].graph;
|
|
99
|
+
const contents = { 'index.html': pageWithGraph(shell, graph), 'graph.json': `${JSON.stringify(graph, null, 2)}\n` };
|
|
100
|
+
for (const { name, svg } of diagramSvgFiles(graph)) contents[name] = svg;
|
|
101
|
+
writeOutputs(outputDir, contents, svgs.filter(name => !Object.hasOwn(contents, name)));
|
|
102
|
+
const files = Object.keys(contents);
|
|
103
|
+
const graphs = graphsOf(graph);
|
|
104
|
+
// One line on success: what was made and whether each gate passed. Candidates, folds and diagnostics stay out of the
|
|
105
|
+
// model's context unless asked for with --verbose; failures still print their diagnostics through the catch below.
|
|
106
|
+
const status = key => quality.every(item => item[key]?.status === 'passed') ? 'passed' : quality.some(item => item[key]?.status === 'failed') ? 'failed' : quality[0]?.[key]?.status ?? 'not-checked';
|
|
107
|
+
const summary = {
|
|
108
|
+
nodes: graphs.reduce((sum, item) => sum + item.nodes.length, 0),
|
|
109
|
+
edges: graphs.reduce((sum, item) => sum + item.edges.length, 0),
|
|
110
|
+
groups: graphs.reduce((sum, item) => sum + (item.groups?.length ?? 0), 0),
|
|
111
|
+
semantic: { status: status('semantic') }, geometry: { status: status('geometry') }, rendering: { status: status('rendering') },
|
|
112
|
+
...(warnings.length ? { warnings: warnings.length } : {})
|
|
113
|
+
};
|
|
114
|
+
const detail = values.verbose ? { layoutComposition: graphs.map(layoutComposition), layout: compiled.map(item => item.report), quality } : {};
|
|
115
|
+
console.log(JSON.stringify(graphs.length === 1 && !Object.hasOwn(graph, 'diagrams')
|
|
116
|
+
? { generated: true, diagramType: diagramTypeOf(graphs[0]), outputDir, files, ...summary, ...detail, sourceEvidence }
|
|
117
|
+
: { generated: true, diagramTypes: graphs.map(diagramTypeOf), diagrams: graphs.length, outputDir, files, ...summary, ...detail, sourceEvidence }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) try {
|
|
121
|
+
await main();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(error.message);
|
|
124
|
+
if (error.phases || error.candidates) console.error(JSON.stringify({ ...error.phases, ...(error.failedViews ? { failedViews: error.failedViews } : {}), diagnostics: error.diagnostics, candidates: error.candidates }));
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { auditGraphLayout, graphBounds } from '../assets/viewer/src/edge-routing.js';
|
|
8
|
+
import { canvasBudgetFor, diagramTypeOf } from '../assets/viewer/src/diagrams/registry.js';
|
|
9
|
+
import { ASPECT_BAND, ASPECT_SLACK, ratioExcess } from '../assets/viewer/src/layout-spacing.js';
|
|
10
|
+
import { graphsOf, reviewComposition, validateGraphInput } from '../assets/viewer/src/graph-validation.js';
|
|
11
|
+
import { requireDiagramQuality, qualityFailure } from '../assets/viewer/src/layout-quality.js';
|
|
12
|
+
import { operandScopes } from '../assets/viewer/src/sequence-fragments.js';
|
|
13
|
+
import { callsMissingExecutions } from '../assets/viewer/src/sequence-executions.js';
|
|
14
|
+
export { DIAGRAM_TYPES, diagramTypeOf } from '../assets/viewer/src/diagrams/registry.js';
|
|
15
|
+
export { graphsOf, reviewComposition, validateGraph, validateGraphInput } from '../assets/viewer/src/graph-validation.js';
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage: node validate-graph.mjs <graph.json> [options]
|
|
18
|
+
--input-only check semantics only (no geometry); use before generating
|
|
19
|
+
--repo-root <dir> verify every node source (file, line range, symbol) against this working tree
|
|
20
|
+
--fix repair mechanical errors in place (sequence order numbering, opt/loop/par operand ids,
|
|
21
|
+
unambiguous replyTo, the callee activation bar of each answered sync call; with --repo-root,
|
|
22
|
+
anchor line re-anchoring to a symbol found once in its file); prints each change; writes back
|
|
23
|
+
only when the graph then passes
|
|
24
|
+
--verbose print the full receipt (layout composition, diagnostics) instead of one summary line
|
|
25
|
+
-h, --help this text
|
|
26
|
+
Success prints one JSON line; failure prints the failing elements with rule, measurement and remediation.
|
|
27
|
+
Composition warnings never fail the run; --input-only prints them in full, later steps only count them in the
|
|
28
|
+
receipt. Fix module.missing, module.inconsistent and flowchart.process-branch; module.single-tone asks whether the
|
|
29
|
+
steps really are one subsystem's work.`;
|
|
30
|
+
|
|
31
|
+
export function readAndValidateGraph(inputPath, options = {}) {
|
|
32
|
+
const absolute = path.resolve(inputPath);
|
|
33
|
+
const graph = JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
34
|
+
const semantic = validateGraphInput(graph, { ...options, inputOnly: true });
|
|
35
|
+
const errors = semantic.length ? semantic : validateGraphInput(graph, options);
|
|
36
|
+
if (errors.length) {
|
|
37
|
+
const phase = semantic.length ? 'semantic' : 'geometry';
|
|
38
|
+
const diagnostics = errors.flatMap(message => {
|
|
39
|
+
const index = message.match(/^diagrams\[(\d+)\]\./)?.[1];
|
|
40
|
+
return qualityFailure(index === undefined ? graph : graph.diagrams[index], phase, message.replace(/^diagrams\[\d+\]\./, '')).diagnostics;
|
|
41
|
+
});
|
|
42
|
+
throw qualityFailure(graph, phase, `Invalid graph:\n- ${errors.join('\n- ')}`, diagnostics);
|
|
43
|
+
}
|
|
44
|
+
if (!options.inputOnly) for (const item of graphsOf(graph)) {
|
|
45
|
+
requireDiagramQuality(item);
|
|
46
|
+
for (const warning of auditGraphLayout(item).warnings) console.warn(`Layout warning: ${warning}`);
|
|
47
|
+
for (const warning of layoutComposition(item).warnings) console.warn(`Composition warning (${diagramTypeOf(item)}): ${warning}`);
|
|
48
|
+
}
|
|
49
|
+
return graph;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Warnings only. The full lines print once per delivery — during input validation, where the author repairs the
|
|
53
|
+
// graph; generation and output validation see the same facts again and carry only the count in their receipt.
|
|
54
|
+
export function printCompositionReview(graph, { print = true } = {}) {
|
|
55
|
+
const warnings = reviewComposition(graph);
|
|
56
|
+
if (print) for (const warning of warnings) console.warn(`Composition warning (${warning.diagramType}) ${warning.ruleId}: ${warning.message} — ${warning.remediation}`);
|
|
57
|
+
return warnings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function layoutComposition(graph) {
|
|
61
|
+
const { width, height } = graphBounds(graph);
|
|
62
|
+
const canvasBudget = canvasBudgetFor(diagramTypeOf(graph));
|
|
63
|
+
const targetRatio = canvasBudget ? canvasBudget.width / canvasBudget.height : null, aspectRatio = width / height;
|
|
64
|
+
const fit = targetRatio === null ? null : Math.min(aspectRatio / targetRatio, targetRatio / aspectRatio);
|
|
65
|
+
const singleRow = diagramTypeOf(graph) !== 'sequence' && graph.nodes.length >= 6
|
|
66
|
+
&& Math.max(...graph.nodes.map(node => node.position.y)) < Math.min(...graph.nodes.map(node => node.position.y + node.size.height));
|
|
67
|
+
const warnings = [];
|
|
68
|
+
if (singleRow) warnings.push('single-row layout: arrange semantic layers, branches or groups across multiple rows');
|
|
69
|
+
// The band is informational here: a small graph may legitimately sit outside it, so it never warns.
|
|
70
|
+
return { diagramType: diagramTypeOf(graph), canvasBudget, aspectRatio: Number(aspectRatio.toFixed(2)), targetRatio: targetRatio === null ? null : Number(targetRatio.toFixed(2)), fit: fit === null ? null : Number(fit.toFixed(2)),
|
|
71
|
+
aspectBand: targetRatio === null ? null : ASPECT_BAND, bandSlack: targetRatio === null ? null : ASPECT_SLACK, withinBand: targetRatio === null ? null : +ratioExcess(aspectRatio).toFixed(2) <= ASPECT_SLACK, singleRow, warnings };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mechanical repairs only: numbering, operand ids, unambiguous reply pairing and the activation bar a paired sync call
|
|
75
|
+
// requires (its anchors follow from the pair). Facts (kinds, evidence, labels, fields, anchors) are never touched and no
|
|
76
|
+
// other element is added; every change is reported so the author can veto it.
|
|
77
|
+
export function applyMechanicalFixes(input) {
|
|
78
|
+
const changes = [], blocked = [];
|
|
79
|
+
for (const [index, graph] of graphsOf(input).entries()) {
|
|
80
|
+
if (graph?.meta?.diagramType !== 'sequence' || !Array.isArray(graph.edges) || !Array.isArray(graph.nodes)) continue;
|
|
81
|
+
const prefix = Object.hasOwn(input, 'diagrams') ? `diagrams[${index}].` : '';
|
|
82
|
+
const edges = graph.edges.filter(edge => edge && typeof edge === 'object');
|
|
83
|
+
// 1. order: renumber in authoring order when any value is missing, invalid or duplicated.
|
|
84
|
+
const orders = edges.map(edge => edge.order);
|
|
85
|
+
const valid = value => Number.isInteger(value) && value > 0;
|
|
86
|
+
if (orders.some(value => !valid(value)) || new Set(orders).size !== orders.length) {
|
|
87
|
+
edges.forEach((edge, i) => { if (edge.order !== i + 1) { changes.push(`${prefix}edge ${edge.id}.order ${JSON.stringify(edge.order)} → ${i + 1}`); edge.order = i + 1; } });
|
|
88
|
+
}
|
|
89
|
+
// 2. operand ids for opt / loop / par.
|
|
90
|
+
for (const group of graph.groups ?? []) {
|
|
91
|
+
if (!group || group.kind === 'alt' || !Array.isArray(group.operands)) continue;
|
|
92
|
+
group.operands.forEach((operand, i) => {
|
|
93
|
+
if (operand && typeof operand === 'object' && operand.id === undefined) { operand.id = `op${i + 1}`; changes.push(`${prefix}group ${group.id}.operands[${i}].id → op${i + 1}`); }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// 3. replyTo: exactly one earlier, unanswered, reversed call in the same operand scope.
|
|
97
|
+
const scopes = operandScopes(graph);
|
|
98
|
+
const answered = new Set(edges.map(edge => edge.replyTo).filter(Boolean));
|
|
99
|
+
for (const edge of edges.filter(edge => edge.kind === 'return' && edge.replyTo === undefined)) {
|
|
100
|
+
const candidates = edges.filter(call => ['sync', 'async'].includes(call.kind) && valid(call.order) && call.order < edge.order && call.source === edge.target && call.target === edge.source && !answered.has(call.id) && (scopes.get(call.id) ?? '') === (scopes.get(edge.id) ?? ''));
|
|
101
|
+
if (candidates.length === 1) { edge.replyTo = candidates[0].id; answered.add(candidates[0].id); changes.push(`${prefix}edge ${edge.id}.replyTo → ${candidates[0].id}`); }
|
|
102
|
+
else blocked.push(`${prefix}edge ${edge.id}.replyTo not filled: ${candidates.length ? `${candidates.length} candidates (${candidates.map(call => call.id).join(', ')})` : 'no unanswered reversed call before it'}`);
|
|
103
|
+
}
|
|
104
|
+
// 4. executions: an answered sync call gets its callee bar (call receive → reply send), nested in the innermost bar of
|
|
105
|
+
// that participant around it. Longer calls go first, so a bar added inside them finds its parent.
|
|
106
|
+
if (edges.length === graph.edges.length && (graph.executions === undefined || Array.isArray(graph.executions) && graph.executions.every(bar => bar && typeof bar === 'object'))) {
|
|
107
|
+
const bars = graph.executions ?? [], byId = new Map(edges.map(edge => [edge.id, edge]));
|
|
108
|
+
const point = anchor => { const edge = byId.get(anchor?.edgeId); return edge ? edge.order * 2 + Number(anchor.at === 'receive' && edge.source === edge.target) : NaN; };
|
|
109
|
+
const missing = callsMissingExecutions(graph).sort((a, b) => (b.reply.order - b.call.order) - (a.reply.order - a.call.order) || a.call.order - b.call.order);
|
|
110
|
+
for (const { call, reply } of missing) {
|
|
111
|
+
let id = `x-${call.id}`;
|
|
112
|
+
for (let n = 2; bars.some(bar => bar.id === id); n++) id = `x-${call.id}-${n}`;
|
|
113
|
+
const bar = { id, participantId: call.target, start: { edgeId: call.id, at: 'receive' }, end: { edgeId: reply.id, at: 'send' } };
|
|
114
|
+
const from = point(bar.start), to = point(bar.end);
|
|
115
|
+
const parent = bars.filter(other => other.participantId === call.target && point(other.start) <= from && to <= point(other.end) && (point(other.start) < from || to < point(other.end)))
|
|
116
|
+
.sort((a, b) => (point(a.end) - point(a.start)) - (point(b.end) - point(b.start)))[0];
|
|
117
|
+
if (parent) bar.parentId = parent.id;
|
|
118
|
+
bars.push(bar);
|
|
119
|
+
changes.push(`${prefix}execution ${id} on ${call.target} from ${call.id} receive to ${reply.id} send${parent ? ` inside ${parent.id}` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
if (bars.length && graph.executions === undefined) graph.executions = bars;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { changes, blocked };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fix in memory, then judge with every check the run makes without --fix (geometry and the quality gate unless
|
|
128
|
+
// --input-only, source evidence under a repository root), and write back only a graph that passes all of them.
|
|
129
|
+
export function fixGraphFile(inputPath, { repoRoot, ...options } = {}) {
|
|
130
|
+
const absolute = path.resolve(inputPath);
|
|
131
|
+
const original = fs.readFileSync(absolute, 'utf8');
|
|
132
|
+
const graph = JSON.parse(original);
|
|
133
|
+
const mechanical = applyMechanicalFixes(graph), anchors = repoRoot === undefined ? { changes: [], blocked: [] } : applyAnchorFixes(graph, repoRoot);
|
|
134
|
+
const changes = [...mechanical.changes, ...anchors.changes], blocked = [...mechanical.blocked, ...anchors.blocked];
|
|
135
|
+
let errors = validateGraphInput(graph, { ...options, inputOnly: true });
|
|
136
|
+
if (!errors.length && !options.inputOnly) errors = validateGraphInput(graph, options);
|
|
137
|
+
if (!errors.length && !options.inputOnly) for (const [index, item] of graphsOf(graph).entries()) {
|
|
138
|
+
const prefix = Object.hasOwn(graph, 'diagrams') ? `diagrams[${index}].` : '';
|
|
139
|
+
try { requireDiagramQuality(item); } catch (error) { errors.push(...error.message.split('\n- ').slice(1).map(message => prefix + message)); }
|
|
140
|
+
}
|
|
141
|
+
if (!errors.length && repoRoot !== undefined) {
|
|
142
|
+
try { verifySourceEvidence(graph, repoRoot); } catch (error) { errors = error.message.split('\n- ').slice(1); }
|
|
143
|
+
}
|
|
144
|
+
if (errors.length) return { changes, blocked, errors, written: false };
|
|
145
|
+
const text = `${JSON.stringify(graph, null, 2)}\n`;
|
|
146
|
+
const written = changes.length > 0 && text !== original;
|
|
147
|
+
if (written) fs.writeFileSync(absolute, text);
|
|
148
|
+
return { changes, blocked, errors: [], written };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Re-anchors a drifted node whose symbol occurs exactly once in its file; the range moves with it and keeps its span.
|
|
152
|
+
// Several matches need a judgement about which one is the definition, so they are only reported.
|
|
153
|
+
export function applyAnchorFixes(input, repoRoot) {
|
|
154
|
+
const changes = [], blocked = [], { read } = sourceReader(repoRoot);
|
|
155
|
+
for (const [index, graph] of graphsOf(input).entries()) {
|
|
156
|
+
const prefix = Object.hasOwn(input, 'diagrams') ? `diagrams[${index}].` : '';
|
|
157
|
+
for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
|
|
158
|
+
const source = node?.source;
|
|
159
|
+
if (typeof source?.symbol !== 'string' || !Number.isInteger(source.lineStart)) continue;
|
|
160
|
+
let lines;
|
|
161
|
+
try { lines = read(source.file); } catch { continue; } // reported by the source evidence check
|
|
162
|
+
const term = symbolTerm(source.symbol), found = term ? symbolLines(lines, term) : [];
|
|
163
|
+
if (found.some(line => line >= source.lineStart && line <= (source.lineEnd ?? source.lineStart))) continue;
|
|
164
|
+
if (found.length !== 1) { blocked.push(`${prefix}node ${node.id}.source not re-anchored: "${term ?? source.symbol}" ${foundAt(found)}`); continue; }
|
|
165
|
+
const lineEnd = source.lineEnd === undefined ? undefined : Math.min(lines.length, found[0] + source.lineEnd - source.lineStart);
|
|
166
|
+
changes.push(`${prefix}node ${node.id}.source.lineStart ${source.lineStart} → ${found[0]}${lineEnd === undefined ? '' : `, lineEnd ${source.lineEnd} → ${lineEnd}`}`);
|
|
167
|
+
source.lineStart = found[0];
|
|
168
|
+
if (lineEnd !== undefined) source.lineEnd = lineEnd;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { changes, blocked };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ponytail: a whole-word text match on the symbol's last segment, not a definition parser. A mention left inside the
|
|
175
|
+
// range (a comment, a call) still passes after the definition moved; add per-language definition rules if that bites.
|
|
176
|
+
const symbolTerm = symbol => symbol.split(/[^\p{L}\p{N}_$]+/u).filter(Boolean).at(-1);
|
|
177
|
+
const symbolLines = (lines, term) => {
|
|
178
|
+
const word = new RegExp(`(?<![\\p{L}\\p{N}_$])${term.replaceAll('$', '\\$')}(?![\\p{L}\\p{N}_$])`, 'u');
|
|
179
|
+
return lines.flatMap((line, index) => word.test(line) ? [index + 1] : []);
|
|
180
|
+
};
|
|
181
|
+
const foundAt = lines => lines.length ? `found at line${lines.length > 1 ? 's' : ''} ${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` and ${lines.length - 5} more` : ''}` : 'not found in the file';
|
|
182
|
+
|
|
183
|
+
// Reads repository files once each: repository-relative paths only, no escape through symlinks, UTF-8 text only.
|
|
184
|
+
function sourceReader(repoRoot) {
|
|
185
|
+
if (typeof repoRoot !== 'string' || !repoRoot.trim()) throw new Error('--repo-root must name a directory');
|
|
186
|
+
const root = fs.realpathSync(repoRoot);
|
|
187
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('--repo-root must name a directory');
|
|
188
|
+
const files = new Map();
|
|
189
|
+
const read = name => {
|
|
190
|
+
if (path.isAbsolute(name) || path.win32.isAbsolute(name) || /[\\\0]/.test(name) || name.split('/').includes('..')) {
|
|
191
|
+
throw new Error('path must be repository-relative without parent traversal');
|
|
192
|
+
}
|
|
193
|
+
const file = fs.realpathSync(path.resolve(root, name)), relative = path.relative(root, file);
|
|
194
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error('path resolves outside --repo-root');
|
|
195
|
+
if (!files.has(file)) {
|
|
196
|
+
if (!fs.statSync(file).isFile()) throw new Error('path must name a regular file');
|
|
197
|
+
const bytes = fs.readFileSync(file);
|
|
198
|
+
if (bytes.includes(0)) throw new Error('source must be a UTF-8 text file');
|
|
199
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
200
|
+
const lines = text ? text.split(/\r\n|\n|\r/) : [];
|
|
201
|
+
if (/[\r\n]$/.test(text)) lines.pop();
|
|
202
|
+
files.set(file, lines);
|
|
203
|
+
}
|
|
204
|
+
return files.get(file);
|
|
205
|
+
};
|
|
206
|
+
return { read, files };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Checks the explicitly selected working tree, not the revision named in sourceRef or the meaning of a claim.
|
|
210
|
+
export function verifySourceEvidence(input, repoRoot) {
|
|
211
|
+
const anchors = graphsOf(input).flatMap((graph, graphIndex) => graph.nodes.flatMap((node, nodeIndex) => node.source
|
|
212
|
+
? [{ source: node.source, label: `diagrams[${graphIndex}].nodes[${nodeIndex}].source` }] : []));
|
|
213
|
+
const summary = { scope: 'working-tree', references: anchors.length, checked: 0, files: 0 };
|
|
214
|
+
if (repoRoot === undefined) {
|
|
215
|
+
if (anchors.length) console.warn('Source evidence not verified: pass --repo-root <repository-directory> to check files, line ranges and symbols.');
|
|
216
|
+
return { ...summary, status: anchors.length ? 'skipped' : 'not-applicable', ...(anchors.length ? { reason: 'repository-root-not-provided' } : {}) };
|
|
217
|
+
}
|
|
218
|
+
const { read, files } = sourceReader(repoRoot), errors = [];
|
|
219
|
+
let symbols = 0;
|
|
220
|
+
for (const { source, label } of anchors) {
|
|
221
|
+
try {
|
|
222
|
+
const lines = read(source.file), end = source.lineEnd ?? source.lineStart;
|
|
223
|
+
if (end > lines.length) throw new Error(`line ${end} exceeds file length (${lines.length} lines)`);
|
|
224
|
+
if (typeof source.symbol !== 'string') continue;
|
|
225
|
+
symbols++;
|
|
226
|
+
const term = symbolTerm(source.symbol);
|
|
227
|
+
if (!term) throw new Error(`symbol ${JSON.stringify(source.symbol)} has no name to check`);
|
|
228
|
+
const found = symbolLines(lines, term);
|
|
229
|
+
if (!found.some(line => line >= source.lineStart && line <= end)) {
|
|
230
|
+
throw new Error(`symbol "${term}" is not in line${end === source.lineStart ? ` ${end}` : `s ${source.lineStart}-${end}`}; ${foundAt(found)}; run --fix to re-anchor a unique match`);
|
|
231
|
+
}
|
|
232
|
+
} catch (error) { errors.push(`${label} (${source.file}): ${error.code === 'ENOENT' ? 'file does not exist' : error.message}`); }
|
|
233
|
+
}
|
|
234
|
+
if (errors.length) throw new Error(`Invalid source evidence:\n- ${errors.join('\n- ')}`);
|
|
235
|
+
return { ...summary, status: anchors.length ? 'passed' : 'not-applicable', checked: anchors.length, files: files.size, symbols };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
239
|
+
try {
|
|
240
|
+
const { positionals, values } = parseArgs({ allowPositionals: true, options: { 'repo-root': { type: 'string' }, 'input-only': { type: 'boolean', default: false }, fix: { type: 'boolean', default: false }, verbose: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false } } });
|
|
241
|
+
if (values.help) { console.log(USAGE); process.exit(0); }
|
|
242
|
+
if (positionals.length !== 1) throw new Error(USAGE);
|
|
243
|
+
if (values.fix) {
|
|
244
|
+
const result = fixGraphFile(positionals[0], { inputOnly: values['input-only'], repoRoot: values['repo-root'] });
|
|
245
|
+
for (const change of result.changes) console.error(`fixed: ${change}`);
|
|
246
|
+
for (const item of result.blocked) console.error(`not fixed: ${item}`);
|
|
247
|
+
if (result.errors.length) throw new Error(`Invalid graph after mechanical fixes (file left unchanged):\n- ${result.errors.join('\n- ')}`);
|
|
248
|
+
const file = path.resolve(positionals[0]), directory = path.dirname(file);
|
|
249
|
+
if (result.written) console.error(`wrote ${file} (${result.changes.length} change${result.changes.length === 1 ? '' : 's'})`);
|
|
250
|
+
// A generated page and its SVGs still embed the old data: regenerate them from the fixed file, keeping the layout.
|
|
251
|
+
if (result.written && fs.existsSync(path.join(directory, 'index.html'))) {
|
|
252
|
+
console.error(`regenerate the page and SVGs: node "${path.join(import.meta.dirname, 'generate-viewer.mjs')}" "${file}" "${directory}" --layout preserve --force${values['repo-root'] === undefined ? '' : ` --repo-root "${path.resolve(values['repo-root'])}"`}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const graph = readAndValidateGraph(positionals[0], { inputOnly: values['input-only'] });
|
|
256
|
+
const sourceEvidence = verifySourceEvidence(graph, values['repo-root']);
|
|
257
|
+
const warnings = printCompositionReview(graph, { print: values['input-only'] });
|
|
258
|
+
const graphs = graphsOf(graph);
|
|
259
|
+
const totals = {
|
|
260
|
+
nodes: graphs.reduce((sum, item) => sum + item.nodes.length, 0),
|
|
261
|
+
edges: graphs.reduce((sum, item) => sum + item.edges.length, 0),
|
|
262
|
+
groups: graphs.reduce((sum, item) => sum + (item.groups?.length ?? 0), 0),
|
|
263
|
+
semantic: { status: 'passed' },
|
|
264
|
+
geometry: { status: values['input-only'] ? 'not-checked' : 'passed' },
|
|
265
|
+
rendering: { status: 'not-checked' },
|
|
266
|
+
...(warnings.length ? { warnings: warnings.length } : {}),
|
|
267
|
+
// Informational diagnostics and composition figures only on request; a passing graph reads as one line.
|
|
268
|
+
...(values.verbose ? { diagnostics: values['input-only'] ? [] : graphs.flatMap(item => requireDiagramQuality(item).diagnostics), layoutComposition: values['input-only'] ? null : graphs.map(layoutComposition) } : {})
|
|
269
|
+
};
|
|
270
|
+
console.log(JSON.stringify(graphs.length === 1 && !Object.hasOwn(graph, 'diagrams')
|
|
271
|
+
? { valid: true, diagramType: diagramTypeOf(graphs[0]), ...totals, sourceEvidence }
|
|
272
|
+
: { valid: true, diagramTypes: graphs.map(diagramTypeOf), diagrams: graphs.length, ...totals, sourceEvidence }));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error(error.message);
|
|
275
|
+
if (error.phases) console.error(JSON.stringify({ ...error.phases, diagnostics: error.diagnostics }));
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
}
|