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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.qoder-plugin/plugin.json +1 -1
  6. package/README.md +119 -70
  7. package/docs/clients.de.md +15 -24
  8. package/docs/clients.es.md +15 -24
  9. package/docs/clients.ja.md +15 -24
  10. package/docs/clients.md +15 -24
  11. package/docs/clients.pt.md +15 -24
  12. package/docs/clients.ru.md +15 -24
  13. package/docs/clients.zh-CN.md +15 -24
  14. package/docs/readme/README.de.md +120 -71
  15. package/docs/readme/README.es.md +120 -71
  16. package/docs/readme/README.ja.md +120 -71
  17. package/docs/readme/README.pt.md +120 -71
  18. package/docs/readme/README.ru.md +120 -71
  19. package/docs/readme/README.zh-CN.md +106 -59
  20. package/examples/jeepay/README.md +23 -0
  21. package/examples/jeepay/capabilities.graph.json +270 -0
  22. package/examples/jeepay/class.graph.json +237 -0
  23. package/examples/jeepay/collection.graph.json +3057 -0
  24. package/examples/jeepay/dataflow.graph.json +212 -0
  25. package/examples/jeepay/deployment.graph.json +222 -0
  26. package/examples/jeepay/engineering.graph.json +277 -0
  27. package/examples/jeepay/er.graph.json +482 -0
  28. package/examples/jeepay/flowchart.graph.json +312 -0
  29. package/examples/jeepay/relations.graph.json +289 -0
  30. package/examples/jeepay/sequence.graph.json +355 -0
  31. package/examples/jeepay/source.json +95 -0
  32. package/examples/jeepay/state.graph.json +175 -0
  33. package/examples/jeepay/usecase.graph.json +222 -0
  34. package/package.json +14 -3
  35. package/skills/q-flow/SKILL.md +28 -20
  36. package/skills/q-flow/agents/openai.yaml +1 -1
  37. package/skills/q-flow/assets/viewer/package.json +1 -1
  38. package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
  39. package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
  41. package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
  42. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
  43. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
  44. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
  45. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
  46. package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
  47. package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
  48. package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
  49. package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
  50. package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
  51. package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
  52. package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
  53. package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
  54. package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
  55. package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
  56. package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
  57. package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
  58. package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
  59. package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
  60. package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
  61. package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
  62. package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
  63. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
  64. package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
  65. package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
  66. package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
  67. package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
  68. package/skills/q-flow/assets/viewer-dist/index.html +30 -28
  69. package/skills/q-flow/references/evidence-sources.md +7 -5
  70. package/skills/q-flow/references/graph-common.md +34 -34
  71. package/skills/q-flow/references/graph-schema.md +28 -7
  72. package/skills/q-flow/references/guided-intake.md +51 -71
  73. package/skills/q-flow/references/layout-routing.md +47 -0
  74. package/skills/q-flow/references/types/architecture.md +42 -22
  75. package/skills/q-flow/references/types/class.md +9 -2
  76. package/skills/q-flow/references/types/dataflow.md +11 -4
  77. package/skills/q-flow/references/types/deployment.md +11 -3
  78. package/skills/q-flow/references/types/er.md +8 -1
  79. package/skills/q-flow/references/types/flowchart.md +12 -5
  80. package/skills/q-flow/references/types/sequence.md +20 -16
  81. package/skills/q-flow/references/types/state.md +10 -3
  82. package/skills/q-flow/references/types/usecase.md +6 -0
  83. package/skills/q-flow/references/viewer-development.md +37 -24
  84. package/skills/q-flow/references/visual-contract.md +12 -6
  85. package/skills/q-flow/scripts/compile-layout.mjs +85 -102
  86. package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
  87. package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
  88. package/skills/q-flow/scripts/validate-graph.mjs +38 -21
  89. 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
- export const LAYOUT_VERSION = 'adaptive-v2-elkjs-0.11.0';
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
- // Fixed-position ports keep the order their relations were created in, so edge order alone decides which relations cross at
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
- if (model.layout) { delete model.layout.version; delete model.layout.strategy; if (!Object.keys(model.layout).length) delete model.layout; }
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 = !['er', 'deployment', 'dataflow', 'usecase'].includes(type);
77
- const spacing = LAYOUT_TARGETS.layerGap + [0, 16, 48][candidate % 3];
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(LAYOUT_TARGETS.nodeGap), 'elk.spacing.componentComponent': String(LAYOUT_TARGETS.nodeGap), 'elk.layered.spacing.nodeNodeBetweenLayers': String(spacing),
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=32,bottom=32,right=32]`,
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] += LAYOUT_LIMITS.groupInset;
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 = LAYOUT_LIMITS.groupInset, done = new Set();
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 = !['er', 'deployment', 'dataflow', 'usecase'].includes(type), bounds = graphBounds(graph);
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 routes = [...audit.routes.values()];
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
- return [audit.errors.length, shapeRank(excess), crossings, excess, round(cost), budget ? Math.abs(bounds.width / bounds.height - budget.width / budget.height) : 0, index];
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: LAYOUT_TARGETS.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 })) : [];
487
- // Folding exists to fix the shape: among the folds the quality gate accepts within the fold slack of the band, the one
488
- // with the fewest crossings wins, then the fewest segments; otherwise the nearest valid shape competes with the
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
- return finish(index, sequence ? compileSequence(graph, index) : applyElk(graph, await post(prepared.root), prepared), prepared, hints);
500
- };
501
- // Crossing-directed local search over port order, decision branch sides and fold lanes (see REFINE_EVALUATIONS).
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
- for (let index = 0; index < CANDIDATE_COUNT; index++) {
548
- try { candidates.push(await solve(index)); }
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
- if (!sequence) for (const base of candidates.filter(candidate => !candidate.errors.length && candidate.score[2] > 0).slice(0, REFINE_CANDIDATES)) {
556
- candidates[candidates.indexOf(base)] = await refine(base);
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
- candidates.sort(compare);
559
- const best = candidates[0];
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, version: LAYOUT_VERSION, strategy: `${sequence ? 'sequence' : 'layered'}-${best.index}${best.fold ? `-fold${best.fold}${best.axis === 'columns' ? 'c' : ''}` : ''}` };
562
- return { graph: best.graph, report: { version: LAYOUT_VERSION, mode: layout, candidateCount: CANDIDATE_COUNT, timeoutMs: timeout, selected: best.index, migration, semantics: semanticReport(best.graph), candidates: candidates.map(({ graph, ...item }) => item) } };
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 { edgeLabelLayout, layoutText } from '../assets/viewer/src/text-layout.js';
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 = LAYOUT_TARGETS.nodeGap + candidate * 8, gap = LAYOUT_LIMITS.labelGap + candidate * 4;
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
- // 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
- }
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 type.
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 }) => [diagramTypeOf(graph), error.candidates])),
54
- failedViews: failures.map(({ graph }) => diagramTypeOf(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(graphsOf(input), item => compileGraphLayout(item, { layout: values.layout }));
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
- if (print) for (const warning of warnings) console.warn(`Composition warning (${warning.diagramType}) ${warning.ruleId}: ${warning.message} — ${warning.remediation}`);
57
- return warnings;
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(graph?.nodes) ? graph.nodes : []) {
158
- const source = node?.source;
159
- if (typeof source?.symbol !== 'string' || !Number.isInteger(source.lineStart)) continue;
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(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;
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 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 };
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),