qgraphflow 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.claude-plugin/plugin.json +13 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/.cursor-plugin/plugin.json +9 -0
  6. package/.qoder-plugin/plugin.json +9 -0
  7. package/LICENSE +21 -0
  8. package/README.md +262 -0
  9. package/THIRD_PARTY_NOTICES.md +190 -0
  10. package/bin/qgraphflow.mjs +17 -0
  11. package/docs/clients.de.md +83 -0
  12. package/docs/clients.es.md +83 -0
  13. package/docs/clients.ja.md +83 -0
  14. package/docs/clients.md +83 -0
  15. package/docs/clients.pt.md +83 -0
  16. package/docs/clients.ru.md +83 -0
  17. package/docs/clients.zh-CN.md +83 -0
  18. package/docs/readme/README.de.md +262 -0
  19. package/docs/readme/README.es.md +262 -0
  20. package/docs/readme/README.ja.md +262 -0
  21. package/docs/readme/README.pt.md +262 -0
  22. package/docs/readme/README.ru.md +262 -0
  23. package/docs/readme/README.zh-CN.md +264 -0
  24. package/examples/order-flow.graph.json +94 -0
  25. package/package.json +61 -0
  26. package/skills/q-flow/SKILL.md +69 -0
  27. package/skills/q-flow/agents/openai.yaml +5 -0
  28. package/skills/q-flow/assets/layout-dist/ELK-LICENSE.md +264 -0
  29. package/skills/q-flow/assets/layout-dist/worker.mjs +24 -0
  30. package/skills/q-flow/assets/viewer/package.json +22 -0
  31. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +43 -0
  32. package/skills/q-flow/assets/viewer/src/diagrams/card.js +21 -0
  33. package/skills/q-flow/assets/viewer/src/diagrams/class.js +52 -0
  34. package/skills/q-flow/assets/viewer/src/diagrams/dataflow.js +19 -0
  35. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +41 -0
  36. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +174 -0
  37. package/skills/q-flow/assets/viewer/src/diagrams/er.js +34 -0
  38. package/skills/q-flow/assets/viewer/src/diagrams/flowchart.js +37 -0
  39. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +28 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +38 -0
  41. package/skills/q-flow/assets/viewer/src/diagrams/state.js +91 -0
  42. package/skills/q-flow/assets/viewer/src/diagrams/usecase.js +28 -0
  43. package/skills/q-flow/assets/viewer/src/edge-routing.js +596 -0
  44. package/skills/q-flow/assets/viewer/src/export-svg.js +90 -0
  45. package/skills/q-flow/assets/viewer/src/graph-validation.js +286 -0
  46. package/skills/q-flow/assets/viewer/src/i18n-messages.json +1314 -0
  47. package/skills/q-flow/assets/viewer/src/i18n.js +14 -0
  48. package/skills/q-flow/assets/viewer/src/layout-measure.js +55 -0
  49. package/skills/q-flow/assets/viewer/src/layout-quality.js +164 -0
  50. package/skills/q-flow/assets/viewer/src/layout-spacing.js +12 -0
  51. package/skills/q-flow/assets/viewer/src/node-svg.js +28 -0
  52. package/skills/q-flow/assets/viewer/src/radix-colors.js +47 -0
  53. package/skills/q-flow/assets/viewer/src/sequence-executions.js +140 -0
  54. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +208 -0
  55. package/skills/q-flow/assets/viewer/src/session-graph.js +43 -0
  56. package/skills/q-flow/assets/viewer/src/text-layout.js +126 -0
  57. package/skills/q-flow/assets/viewer/src/visual-style.js +158 -0
  58. package/skills/q-flow/assets/viewer-dist/index.html +291 -0
  59. package/skills/q-flow/references/acceptance.md +11 -0
  60. package/skills/q-flow/references/evidence-sources.md +38 -0
  61. package/skills/q-flow/references/graph-common.md +54 -0
  62. package/skills/q-flow/references/graph-schema.md +214 -0
  63. package/skills/q-flow/references/guided-intake.md +100 -0
  64. package/skills/q-flow/references/types/architecture.md +41 -0
  65. package/skills/q-flow/references/types/class.md +40 -0
  66. package/skills/q-flow/references/types/dataflow.md +41 -0
  67. package/skills/q-flow/references/types/deployment.md +37 -0
  68. package/skills/q-flow/references/types/er.md +36 -0
  69. package/skills/q-flow/references/types/flowchart.md +47 -0
  70. package/skills/q-flow/references/types/sequence.md +74 -0
  71. package/skills/q-flow/references/types/state.md +44 -0
  72. package/skills/q-flow/references/types/usecase.md +39 -0
  73. package/skills/q-flow/references/viewer-development.md +258 -0
  74. package/skills/q-flow/references/visual-contract.md +54 -0
  75. package/skills/q-flow/scripts/compile-layout.mjs +565 -0
  76. package/skills/q-flow/scripts/compile-sequence.mjs +112 -0
  77. package/skills/q-flow/scripts/generate-viewer.mjs +126 -0
  78. package/skills/q-flow/scripts/validate-graph.mjs +278 -0
@@ -0,0 +1,90 @@
1
+ import { createEdgeRoutes, graphBounds, pathFromRoute, cardinalityMarks } from './edge-routing.js';
2
+ import { layoutText } from './text-layout.js';
3
+ import { DIAGRAM_TYPES, diagramTypeOf, getDiagram, edgeMarkers, isDashed, labelRoleColors } from './diagrams/registry.js';
4
+ import { renderNode } from './node-svg.js';
5
+ import { text, fit, escapeXml, svgStyles, groupHeadingSvg, groupFrameSvg } from './diagrams/drawing.js';
6
+ import { PALETTES, dataKinds, edgeColor, sequenceGroupColor, moduleColorMap, groupAppearanceMap, TYPOGRAPHY } from './visual-style.js';
7
+ import { RADIX_COLORS_NOTICE } from './radix-colors.js';
8
+ import { sequencePairs, sequenceExecutions } from './sequence-executions.js';
9
+ import { sequenceFragment, renderFragment, fragmentDepth, fragmentSurfaceAt } from './sequence-fragments.js';
10
+ import { requireDiagramQuality } from './layout-quality.js';
11
+
12
+ function edgeMarker(edge, type, target, moduleColors, source, pair) {
13
+ const { end } = edgeMarkers(edge, type);
14
+ if (!end) return '';
15
+ if (end !== 'arrow') return ` marker-end="url(#${end})"`;
16
+ const marker = pair ? 'arrow-module' : edge.kind === 'failure' ? 'arrow-warn' : edge.kind === 'success' ? 'arrow-ok' : moduleColors?.has(edge.module ?? source?.module ?? target?.module) ? 'arrow-module' : dataKinds.has(target?.kind) ? 'arrow-data' : 'arrow';
17
+ return ` marker-end="url(#${marker})"`;
18
+ }
19
+
20
+ function edgeStartMarker(edge, type) {
21
+ const { start } = edgeMarkers(edge, type);
22
+ return start ? ` marker-start="url(#${start})"` : '';
23
+ }
24
+
25
+ function renderGroup(group, offsetX, offsetY, fragment, appearance) {
26
+ const x = group.position.x + offsetX;
27
+ const y = group.position.y + offsetY;
28
+ const label = fit(group.label, fragment?.heading?.width ?? group.size.width - (['alt', 'opt', 'loop', 'par'].includes(group.kind) ? 88 : 32));
29
+ return `<g data-diagram-group-id="${escapeXml(group.id)}"><title>${escapeXml(group.label)}</title>${groupFrameSvg(group, appearance, x, y)}${fragment ? (fragment.heading?.lines ?? [label]).map((line, i) => text(fragment.heading ? fragment.heading.x + offsetX : x + 16, y + 23 + i * 22, line, 'group')).join('') : groupHeadingSvg(group, x, y)}${['alt', 'opt', 'loop', 'par'].includes(group.kind) ? text(x + group.size.width - 16, y + 25, group.kind, 'group-kind', ' text-anchor="end"') : ''}</g>`;
30
+ }
31
+
32
+ function renderEdge(edge, route, type, offsetX, offsetY, palette, target, moduleColors, source, pair, labelSurface) {
33
+ const stroke = edgeColor(edge, target, palette, moduleColors, source, pair);
34
+ const dash = isDashed(edge, type) ? ' stroke-dasharray="7 6"' : '';
35
+ const label = route.label;
36
+ const labelX = route.labelPoint.x + offsetX;
37
+ const labelY = route.labelPoint.y + offsetY;
38
+ const labelSize = route.labelBox;
39
+ const pairLine = pair ? `<path d="M${labelX - layoutText(route.labelLines[0], Infinity).width / 2} ${labelY - labelSize.height / 2 + 3 + TYPOGRAPHY.body + 3}h${layoutText(pair.label, Infinity).width}" stroke="${stroke}" stroke-width="2"/>` : '';
40
+ const background = label ? `<rect x="${labelX - labelSize.width / 2}" y="${labelY - labelSize.height / 2}" width="${labelSize.width}" height="${labelSize.height}" rx="4" class="edge-bg"${labelSurface ? ` style="fill:${labelSurface}"` : ''}/>` : '';
41
+ const cardinalities = getDiagram(type).cardinalities ? [
42
+ cardinalityMarks(edge.sourceCardinality, route.points[0], route.points[1]),
43
+ cardinalityMarks(edge.targetCardinality, route.points.at(-1), route.points.at(-2))
44
+ ].map((mark, index) => `<g data-cardinality-endpoint="${index ? 'target' : 'source'}" transform="translate(${offsetX} ${offsetY})" fill="none" stroke="${palette.accent}" stroke-width="1.5"><path d="${mark.path}"/>${mark.circle ? `<circle cx="${mark.circle.cx}" cy="${mark.circle.cy}" r="${mark.circle.r}" fill="${palette.surface}"/>` : ''}</g>`).join('') : '';
45
+ const multiplicities = (route.endpointLabels ?? []).map(item => `<g class="edge-multiplicity" data-endpoint="${item.role}"><rect x="${item.labelBox.x + offsetX}" y="${item.labelBox.y + offsetY}" width="${item.labelBox.width}" height="${item.labelBox.height}" rx="4" class="edge-bg"/>${text(item.labelPoint.x + offsetX, item.labelBox.y + offsetY + 3 + TYPOGRAPHY.body, item.label, 'edge', ' text-anchor="middle"')}</g>`).join('');
46
+ return `<g data-diagram-edge-id="${escapeXml(edge.id)}"><path d="${pathFromRoute(route, offsetX, offsetY)}" fill="none" stroke="${stroke}" stroke-width="1.5"${dash}${edgeMarker(edge, type, target, moduleColors, source, pair)}${edgeStartMarker(edge, type)}/>${cardinalities}${background}${pairLine}${label ? route.labelLines.map((line, index) => text(labelX, labelY - labelSize.height / 2 + 3 + TYPOGRAPHY.body + index * TYPOGRAPHY.edgeLineHeight, route.labelRuns ? { runs: route.labelRuns[index], colors: labelRoleColors(type, palette) } : line, 'edge', ' text-anchor="middle"')).join('') : ''}${multiplicities}</g>`;
47
+ }
48
+
49
+ export function createDiagramSvg(graph, theme = 'light', moduleColors) {
50
+ requireDiagramQuality(graph);
51
+ const palette = PALETTES[theme] ?? PALETTES.light;
52
+ moduleColors ??= moduleColorMap([graph], palette);
53
+ const groupAppearances = groupAppearanceMap(graph.groups ?? [], palette);
54
+ const type = diagramTypeOf(graph);
55
+ const routes = createEdgeRoutes(graph);
56
+ const pairs = sequencePairs(graph), executions = sequenceExecutions(graph);
57
+ const nodeById = new Map(graph.nodes.map(node => [node.id, node]));
58
+ const bounds = graphBounds(graph, routes);
59
+ const margin = 64;
60
+ const width = bounds.width + margin * 2;
61
+ const titleLayout = layoutText(graph.meta.title, width - margin * 2, 24);
62
+ const subtitleLayout = layoutText(graph.meta.subtitle ?? '', width - margin * 2, TYPOGRAPHY.small);
63
+ const subtitleY = 66 + Math.max(0, titleLayout.lines.length - 1) * titleLayout.lineHeight;
64
+ const header = Math.max(88, subtitleLayout.lines.length ? subtitleY + (subtitleLayout.lines.length - 1) * subtitleLayout.lineHeight + 22 : 43 + Math.max(0, titleLayout.lines.length - 1) * titleLayout.lineHeight + 24);
65
+ const height = bounds.height + margin * 2 + header;
66
+ const offsetX = margin - bounds.x;
67
+ const offsetY = margin + header - bounds.y;
68
+ const fragments = new Map((graph.groups ?? []).map(group => [group.id, type === 'sequence' ? sequenceFragment(group, routes, graph.meta.locale, graph.groups ?? [], executions) : null]));
69
+ const groups = [...(graph.groups ?? [])].sort((a, b) => fragmentDepth(a, graph.groups ?? []) - fragmentDepth(b, graph.groups ?? [])).map(group => renderGroup(group, offsetX, offsetY, fragments.get(group.id), groupAppearances.get(group.id)) + (type === 'sequence' ? renderFragment({ ...fragments.get(group.id), guards: [], bodies: [] }, group, offsetX, offsetY, palette.ink3, groupAppearances.get(group.id).fill) : '')).join('');
70
+ const fragmentText = type === 'sequence' ? (graph.groups ?? []).map(group => renderFragment({ ...fragments.get(group.id), separators: [] }, group, offsetX, offsetY, palette.ink3, groupAppearances.get(group.id).fill)).join('') : '';
71
+
72
+ const edges = graph.edges.map(edge => renderEdge(edge, routes.get(edge.id), type, offsetX, offsetY, palette, nodeById.get(edge.target), moduleColors, nodeById.get(edge.source), pairs.get(edge.id), type === 'sequence' && routes.get(edge.id).label ? fragmentSurfaceAt(routes.get(edge.id).labelBox, graph.groups ?? [], groupAppearances) : undefined)).join('');
73
+ const nodes = graph.nodes.map(node => renderNode({ ...node, executionRects: executions.filter(item => item.participantId === node.id).map(item => ({ ...item, x: item.x - node.position.x, y: item.y - node.position.y, color: sequenceGroupColor(pairs.get(item.start.edgeId), palette) ?? palette.edge })) }, type, offsetX, offsetY, palette, graph.meta.locale, moduleColors)).join('');
74
+ const boardX = 24;
75
+ const boardY = header;
76
+ const boardWidth = width - 48;
77
+ const boardHeight = height - header - 24;
78
+
79
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!-- Radix Colors\n${RADIX_COLORS_NOTICE}-->\n<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" data-graph-offset-x="${offsetX}" data-graph-offset-y="${offsetY}" role="img" aria-labelledby="title desc"><title id="title">${escapeXml(graph.meta.title)}</title><desc id="desc">${escapeXml(type)} diagram for ${escapeXml(graph.meta.sourceRef)}</desc><defs><filter id="node-shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="5" stdDeviation="7" flood-color="${palette.ink}" flood-opacity=".045"/></filter><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.edge}"/></marker><marker id="arrow-module" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="context-stroke"/></marker><marker id="arrow-warn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.warn}"/></marker><marker id="arrow-ok" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.accent}"/></marker><marker id="arrow-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10Z" fill="${palette.data}"/></marker><marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M1 1L9 5L1 9" fill="none" stroke="context-stroke" stroke-width="1.5"/></marker><marker id="triangle" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto"><path d="M1 1L11 6L1 11Z" fill="${palette.surface}" stroke="context-stroke"/></marker><marker id="diamond-filled" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="context-stroke"/></marker><marker id="diamond-open" viewBox="0 0 14 10" refX="1" refY="5" markerWidth="12" markerHeight="10" orient="auto"><path d="M1 5L7 1L13 5L7 9Z" fill="${palette.surface}" stroke="context-stroke"/></marker><style>${svgStyles(palette)}</style></defs><rect width="100%" height="100%" fill="${palette.paper}"/><rect x="${boardX}" y="${boardY}" width="${boardWidth}" height="${boardHeight}" rx="16" fill="${palette.surface}" stroke="${palette.rule}"/>${titleLayout.lines.map((line, index) => text(margin, 43 + index * titleLayout.lineHeight, line, 'heading')).join('')}${subtitleLayout.lines.map((line, index) => text(margin, subtitleY + index * subtitleLayout.lineHeight, line, 'meta')).join('')}${groups}${type === 'sequence' ? nodes + edges : edges + nodes}${fragmentText}</svg>\n`;
80
+ }
81
+
82
+ // The generator and the Viewer's in-place save both write these files, so names and bytes come from one place: light
83
+ // theme whatever the screen shows, and one module colour map shared by every view, as the Viewer computes it.
84
+ export const SVG_FILE = new RegExp(`^diagram(-\\d+-(${DIAGRAM_TYPES.join('|')}))?\\.svg$`);
85
+
86
+ export function diagramSvgFiles(input) {
87
+ const collection = Array.isArray(input.diagrams), views = collection ? input.diagrams : [input];
88
+ const moduleColors = moduleColorMap(views, PALETTES.light);
89
+ return views.map((view, index) => ({ name: collection ? `diagram-${index + 1}-${diagramTypeOf(view)}.svg` : 'diagram.svg', svg: createDiagramSvg(view, 'light', moduleColors) }));
90
+ }
@@ -0,0 +1,286 @@
1
+ import { SUPPORTED_LOCALES } from './i18n.js';
2
+ import { auditGraphLayout } from './edge-routing.js';
3
+ import { missingCallExecutions, validateExecutions } from './sequence-executions.js';
4
+ import { validateOperands } from './sequence-fragments.js';
5
+ import { DIAGRAM_TYPES, diagramTypeOf, getDiagram } from './diagrams/registry.js';
6
+
7
+ const EVIDENCE_KINDS = new Set(['source', 'code', 'config', 'schema', 'test', 'document', 'framework', 'inference']);
8
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+
10
+ function requireString(value, label, errors) {
11
+ if (typeof value !== 'string' || value.trim() === '') errors.push(`${label} must be a non-empty string`);
12
+ }
13
+
14
+ function optionalString(value, label, errors) {
15
+ if (value !== undefined && typeof value !== 'string') errors.push(`${label} must be a string`);
16
+ }
17
+
18
+ function requireBox(item, label, errors, inputOnly = false) {
19
+ for (const [group, keys] of [['position', ['x', 'y']], ['size', ['width', 'height']]]) {
20
+ if (inputOnly && item[group] === undefined) continue;
21
+ if (!isObject(item[group])) {
22
+ errors.push(`${label}.${group} is required`);
23
+ continue;
24
+ }
25
+ for (const key of keys) {
26
+ const value = item[group][key];
27
+ if (!Number.isFinite(value) || value < 0) errors.push(`${label}.${group}.${key} must be a non-negative finite number`);
28
+ }
29
+ }
30
+ }
31
+
32
+ function requirePoint(point, label, errors) {
33
+ if (!point || typeof point !== 'object' || Array.isArray(point)) {
34
+ errors.push(`${label} must be an object`);
35
+ return;
36
+ }
37
+ for (const key of ['x', 'y']) {
38
+ if (!Number.isFinite(point[key]) || point[key] < 0) errors.push(`${label}.${key} must be a non-negative finite number`);
39
+ }
40
+ }
41
+
42
+ function validateStringArray(value, label, errors) {
43
+ if (value === undefined) return;
44
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) {
45
+ errors.push(`${label} must be an array of non-empty strings`);
46
+ }
47
+ }
48
+
49
+ export function graphsOf(input) {
50
+ return input && typeof input === 'object' && !Array.isArray(input) && Array.isArray(input.diagrams)
51
+ ? input.diagrams
52
+ : [input];
53
+ }
54
+
55
+ export function validateGraph(graph, { inputOnly = false, audit = true } = {}) {
56
+ const errors = [];
57
+ if (!isObject(graph)) return ['graph must be an object'];
58
+
59
+ if (!isObject(graph.meta)) errors.push('meta must be an object');
60
+ requireString(graph.meta?.title, 'meta.title', errors);
61
+ requireString(graph.meta?.sourceRef, 'meta.sourceRef', errors);
62
+ for (const key of ['subtitle', 'scope']) optionalString(graph.meta?.[key], `meta.${key}`, errors);
63
+ if (graph.meta?.locale !== undefined && !SUPPORTED_LOCALES.includes(graph.meta.locale)) errors.push('meta.locale is unsupported');
64
+ const diagramType = diagramTypeOf(graph);
65
+ if (!DIAGRAM_TYPES.includes(diagramType)) errors.push('meta.diagramType is unsupported');
66
+ const rules = getDiagram(diagramType) ?? getDiagram('architecture');
67
+ if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) errors.push('nodes must be a non-empty array');
68
+ if (!Array.isArray(graph.edges)) errors.push('edges must be an array');
69
+ if (graph.groups !== undefined && !Array.isArray(graph.groups)) errors.push('groups must be an array when provided');
70
+ if (errors.length) return errors;
71
+
72
+ const ids = new Set();
73
+ const nodeIds = new Set();
74
+ for (const [index, node] of (graph.nodes ?? []).entries()) {
75
+ const label = `nodes[${index}]`;
76
+ if (!isObject(node)) { errors.push(`${label} must be an object`); continue; }
77
+ requireString(node?.id, `${label}.id`, errors);
78
+ requireString(node?.label, `${label}.label`, errors);
79
+ optionalString(node.subtitle, `${label}.subtitle`, errors);
80
+ if (node.module !== undefined) requireString(node.module, `${label}.module`, errors);
81
+ if (!rules.nodeKinds.includes(node?.kind)) errors.push(`${label}.kind is unsupported for ${diagramType}`);
82
+ requireBox(node ?? {}, label, errors, inputOnly);
83
+ if (typeof node.id === 'string' && ids.has(node.id)) errors.push(`${label}.id duplicates ${node.id}`);
84
+ ids.add(node?.id);
85
+ nodeIds.add(node?.id);
86
+ if (node.source !== undefined && !isObject(node.source)) errors.push(`${label}.source must be an object`);
87
+ else if (node.source) {
88
+ requireString(node.source.file, `${label}.source.file`, errors);
89
+ optionalString(node.source.symbol, `${label}.source.symbol`, errors);
90
+ if (node.source.kind !== undefined && !EVIDENCE_KINDS.has(node.source.kind)) errors.push(`${label}.source.kind is unsupported`);
91
+ if (!Number.isInteger(node.source.lineStart) || node.source.lineStart < 1) errors.push(`${label}.source.lineStart must be a positive integer`);
92
+ if (node.source.lineEnd !== undefined && (!Number.isInteger(node.source.lineEnd) || (Number.isInteger(node.source.lineStart) && node.source.lineEnd < node.source.lineStart))) {
93
+ errors.push(`${label}.source.lineEnd must be an integer at or after lineStart`);
94
+ }
95
+ }
96
+ for (const key of ['facts', 'tags', 'attributes', 'methods']) validateStringArray(node[key], `${label}.${key}`, errors);
97
+ if (node.fields !== undefined) {
98
+ if (!Array.isArray(node.fields)) errors.push(`${label}.fields must be an array`);
99
+ else for (const [index, field] of node.fields.entries()) {
100
+ const prefix = `${label}.fields[${index}]`;
101
+ if (!isObject(field)) { errors.push(`${prefix} must be an object`); continue; }
102
+ requireString(field.name, `${prefix}.name`, errors);
103
+ requireString(field.type, `${prefix}.type`, errors);
104
+ if (field.key !== undefined && !['PK', 'FK', 'UK'].includes(field.key)) errors.push(`${prefix}.key is unsupported`);
105
+ if (field.nullable !== undefined && typeof field.nullable !== 'boolean') errors.push(`${prefix}.nullable must be boolean`);
106
+ }
107
+ }
108
+ rules.validateNode?.(node, label, errors, { requireString, validateStringArray });
109
+ }
110
+
111
+ for (const [index, group] of (graph.groups ?? []).entries()) {
112
+ const label = `groups[${index}]`;
113
+ if (!isObject(group)) { errors.push(`${label} must be an object`); continue; }
114
+ requireString(group?.id, `${label}.id`, errors);
115
+ requireString(group?.label, `${label}.label`, errors);
116
+ if (!rules.groupKinds.includes(group?.kind)) errors.push(`${label}.kind is unsupported for ${diagramType}`);
117
+ requireBox(group ?? {}, label, errors, inputOnly);
118
+ if (typeof group.id === 'string' && ids.has(group.id)) errors.push(`${label}.id duplicates ${group.id}`);
119
+ ids.add(group?.id);
120
+ }
121
+
122
+ const edgeIds = new Set();
123
+ const sequenceOrders = new Set();
124
+ for (const [index, edge] of (graph.edges ?? []).entries()) {
125
+ const label = `edges[${index}]`;
126
+ if (!isObject(edge)) { errors.push(`${label} must be an object`); continue; }
127
+ requireString(edge?.id, `${label}.id`, errors);
128
+ optionalString(edge.label, `${label}.label`, errors);
129
+ if (edge.module !== undefined) requireString(edge.module, `${label}.module`, errors);
130
+ requireString(edge?.source, `${label}.source`, errors);
131
+ requireString(edge?.target, `${label}.target`, errors);
132
+ if (!rules.edgeKinds.includes(edge?.kind)) errors.push(`${label}.kind is unsupported for ${diagramType}`);
133
+ if (!EVIDENCE_KINDS.has(edge?.evidence)) errors.push(`${label}.evidence is unsupported`);
134
+ if (typeof edge.id === 'string' && edgeIds.has(edge.id)) errors.push(`${label}.id duplicates ${edge.id}`);
135
+ edgeIds.add(edge?.id);
136
+ if (typeof edge.source === 'string' && !nodeIds.has(edge.source)) errors.push(`${label}.source does not name a node: ${edge.source}`);
137
+ if (typeof edge.target === 'string' && !nodeIds.has(edge.target)) errors.push(`${label}.target does not name a node: ${edge.target}`);
138
+ if (edge?.route !== undefined) {
139
+ if (!edge.route || typeof edge.route !== 'object' || Array.isArray(edge.route)) {
140
+ errors.push(`${label}.route must be an object`);
141
+ } else {
142
+ if (edge.route.via !== undefined) {
143
+ if (!Array.isArray(edge.route.via)) errors.push(`${label}.route.via must be an array`);
144
+ else edge.route.via.forEach((point, pointIndex) => requirePoint(point, `${label}.route.via[${pointIndex}]`, errors));
145
+ }
146
+ if (edge.route.labelAt !== undefined) requirePoint(edge.route.labelAt, `${label}.route.labelAt`, errors);
147
+ if (edge.route.messageY !== undefined && (diagramType !== 'sequence' || !Number.isFinite(edge.route.messageY) || edge.route.messageY < 0)) errors.push(`${label}.route.messageY must be a non-negative finite number for sequence`);
148
+ }
149
+ }
150
+ if (diagramType === 'sequence' && !inputOnly && edge.route?.messageY === undefined) errors.push(`${label}.route.messageY is required for a positioned sequence message; regenerate with --layout auto`);
151
+ rules.validateEdge?.(edge, label, errors, { requireString, sequenceOrders });
152
+ }
153
+ if (errors.length === 0) errors.push(...validateLayoutSemantics(graph));
154
+ if (errors.length === 0) errors.push(...validateOperands(graph));
155
+ if (errors.length === 0) errors.push(...validateExecutions(graph, { inputOnly }));
156
+ if (errors.length === 0 && !inputOnly && audit) errors.push(...auditGraphLayout(graph).errors);
157
+ return errors;
158
+ }
159
+
160
+ function validateLayoutSemantics(graph) {
161
+ const errors = [], type = diagramTypeOf(graph), sequence = type === 'sequence';
162
+ const nodes = new Map(graph.nodes.map(node => [node.id, node])), groups = new Map((graph.groups ?? []).map(group => [group.id, group]));
163
+ for (const node of graph.nodes) {
164
+ if (node.groupId !== undefined) {
165
+ if (sequence) errors.push(`node ${node.id}.groupId is not supported for sequence participants`);
166
+ else if (typeof node.groupId !== 'string' || !groups.has(node.groupId)) errors.push(`node ${node.id}.groupId does not name a group: ${String(node.groupId)}`);
167
+ else if (type === 'usecase' && node.kind === 'actor') errors.push(`node ${node.id}.groupId places an actor inside a system boundary`);
168
+ }
169
+ if (node.layout !== undefined) {
170
+ if (!isObject(node.layout)) errors.push(`node ${node.id}.layout must be an object`);
171
+ else for (const key of ['rank', 'order']) if (node.layout[key] !== undefined && (!Number.isInteger(node.layout[key]) || node.layout[key] < 0)) errors.push(`node ${node.id}.layout.${key} must be a non-negative integer`);
172
+ }
173
+ }
174
+ if (!sequence) for (const group of groups.values()) {
175
+ if (group.parentId === undefined) continue;
176
+ if (typeof group.parentId !== 'string' || !groups.has(group.parentId)) errors.push(`group ${group.id}.parentId does not name a group: ${String(group.parentId)}`);
177
+ const seen = new Set([group.id]); let parent = groups.get(group.parentId);
178
+ while (parent) {
179
+ if (seen.has(parent.id)) { errors.push(`group ${group.id}.parentId contains a cycle at ${parent.id}`); break; }
180
+ seen.add(parent.id); parent = groups.get(parent.parentId);
181
+ }
182
+ }
183
+ if (graph.layout !== undefined && !isObject(graph.layout)) errors.push('layout must be an object');
184
+ else for (const key of ['primaryPath', 'participantOrder']) {
185
+ const list = graph.layout?.[key];
186
+ if (list === undefined) continue;
187
+ if (!Array.isArray(list) || !list.length || list.some(id => typeof id !== 'string' || !nodes.has(id)) || new Set(list).size !== list.length) {
188
+ errors.push(`layout.${key} must be a non-empty list of distinct node IDs`); continue;
189
+ }
190
+ if (key === 'participantOrder') {
191
+ if (!sequence || list.length !== nodes.size) errors.push('layout.participantOrder must be a complete permutation of sequence participants');
192
+ const ordered = list.map(id => nodes.get(id).layout?.order).filter(value => value !== undefined);
193
+ if (ordered.some((value, i) => i && value <= ordered[i - 1])) errors.push('layout.participantOrder conflicts with node.layout.order');
194
+ } else {
195
+ if (sequence) errors.push('layout.primaryPath is not supported for sequence; use participantOrder');
196
+ for (let i = 1; i < list.length; i++) {
197
+ if (!graph.edges.some(edge => edge.source === list[i - 1] && edge.target === list[i])) errors.push(`layout.primaryPath has no directed edge from ${list[i - 1]} to ${list[i]}`);
198
+ const before = nodes.get(list[i - 1]).layout?.rank, after = nodes.get(list[i]).layout?.rank;
199
+ if (before !== undefined && after !== undefined && before >= after) errors.push(`layout.primaryPath conflicts with node.layout.rank at ${list[i]}`);
200
+ }
201
+ }
202
+ }
203
+ if (sequence && new Set(graph.nodes.map(node => node.layout?.rank).filter(value => value !== undefined)).size > 1) errors.push('sequence participants must share one layout.rank');
204
+ for (const edge of graph.edges) {
205
+ const source = nodes.get(edge.source), target = nodes.get(edge.target);
206
+ if (type === 'class' && ['inheritance', 'implementation'].includes(edge.kind)) {
207
+ if (edge.kind === 'implementation' && target.kind !== 'interface') errors.push(`edge ${edge.id} implementation target must be an interface`);
208
+ if (source.layout?.rank !== undefined && target.layout?.rank !== undefined && target.layout.rank >= source.layout.rank) errors.push(`edge ${edge.id} layout.rank must place parent/interface ${target.id} above ${source.id}`);
209
+ }
210
+ if (type === 'usecase' && ['include', 'extend'].includes(edge.kind) && (source.kind !== 'usecase' || target.kind !== 'usecase')) errors.push(`edge ${edge.id} ${edge.kind} endpoints must both be use cases`);
211
+ }
212
+ return errors;
213
+ }
214
+
215
+ // The CLI's entry point (validate, generate, --fix). On top of what the Viewer renders, it asks for the callee bar of
216
+ // every answered sync call, so pages generated earlier still open while new and refreshed graphs carry their bars.
217
+ export function validateGraphInput(input, options = {}) {
218
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return ['graph must be an object'];
219
+ const authored = graph => { const errors = validateGraph(graph, options); return errors.length ? errors : missingCallExecutions(graph); };
220
+ if (!Object.hasOwn(input, 'diagrams')) return authored(input);
221
+ if (!Array.isArray(input.diagrams) || input.diagrams.length < 1 || input.diagrams.length > DIAGRAM_TYPES.length) {
222
+ return [`diagrams must contain between 1 and ${DIAGRAM_TYPES.length} graphs`];
223
+ }
224
+
225
+ const errors = [];
226
+ const types = new Set();
227
+ input.diagrams.forEach((graph, index) => {
228
+ const type = diagramTypeOf(graph);
229
+ if (types.has(type)) errors.push(`diagrams[${index}].meta.diagramType duplicates ${type}`);
230
+ types.add(type);
231
+ errors.push(...authored(graph).map(error => `diagrams[${index}].${error}`));
232
+ });
233
+ return errors;
234
+ }
235
+
236
+ // Advisory review of an authored graph or collection: warnings, never errors, so every graph that validated before
237
+ // still validates. It reads the card-identity contract back to the author: a node without `module` renders on the
238
+ // plain surface, one module for a whole flow deserves a second look, and only a decision branches in a flowchart.
239
+ // Outsiders (`external`, `actor`, `device`) may stay plain; what is flagged for them is inconsistency across views.
240
+ const PLAIN_KINDS = new Set(['initial', 'final']);
241
+ const OUTSIDER_KINDS = new Set(['external', 'actor', 'device']);
242
+ const moduleOf = item => (typeof item?.module === 'string' && item.module.trim() ? item.module : undefined);
243
+
244
+ export function reviewComposition(input) {
245
+ if (validateGraphInput(input, { inputOnly: true }).length) return [];
246
+ const graphs = graphsOf(input), warnings = [];
247
+ const members = graph => [...graph.nodes, ...graph.edges];
248
+ const modules = new Set(graphs.flatMap(graph => members(graph).map(moduleOf).filter(Boolean)));
249
+ // The same label across views is the same component: its module must be present everywhere and be the same one.
250
+ const byLabel = new Map();
251
+ for (const graph of graphs) for (const node of graph.nodes) if (typeof node.label === 'string' && !PLAIN_KINDS.has(node.kind)) {
252
+ byLabel.set(node.label, [...(byLabel.get(node.label) ?? []), { diagramType: diagramTypeOf(graph), id: node.id, module: moduleOf(node) }]);
253
+ }
254
+ for (const graph of graphs) {
255
+ const diagramType = diagramTypeOf(graph);
256
+ const warn = (ruleId, elementIds, message, remediation) => warnings.push({ ruleId, severity: 'warning', diagramType, elementIds, message, remediation });
257
+ if (modules.size) {
258
+ const plain = graph.nodes.filter(node => !PLAIN_KINDS.has(node.kind) && !OUTSIDER_KINDS.has(node.kind) && !moduleOf(node));
259
+ if (plain.length) warn('module.missing', plain.map(node => node.id),
260
+ `${plain.length === 1 ? 'node' : 'nodes'} ${plain.map(node => node.id).join(', ')} ${plain.length === 1 ? 'has' : 'have'} no module and render${plain.length === 1 ? 's' : ''} on the plain surface without identity`,
261
+ 'Give every ordinary node the module of the subsystem whose work it performs (a step: the subsystem that does the work; an external hub or broker: its channel); leave only true outsiders plain.');
262
+ for (const node of graph.nodes) {
263
+ const elsewhere = (byLabel.get(node.label) ?? []).filter(item => item.diagramType !== diagramType && item.module && item.module !== moduleOf(node));
264
+ if (!elsewhere.length || PLAIN_KINDS.has(node.kind)) continue;
265
+ const other = elsewhere[0];
266
+ warn('module.inconsistent', [node.id], `node ${node.id} "${node.label}" ${moduleOf(node) ? `carries module "${moduleOf(node)}"` : 'has no module'} here but "${other.module}" in ${other.diagramType}`,
267
+ 'The same component keeps the same module in every view of a collection; copy the module name or make the labels differ when they are different things.');
268
+ }
269
+ }
270
+ const washed = graph.nodes.filter(moduleOf);
271
+ if (['flowchart', 'dataflow'].includes(diagramType) && washed.length >= 6 && new Set(washed.map(moduleOf)).size === 1) {
272
+ warn('module.single-tone', washed.map(node => node.id),
273
+ `${diagramType} gives all ${washed.length} nodes the module "${moduleOf(washed[0])}", so the wash tells the reader nothing`,
274
+ 'Check whether the steps really are one subsystem\'s work: a step performed by another subsystem (a store, a cache layer, a queue) takes that subsystem\'s module, start and end take the caller. If everything truly belongs to one subsystem, leave it.');
275
+ }
276
+ if (diagramType === 'flowchart') {
277
+ const outgoing = new Map();
278
+ for (const edge of graph.edges) outgoing.set(edge.source, (outgoing.get(edge.source) ?? 0) + 1);
279
+ for (const node of graph.nodes) if (node.kind !== 'decision' && (outgoing.get(node.id) ?? 0) > 1) {
280
+ warn('flowchart.process-branch', [node.id], `node ${node.id} (${node.kind}) has ${outgoing.get(node.id)} outgoing edges; only a decision branches`,
281
+ 'Insert a decision that asks the actual condition, or merge the paths into one.');
282
+ }
283
+ }
284
+ }
285
+ return warnings;
286
+ }