qgraphflow 0.0.6 → 0.0.7
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.qoder-plugin/plugin.json +1 -1
- package/README.md +119 -70
- package/docs/clients.de.md +15 -24
- package/docs/clients.es.md +15 -24
- package/docs/clients.ja.md +15 -24
- package/docs/clients.md +15 -24
- package/docs/clients.pt.md +15 -24
- package/docs/clients.ru.md +15 -24
- package/docs/clients.zh-CN.md +15 -24
- package/docs/readme/README.de.md +120 -71
- package/docs/readme/README.es.md +120 -71
- package/docs/readme/README.ja.md +120 -71
- package/docs/readme/README.pt.md +120 -71
- package/docs/readme/README.ru.md +120 -71
- package/docs/readme/README.zh-CN.md +106 -59
- package/examples/jeepay/README.md +23 -0
- package/examples/jeepay/capabilities.graph.json +270 -0
- package/examples/jeepay/class.graph.json +237 -0
- package/examples/jeepay/collection.graph.json +3057 -0
- package/examples/jeepay/dataflow.graph.json +212 -0
- package/examples/jeepay/deployment.graph.json +222 -0
- package/examples/jeepay/engineering.graph.json +277 -0
- package/examples/jeepay/er.graph.json +482 -0
- package/examples/jeepay/flowchart.graph.json +312 -0
- package/examples/jeepay/relations.graph.json +289 -0
- package/examples/jeepay/sequence.graph.json +355 -0
- package/examples/jeepay/source.json +95 -0
- package/examples/jeepay/state.graph.json +175 -0
- package/examples/jeepay/usecase.graph.json +222 -0
- package/package.json +14 -3
- package/skills/q-flow/SKILL.md +28 -20
- package/skills/q-flow/agents/openai.yaml +1 -1
- package/skills/q-flow/assets/viewer/package.json +1 -1
- package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
- package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
- package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
- package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
- package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
- package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
- package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
- package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
- package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
- package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
- package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
- package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
- package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
- package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
- package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
- package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
- package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
- package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
- package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
- package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
- package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
- package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
- package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
- package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
- package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
- package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
- package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
- package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
- package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
- package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
- package/skills/q-flow/assets/viewer-dist/index.html +30 -28
- package/skills/q-flow/references/evidence-sources.md +7 -5
- package/skills/q-flow/references/graph-common.md +34 -34
- package/skills/q-flow/references/graph-schema.md +28 -7
- package/skills/q-flow/references/guided-intake.md +51 -71
- package/skills/q-flow/references/layout-routing.md +47 -0
- package/skills/q-flow/references/types/architecture.md +42 -22
- package/skills/q-flow/references/types/class.md +9 -2
- package/skills/q-flow/references/types/dataflow.md +11 -4
- package/skills/q-flow/references/types/deployment.md +11 -3
- package/skills/q-flow/references/types/er.md +8 -1
- package/skills/q-flow/references/types/flowchart.md +12 -5
- package/skills/q-flow/references/types/sequence.md +20 -16
- package/skills/q-flow/references/types/state.md +10 -3
- package/skills/q-flow/references/types/usecase.md +6 -0
- package/skills/q-flow/references/viewer-development.md +37 -24
- package/skills/q-flow/references/visual-contract.md +12 -6
- package/skills/q-flow/scripts/compile-layout.mjs +85 -102
- package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
- package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
- package/skills/q-flow/scripts/validate-graph.mjs +38 -21
- package/examples/order-flow.graph.json +0 -94
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { routeOrthogonal, placeEdgeLabels } from '../assets/viewer/src/orthogonal-routing.js';
|
|
2
|
+
import { refineDiagramLayout, refineWithLegacySeed, layoutMetrics } from '../assets/viewer/src/layout-refinement.js';
|
|
3
|
+
import { isArchitectureOverview } from '../assets/viewer/src/view-identity.js';
|
|
4
|
+
import { overviewSections, fitArchitectureOverview, overviewLayoutReports } from '../assets/viewer/src/architecture-overview.js';
|
|
1
5
|
import { Worker } from 'node:worker_threads';
|
|
2
6
|
import { createHash } from 'node:crypto';
|
|
3
7
|
import { validateGraph, diagramTypeOf } from './validate-graph.mjs';
|
|
@@ -6,11 +10,13 @@ import { stateSymbolX } from '../assets/viewer/src/diagrams/state.js';
|
|
|
6
10
|
import { minimumNodeSize } from '../assets/viewer/src/layout-measure.js';
|
|
7
11
|
import { graphBounds, occupiedBox, visibleEdgeLabel, groupHeadingBoxes, segmentCrossesBox, createEdgeRoutes } from '../assets/viewer/src/edge-routing.js';
|
|
8
12
|
import { groupHeadingLayout, estimateLabelSize } from '../assets/viewer/src/text-layout.js';
|
|
9
|
-
import { ASPECT_SLACK, LAYOUT_LIMITS, LAYOUT_TARGETS, ratioExcess } from '../assets/viewer/src/layout-spacing.js';
|
|
13
|
+
import { ASPECT_SLACK, LAYOUT_LIMITS, LAYOUT_TARGETS, OVERVIEW_AREA, layeredDirections, layeredDown, layoutLimits, layoutTargets, ratioExcess } from '../assets/viewer/src/layout-spacing.js';
|
|
10
14
|
import { auditLayoutQuality, qualityFailure, requireDiagramQuality } from '../assets/viewer/src/layout-quality.js';
|
|
11
15
|
import { compileSequence } from './compile-sequence.mjs';
|
|
16
|
+
import { templateDraft, templateOf } from '../assets/viewer/src/layout-templates.js';
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
import { LAYOUT_VERSION } from '../assets/viewer/src/layout-policy.js';
|
|
19
|
+
export { LAYOUT_VERSION };
|
|
14
20
|
export const CANDIDATE_COUNT = 6;
|
|
15
21
|
// Hang guard for one ELK solve, not a budget for a compile: a slow or busy machine only takes longer.
|
|
16
22
|
export const LAYOUT_TIMEOUT_MS = 30_000;
|
|
@@ -19,21 +25,8 @@ export const LAYOUT_TIMEOUT_MS = 30_000;
|
|
|
19
25
|
// the shape back within the slack win, so the graph's own shape decides between landscape and portrait. Ranked layouts and
|
|
20
26
|
// branching state charts never fold.
|
|
21
27
|
export const FOLD_MAX = 5;
|
|
22
|
-
//
|
|
23
|
-
// a node. When the best candidates still have crossings, a bounded local search moves only the relations that cross: it
|
|
24
|
-
// swaps their ports within a node side, swaps which branch of a decision leaves on which side, and swaps their lanes across
|
|
25
|
-
// fold cuts. A move is kept only when the candidate scores better, so it is deterministic and never worse than the plain
|
|
26
|
-
// result; it stops with no crossings, no gain, or when its evaluation budget is spent. The budget counts evaluations, never
|
|
27
|
-
// time, so a slow or busy machine lays out the same graph the same way; only LAYOUT_TIMEOUT_MS can end a compile, and that
|
|
28
|
-
// is an error, not a different layout.
|
|
29
|
-
export const REFINE_CANDIDATES = 2;
|
|
28
|
+
// Layout and route refinement share a deterministic evaluation budget, not a wall-clock cutoff.
|
|
30
29
|
export const REFINE_EVALUATIONS = 60;
|
|
31
|
-
// Candidates whose width/height ratio is within this factor of the accepted band rank equal on shape, so a crossing is never
|
|
32
|
-
// traded for a slightly better aspect ratio; only a shape beyond it (a long strip) outranks the crossings, and only by a
|
|
33
|
-
// whole SHAPE_STEP, so a strip does not take on crossings for a marginally better ratio.
|
|
34
|
-
export const SHAPE_TIE = 1.3;
|
|
35
|
-
export const SHAPE_STEP = .25;
|
|
36
|
-
export const shapeRank = excess => excess <= SHAPE_TIE ? 1 : Math.ceil(excess / SHAPE_STEP) * SHAPE_STEP;
|
|
37
30
|
const stable = items => [...items].sort((a, b) => (a.layout?.rank ?? 0) - (b.layout?.rank ?? 0) || (a.layout?.order ?? 0) - (b.layout?.order ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
38
31
|
const round = value => +value.toFixed(3);
|
|
39
32
|
const box = item => ({ ...item.position, ...item.size });
|
|
@@ -46,7 +39,8 @@ function semanticDigest(graph, migration = []) {
|
|
|
46
39
|
for (const item of [...model.nodes, ...(model.groups ?? [])]) { delete item.position; delete item.size; }
|
|
47
40
|
for (const edge of model.edges) delete edge.route;
|
|
48
41
|
for (const item of migration) delete [...model.nodes, ...(model.groups ?? [])].find(element => element.id === item.elementId)[item.field];
|
|
49
|
-
|
|
42
|
+
for (const section of overviewSections(model)) { delete section.position; delete section.size; }
|
|
43
|
+
if (model.layout) { delete model.layout.version; delete model.layout.strategy; delete model.layout.direction; if (!Object.keys(model.layout).length) delete model.layout; }
|
|
50
44
|
const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
51
45
|
return createHash('sha256').update(JSON.stringify(canonical(model))).digest('hex');
|
|
52
46
|
}
|
|
@@ -72,9 +66,11 @@ export function migrateOwnership(graph) {
|
|
|
72
66
|
return migrated;
|
|
73
67
|
}
|
|
74
68
|
|
|
69
|
+
const compactArchitecture = graph => diagramTypeOf(graph) === 'architecture' && !graph.groups?.length && !graph.edges.some(edge => edge.source === edge.target) && graph.nodes.some(node => graph.edges.filter(edge => edge.source === node.id).length > 1);
|
|
70
|
+
|
|
75
71
|
function elkInput(graph, candidate, hints = {}) {
|
|
76
|
-
const type = diagramTypeOf(graph), diagram = getDiagram(type), down =
|
|
77
|
-
const spacing =
|
|
72
|
+
const type = diagramTypeOf(graph), diagram = getDiagram(type), down = (hints.direction ?? layeredDirections(type)[0]) === 'down';
|
|
73
|
+
const spacing = layoutTargets(diagram).layerGap + [0, 16, 48][candidate % 3];
|
|
78
74
|
const portGap = candidate < 3 ? 24 : 48;
|
|
79
75
|
const feedback = new Set();
|
|
80
76
|
if (['flowchart', 'state'].includes(type)) {
|
|
@@ -96,7 +92,7 @@ function elkInput(graph, candidate, hints = {}) {
|
|
|
96
92
|
'elk.edgeRouting': 'ORTHOGONAL', 'elk.hierarchyHandling': !graph.edges.length && !graph.groups?.length && !graph.nodes.some(node => node.layout?.rank !== undefined || type === 'state' && ['initial', 'final'].includes(node.kind)) ? 'SEPARATE_CHILDREN' : 'INCLUDE_CHILDREN',
|
|
97
93
|
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.mergeEdges': 'false',
|
|
98
94
|
'elk.layered.feedbackEdges': String(['flowchart', 'state'].includes(type)),
|
|
99
|
-
'elk.spacing.nodeNode': String(
|
|
95
|
+
'elk.spacing.nodeNode': String(layoutTargets(diagram).nodeGap), 'elk.spacing.componentComponent': String(layoutTargets(diagram).nodeGap), 'elk.layered.spacing.nodeNodeBetweenLayers': String(spacing),
|
|
100
96
|
'elk.spacing.portPort': '24',
|
|
101
97
|
'elk.spacing.edgeEdge': String(portGap), 'elk.layered.spacing.edgeEdgeBetweenLayers': String(portGap),
|
|
102
98
|
'elk.spacing.edgeNode': String(Math.max(LAYOUT_TARGETS.edgeNodeGap, diagram.endpointStub ?? LAYOUT_LIMITS.endpoint)), 'elk.layered.spacing.edgeNodeBetweenLayers': String(Math.max(LAYOUT_TARGETS.edgeNodeGap, diagram.endpointStub ?? LAYOUT_LIMITS.endpoint)), 'elk.spacing.edgeLabel': String(LAYOUT_LIMITS.labelGap), 'elk.spacing.labelNode': String(LAYOUT_LIMITS.labelGap),
|
|
@@ -105,10 +101,11 @@ function elkInput(graph, candidate, hints = {}) {
|
|
|
105
101
|
...(diagram.curvedSelfLoops ? { 'elk.spacing.nodeSelfLoop': '44' } : {})
|
|
106
102
|
};
|
|
107
103
|
const root = { id: '$root', layoutOptions: options, children: [], edges: [] };
|
|
104
|
+
const inset = layoutLimits(diagram).groupInset;
|
|
108
105
|
const groups = new Map(stable(graph.groups ?? []).map(group => {
|
|
109
106
|
const heading = groupHeadingLayout({ ...group, size: undefined });
|
|
110
107
|
return [group.id, { id: `g:${group.id}`, children: [], layoutOptions: { ...options,
|
|
111
|
-
'elk.padding': `[top=${heading.height + LAYOUT_LIMITS.groupHeadingGap},left
|
|
108
|
+
'elk.padding': `[top=${heading.height + LAYOUT_LIMITS.groupHeadingGap},left=${inset},bottom=${inset},right=${inset}]`,
|
|
112
109
|
'elk.nodeSize.constraints': 'MINIMUM_SIZE', 'elk.nodeSize.minimum': `(${heading.width + 64},0)` } }];
|
|
113
110
|
}));
|
|
114
111
|
const ordered = items => hints.nodes ? [...items].sort((a, b) => hints.nodes.indexOf(a.id) - hints.nodes.indexOf(b.id)) : stable(items);
|
|
@@ -143,7 +140,7 @@ function elkInput(graph, candidate, hints = {}) {
|
|
|
143
140
|
root.edges.push({ id: `e:${edge.id}`, sources: [ports[0]], targets: [ports[1]],
|
|
144
141
|
layoutOptions: { 'elk.layered.priority.direction': String(feedback.has(edge.id) ? 1 : 100) },
|
|
145
142
|
// Labels sit on their own line: ELK routes the edge through the label and reserves its size in the layer gap.
|
|
146
|
-
labels: label ? [{ text: label, ...size, layoutOptions: { 'elk.edgeLabels.placement': 'CENTER', 'elk.edgeLabels.inline': 'true' } }] : [] });
|
|
143
|
+
labels: label && !(compactArchitecture(graph) && candidate < 3) ? [{ text: label, ...size, layoutOptions: { 'elk.edgeLabels.placement': 'CENTER', 'elk.edgeLabels.inline': 'true' } }] : [] });
|
|
147
144
|
}
|
|
148
145
|
for (const node of nodes.values()) {
|
|
149
146
|
for (const side of ['NORTH', 'EAST', 'SOUTH', 'WEST']) {
|
|
@@ -302,7 +299,7 @@ function foldSegments(graph, layers, breaks, { spacing, portGap, laneOrder }, do
|
|
|
302
299
|
+ Math.max(0, ...groups.filter(child => child.parentId === group.id && spread.get(child.id).size > 1).map(room));
|
|
303
300
|
for (const group of groups.filter(group => spread.get(group.id).size > 1)) {
|
|
304
301
|
const first = extents[Math.min(...spread.get(group.id))], last = extents[Math.max(...spread.get(group.id))];
|
|
305
|
-
first[C] -= room(group); first[cExtent] += room(group); last[cExtent] +=
|
|
302
|
+
first[C] -= room(group); first[cExtent] += room(group); last[cExtent] += layoutLimits(getDiagram(diagramTypeOf(graph))).groupInset;
|
|
306
303
|
}
|
|
307
304
|
// Crossing edges take the channel right after the earlier of their two segments; lanes there are spaced for their labels.
|
|
308
305
|
const crossing = laneOrder ? output.edges.filter(edge => !internal(edge)).sort((a, b) => laneOrder.indexOf(a.id) - laneOrder.indexOf(b.id)) : stable(output.edges.filter(edge => !internal(edge)));
|
|
@@ -324,7 +321,7 @@ function foldSegments(graph, layers, breaks, { spacing, portGap, laneOrder }, do
|
|
|
324
321
|
if (edge.route?.labelAt) edge.route.labelAt = shift(edge.route.labelAt, d);
|
|
325
322
|
}
|
|
326
323
|
// A boundary inside one segment moves with it; a boundary spread over several is rebuilt around its members and children.
|
|
327
|
-
const inset =
|
|
324
|
+
const inset = layoutLimits(getDiagram(diagramTypeOf(graph))).groupInset, done = new Set();
|
|
328
325
|
const place = group => {
|
|
329
326
|
if (done.has(group.id)) return; done.add(group.id);
|
|
330
327
|
const children = groups.filter(child => child.parentId === group.id); children.forEach(place);
|
|
@@ -387,7 +384,7 @@ function foldSegments(graph, layers, breaks, { spacing, portGap, laneOrder }, do
|
|
|
387
384
|
function foldedVariants(graph, options) {
|
|
388
385
|
const type = diagramTypeOf(graph);
|
|
389
386
|
if (graph.nodes.some(node => node.layout?.rank !== undefined) || type === 'state' && !stateChain(graph)) return [];
|
|
390
|
-
const down =
|
|
387
|
+
const down = layeredDown(type, graph.layout), bounds = graphBounds(graph);
|
|
391
388
|
// Cutting shortens the layer sequence: a top-down layout folds only when too tall, a left-to-right one only when too wide.
|
|
392
389
|
if (down ? bounds.width >= bounds.height : bounds.height >= bounds.width) return [];
|
|
393
390
|
const layers = layersOf(graph, down);
|
|
@@ -403,18 +400,10 @@ function foldedVariants(graph, options) {
|
|
|
403
400
|
|
|
404
401
|
function candidateScore(graph, audit, index) {
|
|
405
402
|
const bounds = graphBounds(graph, audit.routes), budget = canvasBudgetFor(diagramTypeOf(graph));
|
|
406
|
-
const
|
|
407
|
-
const length = routes.reduce((sum, route) => sum + route.points.slice(1).reduce((sum, point, i) => sum + Math.hypot(point.x - route.points[i].x, point.y - route.points[i].y), 0), 0);
|
|
408
|
-
const area = graph.nodes.reduce((sum, node) => { const box = occupiedBox(node, diagramTypeOf(graph)); return sum + box.width * box.height; }, 0);
|
|
409
|
-
const count = Math.max(1, routes.length), unit = Math.sqrt(area / graph.nodes.length);
|
|
410
|
-
const crossings = audit.crossings.reduce((sum, item) => sum + item.measured + 2 * item.repeated, 0);
|
|
411
|
-
const bends = routes.reduce((sum, route) => sum + route.points.length - 2, 0);
|
|
412
|
-
// Normalize by content, so a small routing improvement cannot justify unlimited whitespace.
|
|
413
|
-
const cost = bounds.width * bounds.height / area + length / (count * unit) + .25 * bends / count + 4 * crossings / count;
|
|
414
|
-
// Shapes of one rank compete on crossings, then on the exact distance from the band (every in-band shape scores 1), then
|
|
415
|
-
// on compactness; a better rank wins first. The type's budget ratio only breaks ties towards its preferred orientation.
|
|
403
|
+
const { cost, crossings } = layoutMetrics(graph, audit);
|
|
416
404
|
const excess = +aspectExcess(graph, audit.routes).toFixed(2);
|
|
417
|
-
|
|
405
|
+
// The normalized objective is shared with Worker refinement. Aspect and stable candidate ID break ties.
|
|
406
|
+
return [audit.errors.length, cost, budget ? Math.abs(bounds.width / bounds.height - budget.width / budget.height) : excess, index];
|
|
418
407
|
}
|
|
419
408
|
const compare = (a, b) => { for (let i = 0; i < a.score.length; i++) if (a.score[i] !== b.score[i]) return a.score[i] - b.score[i]; return 0; };
|
|
420
409
|
|
|
@@ -438,6 +427,11 @@ export async function compileGraphLayout(input, { layout = 'auto', timeoutMs = L
|
|
|
438
427
|
requireDiagramQuality(graph);
|
|
439
428
|
return { graph, report: { version: LAYOUT_VERSION, mode: layout, migration, semantics: semanticReport(graph) } };
|
|
440
429
|
}
|
|
430
|
+
if (isArchitectureOverview(graph)) {
|
|
431
|
+
let output;
|
|
432
|
+
try { output = fitArchitectureOverview(graph, requireDiagramQuality); } catch (error) { throw error.diagnostics ? error : Object.assign(qualityFailure(graph, 'geometry', error.message), { routingReport: error.routingReport }); }
|
|
433
|
+
return { graph: output, report: { version: LAYOUT_VERSION, mode: layout, template: { structure: templateOf(graph).structure, applied: true, fallback: null }, refinement: overviewLayoutReports.get(output), semantics: semanticReport(output) } };
|
|
434
|
+
}
|
|
441
435
|
const sequence = getDiagram(diagramTypeOf(graph)).sequence;
|
|
442
436
|
const worker = new Worker(new URL('../assets/layout-dist/worker.mjs', import.meta.url), { execArgv: [] });
|
|
443
437
|
let pending, expired = false;
|
|
@@ -448,7 +442,6 @@ export async function compileGraphLayout(input, { layout = 'auto', timeoutMs = L
|
|
|
448
442
|
const timeout = Math.min(LAYOUT_TIMEOUT_MS, Math.max(1, timeoutMs));
|
|
449
443
|
const candidates = [];
|
|
450
444
|
try {
|
|
451
|
-
const extras = new WeakMap();
|
|
452
445
|
// Each solve has its own limit; the compile as a whole has none, so load can slow it but never fail it or change its result.
|
|
453
446
|
const post = root => new Promise((resolve, reject) => {
|
|
454
447
|
const timer = setTimeout(() => { expired = true; fail(new Error(`Layout exceeded ${timeout}ms for ${diagramTypeOf(graph)}`)); worker.terminate(); }, timeout);
|
|
@@ -456,6 +449,20 @@ export async function compileGraphLayout(input, { layout = 'auto', timeoutMs = L
|
|
|
456
449
|
worker.postMessage(root);
|
|
457
450
|
});
|
|
458
451
|
const evaluate = (candidate, index, errors = []) => {
|
|
452
|
+
// New auto layouts obey tiers; preserve mode keeps legacy coordinates and their recorded version.
|
|
453
|
+
if (diagramTypeOf(candidate) === 'deployment') candidate.layout = { ...candidate.layout, version: LAYOUT_VERSION };
|
|
454
|
+
if (!sequence && !isArchitectureOverview(candidate)) {
|
|
455
|
+
candidate.layout = { ...candidate.layout, version: LAYOUT_VERSION };
|
|
456
|
+
try { placeEdgeLabels(candidate); } catch { /* Routing below must supply an inline corridor. */ }
|
|
457
|
+
}
|
|
458
|
+
let routeRefined = false;
|
|
459
|
+
if (!sequence && (compactArchitecture(candidate) || auditLayoutQuality(candidate).errors.length)) {
|
|
460
|
+
try {
|
|
461
|
+
const routed = routeOrthogonal(candidate, { passes: 1, accept: requireDiagramQuality }).graph;
|
|
462
|
+
const checked = auditLayoutQuality(routed), original = auditLayoutQuality(candidate);
|
|
463
|
+
if (!checked.errors.length && (original.errors.length || layoutMetrics(routed, checked).cost < layoutMetrics(candidate, original).cost)) { routeRefined = checked.crossings.reduce((s, c) => s + c.measured, 0) < original.crossings.reduce((s, c) => s + c.measured, 0); candidate = routed; }
|
|
464
|
+
} catch { /* A measured ELK route remains a valid candidate; bounded failures are reported by final refinement. */ }
|
|
465
|
+
}
|
|
459
466
|
let audit = auditLayoutQuality(candidate);
|
|
460
467
|
if (errors.length) audit = { ...audit, errors: [...errors, ...audit.errors] };
|
|
461
468
|
// ELK can put a label at a legal point crossing. Slide only that label along its own nearest segment.
|
|
@@ -477,89 +484,65 @@ export async function compileGraphLayout(input, { layout = 'auto', timeoutMs = L
|
|
|
477
484
|
}
|
|
478
485
|
edge.route.labelAt = best;
|
|
479
486
|
}
|
|
480
|
-
return { graph: candidate, errors: audit.errors, diagnostics: audit.diagnostics, score: candidateScore(candidate, audit, index), crossings: audit.crossings, excess: +aspectExcess(candidate, audit.routes).toFixed(2) };
|
|
487
|
+
return { graph: candidate, ...(routeRefined ? { refined: true } : {}), errors: audit.errors, diagnostics: audit.diagnostics, score: candidateScore(candidate, audit, index), crossings: audit.crossings, excess: +aspectExcess(candidate, audit.routes).toFixed(2) };
|
|
481
488
|
};
|
|
482
489
|
const finish = (index, layered, prepared, hints) => {
|
|
483
490
|
const unfolded = { ...evaluate(layered, index), fold: 0, axis: null };
|
|
484
491
|
// Fold only a shape beyond the band's slack; the unfolded result stays available as the fallback.
|
|
485
|
-
const variants = !sequence && unfolded.excess > ASPECT_SLACK
|
|
486
|
-
? foldedVariants(layered, { spacing:
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
// unfolded result.
|
|
490
|
-
const valid = variants.filter(variant => !variant.errors.length), within = valid.filter(variant => variant.excess <= ASPECT_SLACK);
|
|
491
|
-
const fold = within.length ? within.reduce((a, b) => b.score[2] < a.score[2] ? b : a) : valid.sort((a, b) => a.excess - b.excess || compare(a, b))[0];
|
|
492
|
+
const variants = !sequence && !(['class', 'deployment', 'state'].includes(diagramTypeOf(graph))) && (diagramTypeOf(graph) === 'er' || !templateOf(graph)?.arrange || graph.layout?.direction && !graph.layout?.version || graph.layout?.strategy?.includes('-fold')) && unfolded.excess > ASPECT_SLACK
|
|
493
|
+
? foldedVariants(layered, { spacing: layoutTargets(getDiagram(diagramTypeOf(graph))).layerGap + [0, 16, 48][index % 3], stub: prepared.stub, portGap: prepared.portGap, laneOrder: hints.lanes }).map(variant => ({ ...evaluate(variant.graph, index, variant.errors), fold: variant.count, axis: variant.axis })) : [];
|
|
494
|
+
// Folded and continuous paths share the same objective.
|
|
495
|
+
const fold = variants.filter(variant => !variant.errors.length).sort(compare)[0];
|
|
492
496
|
const chosen = fold && compare(fold, unfolded) < 0 ? fold : unfolded;
|
|
493
|
-
const candidate = { index, ...chosen, folds: variants.map(variant => ({ axis: variant.axis, count: variant.fold, errors: variant.errors, excess: variant.excess, score: variant.score })) };
|
|
494
|
-
extras.set(candidate, { layered, prepared, hints });
|
|
497
|
+
const candidate = { index, ...(hints.direction ? { direction: hints.direction } : {}), ...chosen, folds: variants.map(variant => ({ axis: variant.axis, count: variant.fold, errors: variant.errors, excess: variant.excess, score: variant.score })) };
|
|
495
498
|
return candidate;
|
|
496
499
|
};
|
|
497
500
|
const solve = async (index, hints = {}) => {
|
|
498
501
|
const prepared = sequence ? null : elkInput(graph, index, hints);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const swapped = (list, i, j) => { const next = [...list]; [next[i], next[j]] = [next[j], next[i]]; return next; };
|
|
503
|
-
const refine = async base => {
|
|
504
|
-
let best = base, evaluations = 0;
|
|
505
|
-
const first = extras.get(base);
|
|
506
|
-
let hints = { ports: new Map(first.prepared.buckets), lanes: stable(graph.edges).map(edge => edge.id), sides: new Map(), nodes: graph.nodes.some(node => node.layout?.order !== undefined) ? undefined : stable(graph.nodes).map(node => node.id) };
|
|
507
|
-
if (['flowchart', 'state'].includes(diagramTypeOf(graph))) for (const node of stable(graph.nodes.filter(node => ['decision', 'choice'].includes(node.kind)))) {
|
|
508
|
-
const branches = stable(graph.edges.filter(edge => edge.source === node.id && edge.target !== node.id)).map(edge => edge.id);
|
|
509
|
-
if (branches.length >= 2) hints.sides.set(node.id, branches);
|
|
510
|
-
}
|
|
511
|
-
if (diagramTypeOf(graph) === 'usecase') for (const node of stable(graph.nodes.filter(node => node.kind === 'actor'))) {
|
|
512
|
-
const incident = stable(graph.edges.filter(edge => edge.source === node.id || edge.target === node.id)).map(edge => edge.id);
|
|
513
|
-
if (incident.length >= 2) hints.sides.set(node.id, incident);
|
|
514
|
-
}
|
|
515
|
-
while (best.score[2] && evaluations < REFINE_EVALUATIONS) {
|
|
516
|
-
const here = extras.get(best), crossing = [...new Set(best.crossings.flatMap(item => item.elementIds))].sort();
|
|
517
|
-
const moves = [];
|
|
518
|
-
for (const id of crossing) {
|
|
519
|
-
for (const [portId, role] of here.prepared.portRoles) {
|
|
520
|
-
if (role.edge.id !== id) continue;
|
|
521
|
-
const key = `n:${role.edge[role.role]}:${role.side}`, list = hints.ports.get(key) ?? [], at = list.indexOf(portId);
|
|
522
|
-
for (let other = 0; other < list.length; other++) if (other !== at) moves.push({ rerun: true, hints: { ...hints, ports: new Map(hints.ports).set(key, swapped(list, at, other)) } });
|
|
523
|
-
}
|
|
524
|
-
for (const [node, branches] of hints.sides) {
|
|
525
|
-
const at = branches.indexOf(id);
|
|
526
|
-
if (at >= 0) for (let other = 0; other < branches.length; other++) if (other !== at) moves.push({ rerun: true, hints: { ...hints, sides: new Map(hints.sides).set(node, swapped(branches, at, other)) } });
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
if (hints.nodes) {
|
|
530
|
-
const ends = [...new Set(graph.edges.filter(edge => crossing.includes(edge.id)).flatMap(edge => [edge.source, edge.target]))].sort();
|
|
531
|
-
for (const a of ends) for (const b of ends) if (a < b && graph.nodes.find(node => node.id === a).groupId === graph.nodes.find(node => node.id === b).groupId) moves.push({ rerun: true, hints: { ...hints, nodes: swapped(hints.nodes, hints.nodes.indexOf(a), hints.nodes.indexOf(b)) } });
|
|
532
|
-
}
|
|
533
|
-
if (best.fold) for (const a of crossing) for (const b of crossing) if (a < b) moves.push({ rerun: false, hints: { ...hints, lanes: swapped(hints.lanes, hints.lanes.indexOf(a), hints.lanes.indexOf(b)) } });
|
|
534
|
-
let improved = false;
|
|
535
|
-
for (const move of moves) {
|
|
536
|
-
if (evaluations >= REFINE_EVALUATIONS) break;
|
|
537
|
-
evaluations++;
|
|
538
|
-
let next;
|
|
539
|
-
try { next = move.rerun ? await solve(base.index, move.hints) : finish(base.index, here.layered, here.prepared, move.hints); }
|
|
540
|
-
catch (error) { if (expired) throw error; continue; }
|
|
541
|
-
if (compare(next, best) < 0) { best = Object.assign(next, { refined: true }); hints = move.hints; improved = true; break; }
|
|
542
|
-
}
|
|
543
|
-
if (!improved) break;
|
|
544
|
-
}
|
|
545
|
-
return best;
|
|
502
|
+
const layered = sequence ? compileSequence(graph, index) : applyElk(graph, await post(prepared.root), prepared);
|
|
503
|
+
if (hints.direction) layered.layout = { ...layered.layout, direction: hints.direction };
|
|
504
|
+
return finish(index, layered, prepared, hints);
|
|
546
505
|
};
|
|
547
|
-
|
|
548
|
-
|
|
506
|
+
// A recorded or authored layout.direction pins the direction, so a regenerated view keeps its orientation.
|
|
507
|
+
const directions = sequence ? [undefined] : graph.layout?.direction ? [graph.layout.direction] : layeredDirections(diagramTypeOf(graph));
|
|
508
|
+
for (const direction of directions) for (let index = 0; index < CANDIDATE_COUNT; index++) {
|
|
509
|
+
try { candidates.push(await solve(index, layeredDirections(diagramTypeOf(graph)).length > 1 ? { direction } : {})); }
|
|
549
510
|
catch (error) {
|
|
550
511
|
if (expired) throw error;
|
|
551
512
|
candidates.push({ index, errors: [error.message], diagnostics: qualityFailure(graph, 'geometry', error.message).diagnostics, score: [Infinity, 0, 0, 0, 0, 0, 0, 0, index] });
|
|
552
513
|
}
|
|
553
514
|
}
|
|
554
515
|
candidates.sort(compare);
|
|
555
|
-
|
|
556
|
-
|
|
516
|
+
const template = templateOf(graph), templateAttempts = [], templates = [];
|
|
517
|
+
if (template?.arrange) for (let variant = 0; variant < 4; variant++) {
|
|
518
|
+
const draft = templateDraft(graph, variant);
|
|
519
|
+
if (!draft) break;
|
|
520
|
+
draft.layout = { ...draft.layout, version: LAYOUT_VERSION, strategy: `template-${template.structure}-${variant}` };
|
|
521
|
+
try {
|
|
522
|
+
const routed = routeOrthogonal(draft, { passes: 3, accept: requireDiagramQuality });
|
|
523
|
+
routed.graph.layout.strategy = `template-${template.structure}-${variant}`;
|
|
524
|
+
const candidate = { ...evaluate(routed.graph, variant), index: variant };
|
|
525
|
+
templateAttempts.push({ variant, errors: candidate.errors, cost: layoutMetrics(candidate.graph).cost });
|
|
526
|
+
if (!candidate.errors.length) templates.push(candidate);
|
|
527
|
+
} catch (error) { templateAttempts.push({ variant, errors: [error.message] }); }
|
|
557
528
|
}
|
|
558
|
-
|
|
559
|
-
|
|
529
|
+
templates.sort(compare);
|
|
530
|
+
// All legal layered and template candidates compete on the same normalized objective.
|
|
531
|
+
const layered = candidates[0];
|
|
532
|
+
const requiredTemplate = Boolean(template?.arrange && templateDraft(graph) && ['deployment', 'class', 'state', 'usecase', 'dataflow'].includes(diagramTypeOf(graph)));
|
|
533
|
+
const semanticTemplates = templates.filter(candidate => !candidate.errors.length);
|
|
534
|
+
const preferred = semanticTemplates.find(candidate => layered.errors.length || compare(candidate, layered) < 0) ?? (!layered.errors.length ? null : semanticTemplates[0]);
|
|
535
|
+
if (requiredTemplate && !preferred && layered.errors.length) throw qualityFailure(graph, 'geometry', `Semantic template ${template.structure} could not be routed:\n- ${templateAttempts.flatMap(attempt => attempt.errors).join('\n- ')}`);
|
|
536
|
+
const semanticPreferred = graph.layout?.primaryPath?.length && semanticTemplates.length ? semanticTemplates[0] : preferred;
|
|
537
|
+
const useTemplate = Boolean(semanticPreferred);
|
|
538
|
+
const best = useTemplate ? semanticPreferred : layered;
|
|
560
539
|
if (best.errors.length) throw Object.assign(qualityFailure(graph, 'geometry', `No valid layout candidate for ${diagramTypeOf(graph)}:\n- ${best.errors.join('\n- ')}`, best.diagnostics), { diagnosticGraph: best.graph, candidates: candidates.map(({ graph, ...item }) => item) });
|
|
561
|
-
best.graph.layout = { ...best.graph.layout,
|
|
562
|
-
|
|
540
|
+
best.graph.layout = { ...best.graph.layout, strategy: `${useTemplate ? `template-${template.structure}` : sequence ? 'sequence' : 'layered'}-${best.index}${best.fold ? `-fold${best.fold}${best.axis === 'columns' ? 'c' : ''}` : ''}` };
|
|
541
|
+
const refined = refineWithLegacySeed(best.graph, graph, { global: true, evaluations: REFINE_EVALUATIONS, laneAxis: best.fold ? best.axis === 'columns' ? 'x' : 'y' : null });
|
|
542
|
+
best.graph = refined.graph;
|
|
543
|
+
best.graph.layout = { ...best.graph.layout, version: LAYOUT_VERSION };
|
|
544
|
+
requireDiagramQuality(best.graph);
|
|
545
|
+
return { graph: best.graph, report: { version: LAYOUT_VERSION, mode: layout, template: template && { structure: template.structure, applied: Boolean(useTemplate || sequence), fallback: useTemplate || sequence ? null : templates.length ? 'lower-normalized-cost' : graph.layout?.direction ? 'authored-direction' : 'routing-quality', attempts: templateAttempts }, refinement: refined.report, candidateCount: CANDIDATE_COUNT, timeoutMs: timeout, selected: best.index, ...(best.direction ? { direction: best.direction } : {}), migration, semantics: semanticReport(best.graph), candidates: candidates.map(({ graph, ...item }) => item) } };
|
|
563
546
|
} catch (error) { throw error.phases ? error : qualityFailure(graph, 'geometry', error.message); }
|
|
564
547
|
finally { await worker.terminate(); }
|
|
565
548
|
}
|
|
@@ -2,8 +2,8 @@ import { minimumNodeSize } from '../assets/viewer/src/layout-measure.js';
|
|
|
2
2
|
import { sequenceHeaderHeight } from '../assets/viewer/src/diagrams/sequence.js';
|
|
3
3
|
import { operandId, operandEdges, fragmentDepth, fragmentHeadingLayout } from '../assets/viewer/src/sequence-fragments.js';
|
|
4
4
|
import { createEdgeRoutes } from '../assets/viewer/src/edge-routing.js';
|
|
5
|
-
import { sequenceMessageLabel, sequenceExecutions } from '../assets/viewer/src/sequence-executions.js';
|
|
6
|
-
import {
|
|
5
|
+
import { sequenceMessageLabel, sequenceExecutions, spreadParticipants } from '../assets/viewer/src/sequence-executions.js';
|
|
6
|
+
import { layoutText } from '../assets/viewer/src/text-layout.js';
|
|
7
7
|
import { LAYOUT_LIMITS, LAYOUT_TARGETS } from '../assets/viewer/src/layout-spacing.js';
|
|
8
8
|
|
|
9
9
|
export function compileSequence(input, candidate) {
|
|
@@ -13,7 +13,7 @@ export function compileSequence(input, candidate) {
|
|
|
13
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
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
15
|
const nodes = ordered.map(id => graph.nodes.find(node => node.id === id));
|
|
16
|
-
const spacing =
|
|
16
|
+
const spacing = 32 + candidate * 8, gap = 12 + candidate * 4;
|
|
17
17
|
const guardText = (group, operand) => group.kind === 'par' ? operand.label : `${group.kind === 'loop' ? `${group.loop.min}..${group.loop.max} ` : ''}[${operand.guard}]`;
|
|
18
18
|
const guardLayout = (group, operand) => layoutText(guardText(group, operand), LAYOUT_TARGETS.labelWidth, 14, 20);
|
|
19
19
|
const bodyLayout = operand => layoutText(operand.body, LAYOUT_TARGETS.labelWidth, 16, 24);
|
|
@@ -22,24 +22,7 @@ export function compileSequence(input, candidate) {
|
|
|
22
22
|
node.size = minimumNodeSize(node, 'sequence', graph.meta.locale); node.position = { x, y: 48 };
|
|
23
23
|
x += node.size.width + spacing;
|
|
24
24
|
}
|
|
25
|
-
|
|
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
|
-
}
|
|
25
|
+
spreadParticipants(graph, nodes);
|
|
43
26
|
// Provisional order-preserving times let the shared router measure actual
|
|
44
27
|
// prefixes, activation offsets, wrapped labels and self calls.
|
|
45
28
|
for (const edge of graph.edges) edge.route = { messageY: 200 + edge.order * 100 };
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { viewIdOf } from '../assets/viewer/src/view-identity.js';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { parseArgs } from 'node:util';
|
|
8
|
-
import { diagramTypeOf, graphsOf, printCompositionReview, readAndValidateGraph, verifySourceEvidence, layoutComposition } from './validate-graph.mjs';
|
|
8
|
+
import { isDeepStrictEqual, parseArgs } from 'node:util';
|
|
9
|
+
import { diagramTypeOf, graphsOf, printCompositionReview, printViewReview, readAndValidateGraph, verifySourceEvidence, layoutComposition } from './validate-graph.mjs';
|
|
9
10
|
import { compileGraphLayout } from './compile-layout.mjs';
|
|
10
11
|
import { requireDiagramQuality } from '../assets/viewer/src/layout-quality.js';
|
|
11
12
|
import { pageWithGraph } from '../assets/viewer/src/session-graph.js';
|
|
@@ -38,7 +39,7 @@ export function writeOutputs(outputDir, contents, stale = []) {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
// 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
|
|
42
|
+
// A single failure is rethrown untouched; several are combined, their diagnostics concatenated and candidates and routing reports keyed by view identity.
|
|
42
43
|
export async function compileViews(graphs, compile) {
|
|
43
44
|
const compiled = [], failures = [];
|
|
44
45
|
for (const graph of graphs) {
|
|
@@ -50,8 +51,9 @@ export async function compileViews(graphs, compile) {
|
|
|
50
51
|
throw Object.assign(new Error(failures.map(({ error }) => error.message).join('\n\n')), {
|
|
51
52
|
phases: { semantic: { status: phase === 'semantic' ? 'failed' : 'passed' }, geometry: { status: phase === 'semantic' ? 'not-checked' : 'failed' }, rendering: { status: 'not-checked' } },
|
|
52
53
|
diagnostics: failures.flatMap(({ error }) => error.diagnostics ?? []),
|
|
53
|
-
candidates: Object.fromEntries(failures.filter(({ error }) => error.candidates).map(({ graph, error }) => [
|
|
54
|
-
failedViews: failures.map(({ graph }) =>
|
|
54
|
+
candidates: Object.fromEntries(failures.filter(({ error }) => error.candidates).map(({ graph, error }) => [viewIdOf(graph), error.candidates])),
|
|
55
|
+
failedViews: failures.map(({ graph }) => viewIdOf(graph)),
|
|
56
|
+
routingReports: Object.fromEntries(failures.filter(({ error }) => error.routingReport).map(({ graph, error }) => [viewIdOf(graph), error.routingReport]))
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
return compiled;
|
|
@@ -60,6 +62,7 @@ export async function compileViews(graphs, compile) {
|
|
|
60
62
|
const USAGE = `Usage: node generate-viewer.mjs <graph.json> <output-directory> [options]
|
|
61
63
|
--repo-root <dir> verify every node source (file, line range, symbol) against this working tree
|
|
62
64
|
--layout auto|preserve auto (default) computes positions; preserve keeps authored geometry under the same gate
|
|
65
|
+
--view <view-id> auto-layout only this view; repeat for several; all other views must pass preserve
|
|
63
66
|
--force replace existing index.html / graph.json / diagram*.svg in the output directory (needs approval)
|
|
64
67
|
--verbose print the full receipt (layout candidates, folds, diagnostics) instead of one summary line
|
|
65
68
|
-h, --help this text
|
|
@@ -67,7 +70,7 @@ Writes index.html, graph.json and one SVG per view (diagram.svg, or diagram-<n>-
|
|
|
67
70
|
Success prints one JSON line; failure prints the failing elements with rule, measurement and remediation.`;
|
|
68
71
|
|
|
69
72
|
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 } } });
|
|
73
|
+
const { positionals: positional, values } = parseArgs({ allowPositionals: true, options: { force: { type: 'boolean' }, 'repo-root': { type: 'string' }, layout: { type: 'string', default: 'auto' }, view: { type: 'string', multiple: true }, verbose: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false } } });
|
|
71
74
|
const force = values.force;
|
|
72
75
|
if (values.help) { console.log(USAGE); process.exit(0); }
|
|
73
76
|
if (positional.length !== 2) {
|
|
@@ -79,6 +82,10 @@ async function main() {
|
|
|
79
82
|
const outputDir = path.resolve(outputArg);
|
|
80
83
|
if (outputDir === path.parse(outputDir).root || outputDir === os.homedir()) throw new Error('Refusing broad output directory');
|
|
81
84
|
const input = readAndValidateGraph(inputPath, { inputOnly: true });
|
|
85
|
+
const selected = new Set(values.view ?? []), views = graphsOf(input);
|
|
86
|
+
if (selected.size && values.layout !== 'auto') throw new Error('--view requires --layout auto');
|
|
87
|
+
const unknown = [...selected].filter(id => !views.some(graph => viewIdOf(graph) === id));
|
|
88
|
+
if (unknown.length) throw new Error(`Unknown view: ${unknown.join(', ')}; available views: ${views.map(viewIdOf).join(', ')}`);
|
|
82
89
|
const sourceEvidence = verifySourceEvidence(input, values['repo-root']);
|
|
83
90
|
const warnings = printCompositionReview(input, { print: false }); // already printed by the input validation step
|
|
84
91
|
if (!fs.existsSync(shellPath)) throw new Error(`Viewer shell missing: ${shellPath}`);
|
|
@@ -93,9 +100,11 @@ async function main() {
|
|
|
93
100
|
if (existing.length && !force) throw new Error(`Refusing to overwrite: ${existing.join(', ')}; rerun with --force after approval`);
|
|
94
101
|
const shell = fs.readFileSync(shellPath, 'utf8');
|
|
95
102
|
if (!shell.includes('__CODEGRAPH_FLOW_DATA__')) throw new Error('Viewer shell data marker is missing');
|
|
96
|
-
const compiled = await compileViews(
|
|
103
|
+
const compiled = await compileViews(views, item => compileGraphLayout(item, { layout: selected.size && !selected.has(viewIdOf(item)) ? 'preserve' : values.layout }));
|
|
104
|
+
for (let i = 0; i < views.length; i++) if (selected.size && !selected.has(viewIdOf(views[i])) && !isDeepStrictEqual(compiled[i].graph, views[i])) throw new Error(`Unselected view ${viewIdOf(views[i])} requires migration; update it explicitly before changing this collection`);
|
|
97
105
|
const quality = compiled.map(item => requireDiagramQuality(item.graph));
|
|
98
106
|
const graph = Array.isArray(input.diagrams) ? { ...input, diagrams: compiled.map(item => item.graph) } : compiled[0].graph;
|
|
107
|
+
const oversized = printViewReview(graph, { print: true }); // geometry exists only now; the input review could not see it
|
|
99
108
|
const contents = { 'index.html': pageWithGraph(shell, graph), 'graph.json': `${JSON.stringify(graph, null, 2)}\n` };
|
|
100
109
|
for (const { name, svg } of diagramSvgFiles(graph)) contents[name] = svg;
|
|
101
110
|
writeOutputs(outputDir, contents, svgs.filter(name => !Object.hasOwn(contents, name)));
|
|
@@ -109,7 +118,7 @@ async function main() {
|
|
|
109
118
|
edges: graphs.reduce((sum, item) => sum + item.edges.length, 0),
|
|
110
119
|
groups: graphs.reduce((sum, item) => sum + (item.groups?.length ?? 0), 0),
|
|
111
120
|
semantic: { status: status('semantic') }, geometry: { status: status('geometry') }, rendering: { status: status('rendering') },
|
|
112
|
-
...(warnings.length ? { warnings: warnings.length } : {})
|
|
121
|
+
...(warnings.length + oversized.length ? { warnings: warnings.length + oversized.length } : {})
|
|
113
122
|
};
|
|
114
123
|
const detail = values.verbose ? { layoutComposition: graphs.map(layoutComposition), layout: compiled.map(item => item.report), quality } : {};
|
|
115
124
|
console.log(JSON.stringify(graphs.length === 1 && !Object.hasOwn(graph, 'diagrams')
|
|
@@ -121,6 +130,6 @@ if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process
|
|
|
121
130
|
await main();
|
|
122
131
|
} catch (error) {
|
|
123
132
|
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 }));
|
|
133
|
+
if (error.phases || error.candidates) console.error(JSON.stringify({ ...error.phases, ...(error.failedViews ? { failedViews: error.failedViews } : {}), diagnostics: error.diagnostics, candidates: error.candidates, ...(error.routingReport ? { routingReport: error.routingReport } : {}), ...(error.routingReports ? { routingReports: error.routingReports } : {}) }));
|
|
125
134
|
process.exit(1);
|
|
126
135
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { overviewSections } from '../assets/viewer/src/architecture-overview.js';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -7,7 +8,7 @@ import { parseArgs } from 'node:util';
|
|
|
7
8
|
import { auditGraphLayout, graphBounds } from '../assets/viewer/src/edge-routing.js';
|
|
8
9
|
import { canvasBudgetFor, diagramTypeOf } from '../assets/viewer/src/diagrams/registry.js';
|
|
9
10
|
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 { graphsOf, needsSite, overviewWarnings, reviewComposition, validateGraphInput } from '../assets/viewer/src/graph-validation.js';
|
|
11
12
|
import { requireDiagramQuality, qualityFailure } from '../assets/viewer/src/layout-quality.js';
|
|
12
13
|
import { operandScopes } from '../assets/viewer/src/sequence-fragments.js';
|
|
13
14
|
import { callsMissingExecutions } from '../assets/viewer/src/sequence-executions.js';
|
|
@@ -16,7 +17,7 @@ export { graphsOf, reviewComposition, validateGraph, validateGraphInput } from '
|
|
|
16
17
|
|
|
17
18
|
const USAGE = `Usage: node validate-graph.mjs <graph.json> [options]
|
|
18
19
|
--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
|
+
--repo-root <dir> verify every node source and edge site (file, line range, symbol) against this working tree
|
|
20
21
|
--fix repair mechanical errors in place (sequence order numbering, opt/loop/par operand ids,
|
|
21
22
|
unambiguous replyTo, the callee activation bar of each answered sync call; with --repo-root,
|
|
22
23
|
anchor line re-anchoring to a symbol found once in its file); prints each change; writes back
|
|
@@ -26,7 +27,8 @@ const USAGE = `Usage: node validate-graph.mjs <graph.json> [options]
|
|
|
26
27
|
Success prints one JSON line; failure prints the failing elements with rule, measurement and remediation.
|
|
27
28
|
Composition warnings never fail the run; --input-only prints them in full, later steps only count them in the
|
|
28
29
|
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
|
+
steps really are one subsystem's work. view.oversized (a view needing over 4 screens at the readable zoom) needs the
|
|
31
|
+
laid-out graph: generate-viewer.mjs prints it, output validation only counts it.`;
|
|
30
32
|
|
|
31
33
|
export function readAndValidateGraph(inputPath, options = {}) {
|
|
32
34
|
const absolute = path.resolve(inputPath);
|
|
@@ -51,10 +53,19 @@ export function readAndValidateGraph(inputPath, options = {}) {
|
|
|
51
53
|
|
|
52
54
|
// Warnings only. The full lines print once per delivery — during input validation, where the author repairs the
|
|
53
55
|
// graph; generation and output validation see the same facts again and carry only the count in their receipt.
|
|
56
|
+
const printWarnings = warnings => {
|
|
57
|
+
for (const warning of warnings) console.warn(`Composition warning (${warning.diagramType}) ${warning.ruleId}: ${warning.message} — ${warning.remediation}`);
|
|
58
|
+
return warnings;
|
|
59
|
+
};
|
|
54
60
|
export function printCompositionReview(graph, { print = true } = {}) {
|
|
55
61
|
const warnings = reviewComposition(graph);
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
return print ? printWarnings(warnings) : warnings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The size advisory needs the laid-out graph: generation prints it once, the output check only counts it.
|
|
66
|
+
export function printViewReview(graph, { print = false } = {}) {
|
|
67
|
+
const warnings = overviewWarnings(graph);
|
|
68
|
+
return print ? printWarnings(warnings) : warnings;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export function layoutComposition(graph) {
|
|
@@ -148,24 +159,24 @@ export function fixGraphFile(inputPath, { repoRoot, ...options } = {}) {
|
|
|
148
159
|
return { changes, blocked, errors: [], written };
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
// Re-anchors a drifted node whose symbol occurs exactly once in its file; the range moves with it and keeps its span.
|
|
162
|
+
// Re-anchors a drifted node source or edge site whose symbol occurs exactly once in its file; the range moves with it and keeps its span.
|
|
152
163
|
// Several matches need a judgement about which one is the definition, so they are only reported.
|
|
153
164
|
export function applyAnchorFixes(input, repoRoot) {
|
|
154
165
|
const changes = [], blocked = [], { read } = sourceReader(repoRoot);
|
|
155
166
|
for (const [index, graph] of graphsOf(input).entries()) {
|
|
156
167
|
const prefix = Object.hasOwn(input, 'diagrams') ? `diagrams[${index}].` : '';
|
|
157
|
-
for (const node of Array.isArray(
|
|
158
|
-
const
|
|
159
|
-
if (typeof
|
|
168
|
+
for (const [kind, key, items] of [['node', 'source', graph?.nodes], ['edge', 'site', graph?.edges]]) for (const item of Array.isArray(items) ? items : []) {
|
|
169
|
+
const anchor = item?.[key];
|
|
170
|
+
if (typeof anchor?.symbol !== 'string' || !Number.isInteger(anchor.lineStart)) continue;
|
|
160
171
|
let lines;
|
|
161
|
-
try { lines = read(
|
|
162
|
-
const term = symbolTerm(
|
|
163
|
-
if (found.some(line => line >=
|
|
164
|
-
if (found.length !== 1) { blocked.push(`${prefix}
|
|
165
|
-
const lineEnd =
|
|
166
|
-
changes.push(`${prefix}
|
|
167
|
-
|
|
168
|
-
if (lineEnd !== undefined)
|
|
172
|
+
try { lines = read(anchor.file); } catch { continue; } // reported by the source evidence check
|
|
173
|
+
const term = symbolTerm(anchor.symbol), found = term ? symbolLines(lines, term) : [];
|
|
174
|
+
if (found.some(line => line >= anchor.lineStart && line <= (anchor.lineEnd ?? anchor.lineStart))) continue;
|
|
175
|
+
if (found.length !== 1) { blocked.push(`${prefix}${kind} ${item.id}.${key} not re-anchored: "${term ?? anchor.symbol}" ${foundAt(found)}`); continue; }
|
|
176
|
+
const lineEnd = anchor.lineEnd === undefined ? undefined : Math.min(lines.length, found[0] + anchor.lineEnd - anchor.lineStart);
|
|
177
|
+
changes.push(`${prefix}${kind} ${item.id}.${key}.lineStart ${anchor.lineStart} → ${found[0]}${lineEnd === undefined ? '' : `, lineEnd ${anchor.lineEnd} → ${lineEnd}`}`);
|
|
178
|
+
anchor.lineStart = found[0];
|
|
179
|
+
if (lineEnd !== undefined) anchor.lineEnd = lineEnd;
|
|
169
180
|
}
|
|
170
181
|
}
|
|
171
182
|
return { changes, blocked };
|
|
@@ -207,10 +218,16 @@ function sourceReader(repoRoot) {
|
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
// Checks the explicitly selected working tree, not the revision named in sourceRef or the meaning of a claim.
|
|
221
|
+
// `relations` counts the edges that should record a site, so a delivery can say how much of the diagram is traceable.
|
|
210
222
|
export function verifySourceEvidence(input, repoRoot) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
223
|
+
const graphs = graphsOf(input), edges = graphs.flatMap(graph => graph.edges ?? []);
|
|
224
|
+
const anchors = graphs.flatMap((graph, graphIndex) => [
|
|
225
|
+
...overviewSections(graph).flatMap(section => section.source ? [{ source: section.source, label: `diagrams[${graphIndex}].sections.${section.id}.source` }] : []),
|
|
226
|
+
...graph.nodes.flatMap((node, nodeIndex) => (node.badges ?? []).flatMap((badge, i) => badge.source ? [{ source: badge.source, label: `diagrams[${graphIndex}].nodes[${nodeIndex}].badges[${i}].source` }] : [])),
|
|
227
|
+
...graph.nodes.flatMap((node, nodeIndex) => node.source ? [{ source: node.source, label: `diagrams[${graphIndex}].nodes[${nodeIndex}].source` }] : []),
|
|
228
|
+
...(graph.edges ?? []).flatMap((edge, edgeIndex) => edge.site ? [{ source: edge.site, label: `diagrams[${graphIndex}].edges[${edgeIndex}].site` }] : [])]);
|
|
229
|
+
const summary = { scope: 'working-tree', references: anchors.length, checked: 0, files: 0,
|
|
230
|
+
relations: { sited: edges.filter(edge => needsSite(edge) && edge.site).length, eligible: edges.filter(needsSite).length } };
|
|
214
231
|
if (repoRoot === undefined) {
|
|
215
232
|
if (anchors.length) console.warn('Source evidence not verified: pass --repo-root <repository-directory> to check files, line ranges and symbols.');
|
|
216
233
|
return { ...summary, status: anchors.length ? 'skipped' : 'not-applicable', ...(anchors.length ? { reason: 'repository-root-not-provided' } : {}) };
|
|
@@ -254,7 +271,7 @@ if (process.argv[1] && fs.existsSync(process.argv[1]) && fs.realpathSync(process
|
|
|
254
271
|
}
|
|
255
272
|
const graph = readAndValidateGraph(positionals[0], { inputOnly: values['input-only'] });
|
|
256
273
|
const sourceEvidence = verifySourceEvidence(graph, values['repo-root']);
|
|
257
|
-
const warnings = printCompositionReview(graph, { print: values['input-only'] });
|
|
274
|
+
const warnings = [...printCompositionReview(graph, { print: values['input-only'] }), ...(values['input-only'] ? [] : printViewReview(graph))];
|
|
258
275
|
const graphs = graphsOf(graph);
|
|
259
276
|
const totals = {
|
|
260
277
|
nodes: graphs.reduce((sum, item) => sum + item.nodes.length, 0),
|