arkaos 4.32.0 → 4.34.0

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 (92) hide show
  1. package/README.md +1 -1
  2. package/THE-ARKAOS-GUIDE.md +1 -1
  3. package/VERSION +1 -1
  4. package/arka/SKILL.md +1 -1
  5. package/arka/skills/forge/references/workflows.md +10 -0
  6. package/config/claude-agents/architect.md +1 -1
  7. package/config/claude-agents/backend-dev.md +1 -1
  8. package/config/claude-agents/devops-eng.md +1 -1
  9. package/config/claude-agents/frontend-dev.md +1 -1
  10. package/config/claude-agents/ops-lead.md +1 -1
  11. package/config/claude-agents/pm-director.md +1 -1
  12. package/config/claude-agents/strategy-director.md +1 -1
  13. package/config/hooks/session-start.sh +1 -1
  14. package/config/skills-curated.yaml +2 -2
  15. package/config/skills-provenance.yaml +7 -0
  16. package/config/statusline.sh +6 -1
  17. package/core/hooks/session_start.py +69 -28
  18. package/departments/dev/agents/architect.yaml +1 -0
  19. package/departments/dev/agents/backend-dev.yaml +1 -0
  20. package/departments/dev/agents/devops-eng.yaml +1 -0
  21. package/departments/dev/agents/frontend-dev.yaml +1 -0
  22. package/departments/dev/agents/tech-lead.yaml +1 -0
  23. package/departments/dev/skills/diagram/SKILL.md +175 -0
  24. package/departments/dev/skills/diagram/examples/archify-repo-grid.architecture.json +57 -0
  25. package/departments/dev/skills/diagram/vendor/LICENSE +22 -0
  26. package/departments/dev/skills/diagram/vendor/SKILL.md +367 -0
  27. package/departments/dev/skills/diagram/vendor/assets/template.html +11470 -0
  28. package/departments/dev/skills/diagram/vendor/bin/archify.mjs +600 -0
  29. package/departments/dev/skills/diagram/vendor/bin/open-artifact.mjs +67 -0
  30. package/departments/dev/skills/diagram/vendor/examples/agent-run.lifecycle.json +71 -0
  31. package/departments/dev/skills/diagram/vendor/examples/agent-tool-call.workflow.json +111 -0
  32. package/departments/dev/skills/diagram/vendor/examples/async-job-roundtrip.sequence.json +62 -0
  33. package/departments/dev/skills/diagram/vendor/examples/cache-miss-request.sequence.json +82 -0
  34. package/departments/dev/skills/diagram/vendor/examples/deployment-release.lifecycle.json +50 -0
  35. package/departments/dev/skills/diagram/vendor/examples/event-stream.dataflow.json +58 -0
  36. package/departments/dev/skills/diagram/vendor/examples/incident-response.workflow.json +65 -0
  37. package/departments/dev/skills/diagram/vendor/examples/product-analytics.dataflow.json +77 -0
  38. package/departments/dev/skills/diagram/vendor/examples/production-deployment.architecture.json +70 -0
  39. package/departments/dev/skills/diagram/vendor/examples/release-delivery.workflow.json +63 -0
  40. package/departments/dev/skills/diagram/vendor/examples/web-app.architecture.json +47 -0
  41. package/departments/dev/skills/diagram/vendor/package-lock.json +80 -0
  42. package/departments/dev/skills/diagram/vendor/package.json +28 -0
  43. package/departments/dev/skills/diagram/vendor/recipes/scenarios.mjs +350 -0
  44. package/departments/dev/skills/diagram/vendor/renderers/architecture/grid.mjs +62 -0
  45. package/departments/dev/skills/diagram/vendor/renderers/architecture/render-architecture.mjs +490 -0
  46. package/departments/dev/skills/diagram/vendor/renderers/dataflow/README.md +93 -0
  47. package/departments/dev/skills/diagram/vendor/renderers/dataflow/render-dataflow.mjs +396 -0
  48. package/departments/dev/skills/diagram/vendor/renderers/lifecycle/README.md +103 -0
  49. package/departments/dev/skills/diagram/vendor/renderers/lifecycle/render-lifecycle.mjs +482 -0
  50. package/departments/dev/skills/diagram/vendor/renderers/sequence/README.md +96 -0
  51. package/departments/dev/skills/diagram/vendor/renderers/sequence/render-sequence.mjs +357 -0
  52. package/departments/dev/skills/diagram/vendor/renderers/shared/cli.mjs +176 -0
  53. package/departments/dev/skills/diagram/vendor/renderers/shared/generated-validators.mjs +13 -0
  54. package/departments/dev/skills/diagram/vendor/renderers/shared/geometry.mjs +992 -0
  55. package/departments/dev/skills/diagram/vendor/renderers/shared/layout-report.mjs +40 -0
  56. package/departments/dev/skills/diagram/vendor/renderers/shared/utils.mjs +156 -0
  57. package/departments/dev/skills/diagram/vendor/renderers/shared/validator.mjs +40 -0
  58. package/departments/dev/skills/diagram/vendor/renderers/workflow/README.md +114 -0
  59. package/departments/dev/skills/diagram/vendor/renderers/workflow/render-workflow.mjs +598 -0
  60. package/departments/dev/skills/diagram/vendor/schemas/README.md +93 -0
  61. package/departments/dev/skills/diagram/vendor/schemas/architecture.schema.json +125 -0
  62. package/departments/dev/skills/diagram/vendor/schemas/common.schema.json +62 -0
  63. package/departments/dev/skills/diagram/vendor/schemas/dataflow.schema.json +240 -0
  64. package/departments/dev/skills/diagram/vendor/schemas/lifecycle.schema.json +260 -0
  65. package/departments/dev/skills/diagram/vendor/schemas/sequence.schema.json +205 -0
  66. package/departments/dev/skills/diagram/vendor/schemas/workflow.schema.json +351 -0
  67. package/departments/dev/skills/diagram/vendor/scripts/check-render-output.mjs +774 -0
  68. package/departments/dev/skills/diagram/vendor/scripts/generate-validators.mjs +66 -0
  69. package/departments/dev/skills/diagram/vendor/scripts/render-examples.mjs +26 -0
  70. package/departments/dev/skills/spec/SKILL.md +19 -0
  71. package/departments/ops/agents/ops-lead.yaml +1 -0
  72. package/departments/pm/agents/pm-director.yaml +1 -0
  73. package/departments/strategy/agents/strategy-director.yaml +1 -0
  74. package/harness/codex/AGENTS.md +1 -1
  75. package/harness/copilot/copilot-instructions.md +1 -1
  76. package/harness/cursor/rules/arkaos.mdc +2 -2
  77. package/harness/gemini/GEMINI.md +1 -1
  78. package/harness/opencode/AGENTS.md +1 -1
  79. package/harness/zed/.rules +1 -1
  80. package/installer/autoupdate.js +249 -0
  81. package/installer/cli.js +7 -0
  82. package/installer/core-snapshot.js +5 -1
  83. package/installer/doctor.js +11 -0
  84. package/installer/index.js +11 -0
  85. package/installer/update.js +11 -0
  86. package/knowledge/agents-registry-v2.json +9 -1
  87. package/knowledge/skills-manifest.json +14 -1
  88. package/package.json +3 -2
  89. package/pyproject.toml +1 -1
  90. package/scripts/auto-update.sh +188 -0
  91. package/scripts/skill_validator.py +10 -2
  92. package/scripts/tools/docs_stats.py +8 -2
@@ -0,0 +1,490 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
4
+ import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5
+ import { componentBox, boundaryBox, connectionPath } from '../shared/layout-report.mjs';
6
+ import { gridLayout, resolveComponentPos, validateGridPlacement } from './grid.mjs';
7
+ import {
8
+ asArray,
9
+ isFinitePoint,
10
+ rectsOverlap,
11
+ segmentIntersectsRect,
12
+ cleanFlowProblems,
13
+ cleanCrossingProblems,
14
+ cleanAmbiguousCorridorProblems,
15
+ cleanBorderRunProblems,
16
+ cleanRouteRhythmProblems,
17
+ cleanLabelRouteClearanceProblems,
18
+ suggestLabelObstacleFix,
19
+ suggestComponentSeparation,
20
+ anchor,
21
+ automaticPortSpread,
22
+ defaultFromSide,
23
+ defaultToSide,
24
+ chosenSide,
25
+ polylinePath,
26
+ routePointsValue,
27
+ roundedPath,
28
+ labelPoint,
29
+ componentFill,
30
+ componentText,
31
+ arrowClassMap,
32
+ variantAccent,
33
+ } from '../shared/geometry.mjs';
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const layoutJsonMode = process.argv.includes('--layout-json');
37
+ const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
38
+ const { diagram: arch, template, outPath } = loadDiagram({
39
+ rendererDir: __dirname,
40
+ diagramType: 'architecture',
41
+ defaultExample: 'web-app.architecture.json',
42
+ argv: cliArgs,
43
+ });
44
+
45
+ const grid = gridLayout(arch);
46
+
47
+ const layout = {
48
+ defaultW: 120,
49
+ defaultH: 60,
50
+ margin: 40,
51
+ // Boundary padding — the 30/50 rule that was a hand-arithmetic footgun
52
+ // (CHANGELOG v2.2.1): 30px on top/left/right, plus 20px extra at the bottom.
53
+ boundaryPad: 30,
54
+ boundaryExtraBottom: 20,
55
+ legendH: 28,
56
+ };
57
+
58
+ // ---- Measure components from free coordinates --------------------------------
59
+ function measureComponent(c) {
60
+ const [x, y] = resolveComponentPos(c, grid);
61
+ const [w, h] = Array.isArray(c.size) ? c.size : [layout.defaultW, layout.defaultH];
62
+ return { ...c, x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 };
63
+ }
64
+
65
+ const components = new Map(asArray(arch.components).map((c) => [c.id, measureComponent(c)]));
66
+ const componentSteps = new Map();
67
+ for (const [index, conn] of asArray(arch.connections).entries()) {
68
+ if (!componentSteps.has(conn.from)) componentSteps.set(conn.from, index);
69
+ if (!componentSteps.has(conn.to)) componentSteps.set(conn.to, index + 1);
70
+ }
71
+ for (const [index, c] of asArray(arch.components).entries()) {
72
+ if (!componentSteps.has(c.id)) componentSteps.set(c.id, index);
73
+ }
74
+
75
+ // ---- Boundaries computed from the `wraps` id list ---------------------------
76
+ function boundaryRect(boundary) {
77
+ const members = asArray(boundary.wraps).map((id) => components.get(id)).filter(Boolean);
78
+ if (!members.length) return null;
79
+ const minX = Math.min(...members.map((m) => m.x));
80
+ const minY = Math.min(...members.map((m) => m.y));
81
+ const maxX = Math.max(...members.map((m) => m.x + m.width));
82
+ const maxY = Math.max(...members.map((m) => m.y + m.height));
83
+ const pad = boundary.pad ?? layout.boundaryPad;
84
+ return {
85
+ ...boundary,
86
+ x: minX - pad,
87
+ y: minY - pad,
88
+ width: maxX - minX + pad * 2,
89
+ height: maxY - minY + pad + layout.boundaryExtraBottom,
90
+ };
91
+ }
92
+
93
+ const boundaries = asArray(arch.boundaries).map(boundaryRect).filter(Boolean);
94
+ const compositionFrames = boundaries.map((boundary, index) => ({
95
+ ...boundary,
96
+ id: boundary.id || index,
97
+ kind: boundary.kind || 'boundary',
98
+ radius: boundary.kind === 'security-group' ? 8 : 12,
99
+ }));
100
+
101
+ function componentContext(component) {
102
+ const scopes = boundaries
103
+ .filter((boundary) => asArray(boundary.wraps).includes(component.id))
104
+ .sort((a, b) => (b.width * b.height) - (a.width * a.height))
105
+ .map((boundary) => boundary.label);
106
+ return scopes.length ? scopes.join(' › ') : 'Architecture component';
107
+ }
108
+
109
+ // ---- Auto viewBox: fit all geometry + a legend row --------------------------
110
+ function autoViewBox() {
111
+ let maxX = 0;
112
+ let maxY = 0;
113
+ for (const c of components.values()) {
114
+ maxX = Math.max(maxX, c.x + c.width);
115
+ maxY = Math.max(maxY, c.y + c.height);
116
+ }
117
+ for (const b of boundaries) {
118
+ maxX = Math.max(maxX, b.x + b.width);
119
+ maxY = Math.max(maxY, b.y + b.height);
120
+ }
121
+ return [
122
+ Math.ceil(maxX + layout.margin),
123
+ Math.ceil(maxY + layout.margin + layout.legendH),
124
+ ];
125
+ }
126
+
127
+ const viewBox = arch.meta?.viewBox || autoViewBox();
128
+ const legendY = () => viewBox[1] - 16;
129
+
130
+ // ---- Validation: mechanical correctness, never layout taste -----------------
131
+ function validateArchitecture() {
132
+ const problems = [];
133
+ if (arch.schema_version !== 1) problems.push('Architecture files must set "schema_version": 1.');
134
+ if (arch.diagram_type !== 'architecture') problems.push('Architecture files must set "diagram_type": "architecture".');
135
+ if (!arch.meta?.title) problems.push('Architecture files must include meta.title.');
136
+ if (!Array.isArray(arch.components) || arch.components.length < 1) {
137
+ problems.push('Architecture diagrams need at least one component.');
138
+ }
139
+ if (arch.connections !== undefined && !Array.isArray(arch.connections)) problems.push('Architecture "connections" must be an array.');
140
+ if (arch.boundaries !== undefined && !Array.isArray(arch.boundaries)) problems.push('Architecture "boundaries" must be an array.');
141
+ if (arch.cards !== undefined && !Array.isArray(arch.cards)) problems.push('Architecture "cards" must be an array.');
142
+ if (components.size !== asArray(arch.components).length) problems.push('Component ids must be unique.');
143
+ if (grid) {
144
+ validateGridPlacement(arch, grid, problems);
145
+ } else {
146
+ for (const c of asArray(arch.components)) {
147
+ if (!Array.isArray(c.pos) || c.pos.length !== 2) {
148
+ problems.push(`Component "${c.id}" must include pos [x, y] when layout.mode is omitted (free placement).`);
149
+ }
150
+ }
151
+ }
152
+
153
+ for (const c of components.values()) {
154
+ if (!isFinitePoint(c.x, c.y, c.width, c.height)) {
155
+ problems.push(`Component "${c.id}" has non-finite pos/size — pos and size must be [number, number].`);
156
+ continue;
157
+ }
158
+ if (c.width <= 0 || c.height <= 0) {
159
+ problems.push(`Component "${c.id}" has invalid size ${c.width}x${c.height} — width and height must be greater than 0.`);
160
+ continue;
161
+ }
162
+ if (c.x < 0 || c.y < 0 || c.x + c.width > viewBox[0] || c.y + c.height > viewBox[1]) {
163
+ problems.push(`Component "${c.id}" falls outside the viewBox ${viewBox[0]}x${viewBox[1]} — adjust pos/size or set a larger meta.viewBox.`);
164
+ }
165
+ const estLabelW = textUnits(c.label) * 6.6;
166
+ if (estLabelW > c.width + 8) {
167
+ problems.push(`Label "${c.label}" (~${Math.round(estLabelW)}px) is wider than component "${c.id}" (${c.width}px) — shorten the label, move detail to sublabel, or widen size.`);
168
+ }
169
+ }
170
+
171
+ // Component overlap — the highest-traffic hand-placement failure mode.
172
+ const list = [...components.values()];
173
+ for (let i = 0; i < list.length; i += 1) {
174
+ for (let j = i + 1; j < list.length; j += 1) {
175
+ if (rectsOverlap(list[i], list[j], 8)) {
176
+ problems.push(`Components "${list[i].id}" and "${list[j].id}" are less than 8px apart — move one or shrink its size.\n${suggestComponentSeparation(list[i], list[j], 8)}`);
177
+ }
178
+ }
179
+ }
180
+
181
+ // Boundaries: every wrapped id must exist; the computed box must stay in view.
182
+ for (const boundary of asArray(arch.boundaries)) {
183
+ for (const id of asArray(boundary.wraps)) {
184
+ if (!components.has(id)) problems.push(`Boundary "${boundary.label}" wraps unknown component "${id}".`);
185
+ }
186
+ }
187
+ for (const b of boundaries) {
188
+ if (b.x < 0 || b.y < 0 || b.x + b.width > viewBox[0] || b.y + b.height > viewBox[1]) {
189
+ problems.push(`Boundary "${b.label}" extends outside the viewBox — its members sit too close to the canvas edge; add margin or enlarge meta.viewBox.`);
190
+ }
191
+ }
192
+
193
+ for (const conn of asArray(arch.connections)) {
194
+ if (!components.has(conn.from)) problems.push(`Connection "${conn.label || conn.from}" references unknown source "${conn.from}".`);
195
+ if (!components.has(conn.to)) problems.push(`Connection "${conn.label || conn.to}" references unknown target "${conn.to}".`);
196
+ if (components.has(conn.from) && components.has(conn.to)) {
197
+ const routed = pathFor(conn);
198
+ const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
199
+ const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
200
+ if (distance < 24) problems.push(`Connection "${conn.label || `${conn.from}->${conn.to}`}" is too short (${Math.round(distance)}px; minimum 24px) — place its components farther apart.`);
201
+ }
202
+ }
203
+
204
+ problems.push(...cleanFlowProblems({
205
+ relations: arch.connections,
206
+ endpointIds: new Set(components.keys()),
207
+ obstacles: components.values(),
208
+ pathFor,
209
+ diagramType: 'architecture',
210
+ relationCollection: 'connections',
211
+ obstacleKind: 'component',
212
+ profile: arch.meta?.quality_profile,
213
+ routeHint: 'adjust fromSide/toSide, set route/via, or move the component'
214
+ }));
215
+ problems.push(...cleanCrossingProblems({
216
+ relations: arch.connections,
217
+ endpointIds: new Set(components.keys()),
218
+ pathFor,
219
+ diagramType: 'architecture',
220
+ relationCollection: 'connections',
221
+ profile: arch.meta?.quality_profile,
222
+ routeHint: 'adjust route/via or fromSide/toSide so the connections use separate corridors'
223
+ }));
224
+ problems.push(...cleanAmbiguousCorridorProblems({
225
+ relations: arch.connections,
226
+ endpointIds: new Set(components.keys()),
227
+ pathFor,
228
+ diagramType: 'architecture',
229
+ relationCollection: 'connections',
230
+ profile: arch.meta?.quality_profile,
231
+ routeHint: 'adjust route/via or fromSide/toSide so unrelated connections do not visually merge'
232
+ }));
233
+ problems.push(...cleanBorderRunProblems({
234
+ relations: arch.connections,
235
+ endpointIds: new Set(components.keys()),
236
+ frames: compositionFrames,
237
+ pathFor,
238
+ diagramType: 'architecture',
239
+ relationCollection: 'connections',
240
+ profile: arch.meta?.quality_profile,
241
+ routeHint: 'adjust route/via or fromSide/toSide so the connection crosses the boundary perpendicularly instead of following its border'
242
+ }));
243
+ problems.push(...cleanRouteRhythmProblems({
244
+ relations: arch.connections,
245
+ endpointIds: new Set(components.keys()),
246
+ pathFor,
247
+ diagramType: 'architecture',
248
+ relationCollection: 'connections',
249
+ profile: arch.meta?.quality_profile,
250
+ routeHint: 'move route/via points into a wider corridor or move the component so every turn has room to read'
251
+ }));
252
+
253
+ // Connection labels must not land on top of components.
254
+ const labelRects = [];
255
+ for (const [connectionIndex, conn] of asArray(arch.connections).entries()) {
256
+ if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
257
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
258
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
259
+ labelRects.push({ relation: conn, relationIndex: connectionIndex, label: conn.label, x: lx - w / 2, y: ly - 10, width: w, height: 14, lx, ly });
260
+ }
261
+ for (const rect of labelRects) {
262
+ for (const c of components.values()) {
263
+ if (rectsOverlap(rect, c, -2)) {
264
+ problems.push(`Label "${rect.label}" overlaps component "${c.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, c)}`);
265
+ }
266
+ }
267
+ }
268
+ problems.push(...cleanLabelRouteClearanceProblems({
269
+ relations: arch.connections,
270
+ labels: labelRects,
271
+ endpointIds: new Set(components.keys()),
272
+ pathFor,
273
+ diagramType: 'architecture',
274
+ relationCollection: 'connections',
275
+ profile: arch.meta?.quality_profile,
276
+ }));
277
+
278
+ if (problems.length) {
279
+ throw new Error(`Architecture layout validation failed:\n- ${problems.join('\n- ')}`);
280
+ }
281
+ }
282
+
283
+ function buildLayoutReport() {
284
+ const labels = [];
285
+ for (const conn of asArray(arch.connections)) {
286
+ if (!conn.label || !components.has(conn.from) || !components.has(conn.to)) continue;
287
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
288
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
289
+ labels.push({
290
+ text: conn.label,
291
+ x: Math.round(lx - w / 2),
292
+ y: Math.round(ly - 10),
293
+ width: Math.round(w),
294
+ height: 14,
295
+ labelAt: [Math.round(lx), Math.round(ly)],
296
+ });
297
+ }
298
+ return {
299
+ ok: true,
300
+ diagram_type: 'architecture',
301
+ layout: grid ? { mode: 'grid', ...grid } : { mode: 'free' },
302
+ viewBox,
303
+ components: [...components.values()].map(componentBox),
304
+ boundaries: boundaries.map(boundaryBox),
305
+ connections: asArray(arch.connections)
306
+ .filter((conn) => components.has(conn.from) && components.has(conn.to))
307
+ .map((conn) => {
308
+ const routed = pathFor(conn);
309
+ const labelAt = conn.label ? labelPoint(conn, routed.points) : null;
310
+ return connectionPath(conn, routed, labelAt);
311
+ }),
312
+ labels,
313
+ };
314
+ }
315
+
316
+ // ---- Connection routing ------------------------------------------------------
317
+ function routeClearsComponents(conn, points, clearance = 2) {
318
+ const endpointIds = new Set([conn.from, conn.to]);
319
+ for (const component of components.values()) {
320
+ if (endpointIds.has(component.id)) continue;
321
+ for (let index = 0; index < points.length - 1; index += 1) {
322
+ if (segmentIntersectsRect({ start: points[index], end: points[index + 1] }, component, clearance)) {
323
+ return false;
324
+ }
325
+ }
326
+ }
327
+ return true;
328
+ }
329
+
330
+ function routeVia(conn, from, to, start, end) {
331
+ if (conn.via) return conn.via;
332
+ switch (conn.route || 'auto') {
333
+ case 'straight':
334
+ return [];
335
+ case 'orthogonal-h': {
336
+ const midX = (start[0] + end[0]) / 2;
337
+ return [[midX, start[1]], [midX, end[1]]];
338
+ }
339
+ case 'orthogonal-v': {
340
+ const midY = (start[1] + end[1]) / 2;
341
+ return [[start[0], midY], [end[0], midY]];
342
+ }
343
+ case 'auto':
344
+ default: {
345
+ // Direct line unless the anchors are clearly orthogonal-friendly.
346
+ if (Math.abs(start[0] - end[0]) < 4 || Math.abs(start[1] - end[1]) < 4) return [];
347
+ const midX = (start[0] + end[0]) / 2;
348
+ const horizontalFirst = [[midX, start[1]], [midX, end[1]]];
349
+ if (routeClearsComponents(conn, [start, ...horizontalFirst, end])) return horizontalFirst;
350
+
351
+ // The only fallback is the complementary in-bounds dogleg. Both
352
+ // candidates stay between the authored anchors, preserve two bends, and
353
+ // remain deterministic. If neither is safe, keep the original route so
354
+ // the universal Clean Flow gate returns the actionable hard failure.
355
+ const midY = (start[1] + end[1]) / 2;
356
+ const verticalFirst = [[start[0], midY], [end[0], midY]];
357
+ if (routeClearsComponents(conn, [start, ...verticalFirst, end])) return verticalFirst;
358
+ return horizontalFirst;
359
+ }
360
+ }
361
+ }
362
+
363
+ const pathCache = new Map();
364
+ const automaticPorts = automaticPortSpread(arch.connections, components);
365
+ function pathFor(conn) {
366
+ if (pathCache.has(conn)) return pathCache.get(conn);
367
+ const from = components.get(conn.from);
368
+ const to = components.get(conn.to);
369
+ const ports = automaticPorts.get(conn);
370
+ const start = ports?.from || anchor(from, chosenSide(conn.fromSide, defaultFromSide(from, to)));
371
+ const end = ports?.to || anchor(to, chosenSide(conn.toSide, defaultToSide(from, to)));
372
+ const points = [start, ...routeVia(conn, from, to, start, end), end];
373
+ const routed = { d: roundedPath(points, 8), points };
374
+ pathCache.set(conn, routed);
375
+ return routed;
376
+ }
377
+
378
+ // ---- Rendering ---------------------------------------------------------------
379
+ function renderBoundary(b, index) {
380
+ const cls = b.kind === 'security-group' ? 'c-security-group' : 'c-region';
381
+ const labelCls = b.kind === 'security-group' ? 't-security' : 't-cloud';
382
+ const rx = b.kind === 'security-group' ? 8 : 12;
383
+ return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="${esc(b.kind || 'boundary')}" data-composition-frame-id="${index}" x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" rx="${rx}" class="${cls}" stroke-width="1"/>
384
+ <text x="${b.x + 8}" y="${b.y + 18}" class="${labelCls}" font-size="9" font-weight="600">${esc(b.label)}</text>`;
385
+ }
386
+
387
+ function renderConnectionPath(conn, index) {
388
+ const [cls, marker] = arrowClassMap[conn.variant || 'default'] || arrowClassMap.default;
389
+ const routed = pathFor(conn);
390
+ const strokeWidth = conn.width || (conn.variant === 'emphasis' ? 1.8 : 1.5);
391
+ return ` <path ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(arch.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
392
+ }
393
+
394
+ function renderConnectionLabel(conn, index) {
395
+ if (!conn.label) return '';
396
+ const [lx, ly] = labelPoint(conn, pathFor(conn).points);
397
+ const w = Math.max(30, textUnits(conn.label) * 4.8 + 10);
398
+ return ` <g data-detail="context" ${focusEdgeAttrs(conn.from, conn.to, conn.label, index, conn.id)}>
399
+ <rect x="${lx - w / 2}" y="${ly - 10}" width="${w}" height="14" rx="3" class="c-mask"/>
400
+ <text x="${lx}" y="${ly}" class="${variantAccent(conn.variant)}" font-size="8" text-anchor="middle">${esc(conn.label)}</text>
401
+ </g>`;
402
+ }
403
+
404
+ function renderComponent(c) {
405
+ const fill = componentFill[c.type] || 'c-external';
406
+ const accent = componentText[c.type] || 't-muted';
407
+ const cx = c.cx;
408
+ const hasSub = c.sublabel != null && c.sublabel !== '';
409
+ const labelY = hasSub ? c.y + c.height / 2 - 2 : c.y + c.height / 2 + 4;
410
+ const sub = hasSub
411
+ ? `\n <text data-detail="context" x="${cx}" y="${c.y + c.height / 2 + 14}" class="t-muted" font-size="9" text-anchor="middle">${esc(c.sublabel)}</text>`
412
+ : '';
413
+ const tag = c.tag
414
+ ? `\n <text data-detail="fine" x="${cx}" y="${c.y + c.height - 8}" class="${accent}" font-size="7" text-anchor="middle">${esc(c.tag)}</text>`
415
+ : '';
416
+ const passport = { kind: c.type, sublabel: c.sublabel, tag: c.tag, context: componentContext(c) };
417
+ return ` <g ${focusNodeAttrs(c.id, c.label, passport)}>
418
+ ${focusNodeTitle(c.label, passport)}
419
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="c-mask"/>
420
+ <rect x="${c.x}" y="${c.y}" width="${c.width}" height="${c.height}" rx="6" class="${fill}"${animateAttr(arch.meta, 'node', componentSteps.get(c.id))} stroke-width="1.5"/>
421
+ ${renderSemanticSigil(c.type, { x: c.x + 6, y: c.y + 6 })}
422
+ <text${hasSub ? ' data-detail-anchor' : ''} x="${cx}" y="${labelY}" class="t-primary" font-size="11" font-weight="600" text-anchor="middle">${esc(c.label)}</text>${sub}${tag}
423
+ </g>`;
424
+ }
425
+
426
+ // Auto legend: one swatch per component type actually used, left to right.
427
+ const TYPE_LABELS = {
428
+ frontend: 'Frontend', backend: 'Backend', database: 'Database', cloud: 'Cloud',
429
+ security: 'Security', messagebus: 'Message bus', external: 'External',
430
+ };
431
+ function renderLegend() {
432
+ const used = [];
433
+ const seen = new Set();
434
+ for (const c of components.values()) {
435
+ if (!seen.has(c.type)) { seen.add(c.type); used.push(c.type); }
436
+ }
437
+ const y = legendY();
438
+ let x = layout.margin;
439
+ const parts = [' <g data-legend-bridge>',
440
+ ` <text x="${x}" y="${y - 13}" class="t-primary" font-size="9" font-weight="600">Legend</text>`];
441
+ for (const type of used) {
442
+ parts.push(` <g data-legend-kind="${esc(type)}">`);
443
+ parts.push(` <rect x="${x}" y="${y - 8}" width="14" height="9" rx="2" class="${componentFill[type] || 'c-external'}" stroke-width="1"/>`);
444
+ parts.push(` <text x="${x + 20}" y="${y}" class="t-muted" font-size="8">${TYPE_LABELS[type] || type}</text>`);
445
+ parts.push(' </g>');
446
+ x += 30 + (textUnits(TYPE_LABELS[type] || type) * 5 + 34);
447
+ }
448
+ parts.push(' </g>');
449
+ return parts.join('\n');
450
+ }
451
+
452
+ function renderSvg() {
453
+ return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(arch.meta, 'architecture diagram')}>
454
+ ${svgAccessibleText(arch.meta, 'architecture diagram')}
455
+ ${renderDefinitions()}
456
+
457
+ <!-- Background Grid -->
458
+ <rect width="100%" height="100%" fill="url(#grid)" />
459
+
460
+ <!-- Boundaries (behind everything) -->
461
+ ${boundaries.map(renderBoundary).join('\n\n')}
462
+
463
+ <!-- Connection paths (before components for correct z-order) -->
464
+ ${asArray(arch.connections).map(renderConnectionPath).join('\n')}
465
+
466
+ <!-- Components -->
467
+ ${[...components.values()].map(renderComponent).join('\n\n')}
468
+
469
+ <!-- Connection labels -->
470
+ ${asArray(arch.connections).map(renderConnectionLabel).join('\n')}
471
+
472
+ <!-- Legend -->
473
+ ${renderLegend()}
474
+ </svg>`;
475
+ }
476
+
477
+ validateArchitecture();
478
+ if (layoutJsonMode) {
479
+ console.log(JSON.stringify(buildLayoutReport(), null, 2));
480
+ process.exit(0);
481
+ }
482
+ writeDiagram({
483
+ outPath,
484
+ template,
485
+ diagramType: 'architecture',
486
+ meta: arch.meta,
487
+ footerLabel: 'Architecture diagram',
488
+ svg: renderSvg(),
489
+ cards: arch.cards,
490
+ });
@@ -0,0 +1,93 @@
1
+ # Data Flow Renderer
2
+
3
+ Render `diagram_type: "dataflow"` JSON files into the standard Archify HTML
4
+ template.
5
+
6
+ ```bash
7
+ node archify/renderers/dataflow/render-dataflow.mjs input.dataflow.json output.html
8
+ ```
9
+
10
+ The renderer validates input against `archify/schemas/dataflow.schema.json`
11
+ with the bundled standalone validator. No dependency installation is required.
12
+
13
+ If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
14
+ or falls back to `dataflow.html` in the current working directory.
15
+
16
+ ## Input
17
+
18
+ Data-flow JSON files must set:
19
+
20
+ ```json
21
+ {
22
+ "schema_version": 1,
23
+ "diagram_type": "dataflow",
24
+ "meta": {
25
+ "title": "Product Analytics Data Flow",
26
+ "subtitle": "Events, consent, PII isolation, warehouse sync, and consumers",
27
+ "viewBox": [940, 720]
28
+ },
29
+ "stages": [],
30
+ "nodes": [],
31
+ "flows": [],
32
+ "cards": []
33
+ }
34
+ ```
35
+
36
+ A complete worked example lives at
37
+ `archify/examples/product-analytics.dataflow.json`.
38
+
39
+ The schema lives at:
40
+
41
+ ```text
42
+ archify/schemas/dataflow.schema.json
43
+ ```
44
+
45
+ ## Layout budget
46
+
47
+ | Constant | Value |
48
+ |----------|-------|
49
+ | viewBox | default `[940, 720]`; schema minimum `[360, 360]` |
50
+ | Stages (2–5) | centers at x = 100 + stage×215; stage band 168 wide, header at y 46 |
51
+ | Row tops (`row` 0–4) | y = 128, 242, 356, 470, 584 (plus `yOffset`) |
52
+ | Default node | 112×58 |
53
+ | Node area | x within `[24, width − 24]`; y within `[104, height − 74]` |
54
+ | Node spacing | ≥10px between any two nodes (checked across stages and rows) |
55
+ | Flow length | ≥34px between endpoints |
56
+ | Legend row | y = height − 36 |
57
+
58
+ Route presets for flows: `straight`, `vertical-channel`, `bottom-channel`,
59
+ `top-channel`, explicit `via` points, or the default `auto` (midpoint elbow).
60
+
61
+ ## Design Rules
62
+
63
+ - Use stages for data lifecycle boundaries: source, ingest, process, store,
64
+ consume.
65
+ - Place nodes by stage index and row index; do not hand-place raw SVG for the
66
+ common case.
67
+ - Use flow labels to name the data asset, not the transport primitive:
68
+ `clickstream`, `identity map`, `normalized facts`, `feature vectors`.
69
+ - Use `classification` for short sensitivity or governance context:
70
+ `PII touch`, `non-PII`, `approved only`, `batch`, `read-only`.
71
+ - Use `security` for PII, policy, consent, access-control, or restricted joins.
72
+ - Use `emphasis` for the primary data path and `dashed` for async or batch
73
+ derivations.
74
+ - Keep labels short enough to fit in narrow previews.
75
+
76
+ Schema violations exit non-zero with path-prefixed messages annotated with the
77
+ element's id or label. The renderer additionally fails when it can detect
78
+ layout problems, including missing stages, duplicate node IDs, nodes outside
79
+ the readable diagram area, node overlap, labels colliding with nodes or other
80
+ labels, labels wider than their node, unknown flow endpoints, missing flow
81
+ labels, unreadably short flows, flows crossing unrelated nodes (2px Clean Flow
82
+ clearance), or stages that exceed the viewBox. Stage frames remain intentional
83
+ pass-through containers. Text width
84
+ is estimated CJK-aware: fullwidth glyphs count as two units.
85
+
86
+ Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
87
+ X crossings then fail with `composition/proper-crossing`; default `standard`
88
+ keeps them as artifact-receipt warnings. Collinear stage corridors are outside
89
+ the proper-X rule, but a separate gate warns in `standard` and fails in
90
+ `showcase` when unrelated flows overlap for at least 8px. Shared semantic
91
+ endpoints, point touches, and shorter overlaps remain valid. Showcase also
92
+ rejects any route segment below 8px and any interior turn segment below 16px;
93
+ ordinary 8–15px endpoint stubs remain valid.