@diagc/core 0.1.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 (59) hide show
  1. package/LICENSE +709 -0
  2. package/README.md +27 -0
  3. package/dist/builder.d.ts +381 -0
  4. package/dist/builder.js +590 -0
  5. package/dist/children.d.ts +21 -0
  6. package/dist/children.js +45 -0
  7. package/dist/commands.d.ts +219 -0
  8. package/dist/commands.js +474 -0
  9. package/dist/compose.d.ts +19 -0
  10. package/dist/compose.js +246 -0
  11. package/dist/drawings.d.ts +13 -0
  12. package/dist/drawings.js +36 -0
  13. package/dist/eject.d.ts +20 -0
  14. package/dist/eject.js +260 -0
  15. package/dist/fishbone.d.ts +66 -0
  16. package/dist/fishbone.js +95 -0
  17. package/dist/git.d.ts +65 -0
  18. package/dist/git.js +159 -0
  19. package/dist/guards.d.ts +10 -0
  20. package/dist/guards.js +98 -0
  21. package/dist/index.d.ts +25 -0
  22. package/dist/index.js +45 -0
  23. package/dist/labels.d.ts +5 -0
  24. package/dist/labels.js +10 -0
  25. package/dist/layout-defaults.d.ts +20 -0
  26. package/dist/layout-defaults.js +20 -0
  27. package/dist/mutate.d.ts +106 -0
  28. package/dist/mutate.js +547 -0
  29. package/dist/second-order.d.ts +39 -0
  30. package/dist/second-order.js +86 -0
  31. package/dist/text.d.ts +5 -0
  32. package/dist/text.js +25 -0
  33. package/dist/threat-model.d.ts +88 -0
  34. package/dist/threat-model.js +188 -0
  35. package/dist/types.d.ts +395 -0
  36. package/dist/types.js +27 -0
  37. package/dist/util.d.ts +11 -0
  38. package/dist/util.js +13 -0
  39. package/dist/validate.d.ts +19 -0
  40. package/dist/validate.js +736 -0
  41. package/dist/view/compile.d.ts +29 -0
  42. package/dist/view/compile.js +78 -0
  43. package/dist/view/edges.d.ts +4 -0
  44. package/dist/view/edges.js +118 -0
  45. package/dist/view/hierarchy.d.ts +41 -0
  46. package/dist/view/hierarchy.js +103 -0
  47. package/dist/view/layers.d.ts +7 -0
  48. package/dist/view/layers.js +17 -0
  49. package/dist/view/lod.d.ts +15 -0
  50. package/dist/view/lod.js +17 -0
  51. package/dist/view/scope.d.ts +23 -0
  52. package/dist/view/scope.js +106 -0
  53. package/dist/view/size.d.ts +8 -0
  54. package/dist/view/size.js +34 -0
  55. package/dist/view/tree.d.ts +12 -0
  56. package/dist/view/tree.js +147 -0
  57. package/dist/view/types.d.ts +68 -0
  58. package/dist/view/types.js +1 -0
  59. package/package.json +38 -0
@@ -0,0 +1,736 @@
1
+ import { BUILTIN_NOTATIONS, EDGE_LABEL_SIDES, FONT_SCALES, LEGEND_POSITIONS, LEGEND_SECTIONS, RELATION_LINES, RELATION_MARKERS, RELATION_SHAPES, RESERVED_NODE_ID, SIDES, STRIDE, TEXT_ALIGNS, THREAT_SEVERITIES, THREAT_STATUSES, } from './types.js';
2
+ import { childrenOf } from './children.js';
3
+ import { FB_CATEGORY_TYPE, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_NOTATION, fishboneParents, fishboneTree, isFishboneNode } from './fishbone.js';
4
+ import { GIT_NOTATION, GIT_STAGE_TYPE, gitGraph, isGitKind, stageCommit } from './git.js';
5
+ import { SECOND_ORDER_NOTATION, SO_DECISION_TYPE, consequenceOrders, isSecondOrderNode } from './second-order.js';
6
+ import { TM_BOUNDARY_TYPE, TM_FLOW_KIND, TM_NOTATION } from './threat-model.js';
7
+ export class DiagramValidationError extends Error {
8
+ issues;
9
+ constructor(issues) {
10
+ super(`Invalid diagram model:\n${issues.map((i) => ` - ${i.message}`).join('\n')}`);
11
+ this.issues = issues;
12
+ this.name = 'DiagramValidationError';
13
+ }
14
+ }
15
+ /** content-hashed asset filename shape (hex hash + extension); shared with the
16
+ * studio dev middleware's asset naming (`apps/studio/vite-plugins/handlers.ts`) */
17
+ export const IMAGE_REF = /^[a-z0-9]+\.(png|jpe?g|svg|webp|gif)$/;
18
+ /** Bundled library icons served verbatim from apps/studio/public/library/<pack>/.
19
+ * A fixed, traversal-free namespace (never passed to readAsset, which uses IMAGE_REF). */
20
+ export const LIBRARY_IMAGE_REF = /^\/library\/[a-z0-9-]+\/[a-z0-9-]+\.(png|jpe?g|svg|webp|gif)$/;
21
+ /** shared-identity keys are slugs; also the composed id of a merged node */
22
+ export const KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
23
+ /**
24
+ * Append one issue. `ref` is omitted rather than `undefined` so serialized
25
+ * issues stay minimal (an absent ref is the same shape as an absent key).
26
+ */
27
+ const report = (issues, code, message, ref) => {
28
+ issues.push(ref === undefined ? { code, message } : { code, message, ref });
29
+ };
30
+ /** Diagram-level style: pinned style must be a non-empty string. Unknown ids are
31
+ * intentionally legal — the renderer treats them as unpinned. Also the
32
+ * model-level `notation`, unlike style, must be one of BUILTIN_NOTATIONS —
33
+ * it is a closed vocabulary the renderer keys a `Record` on, not an open
34
+ * preset id. */
35
+ function validateModelStyle(ctx) {
36
+ const { m, issues } = ctx;
37
+ if (m.style !== undefined && (typeof m.style !== 'string' || m.style === '')) {
38
+ report(issues, 'invalid-style', 'Diagram style must be a non-empty string');
39
+ }
40
+ if (m.notation !== undefined &&
41
+ (typeof m.notation !== 'string' || !BUILTIN_NOTATIONS.includes(m.notation))) {
42
+ report(issues, 'unknown-notation', `Diagram has unknown notation '${String(m.notation)}'`, m.id);
43
+ }
44
+ }
45
+ /** Per-node checks: duplicate ids, style scalars, rich runs, aligns/scale, image
46
+ * & shape refs, keys, include, table columns. Builds the shared node-key/id maps. */
47
+ function validateNodes(ctx) {
48
+ const { m, issues, nodeIds } = ctx;
49
+ const keys = new Map(); // key -> first declaring node id
50
+ for (const n of m.nodes) {
51
+ if (nodeIds.has(n.id))
52
+ report(issues, 'duplicate-node', `Duplicate node id '${n.id}'`, n.id);
53
+ nodeIds.add(n.id);
54
+ if (n.id === RESERVED_NODE_ID) {
55
+ report(issues, 'reserved-node-id', `Node id '${n.id}' is reserved for the layout root`, n.id);
56
+ }
57
+ if (n.color !== undefined && typeof n.color !== 'string') {
58
+ report(issues, 'invalid-style', `Node '${n.id}' has invalid color '${String(n.color)}'`, n.id);
59
+ }
60
+ if (n.textColor !== undefined && typeof n.textColor !== 'string') {
61
+ report(issues, 'invalid-style', `Node '${n.id}' has invalid textColor '${String(n.textColor)}'`, n.id);
62
+ }
63
+ if (n.technology !== undefined && typeof n.technology !== 'string') {
64
+ report(issues, 'invalid-style', `Node '${n.id}' has invalid technology '${String(n.technology)}'`, n.id);
65
+ }
66
+ if (n.rich !== undefined) {
67
+ const bad = !Array.isArray(n.rich) ||
68
+ n.rich.some((r) => {
69
+ const run = r;
70
+ return (r === null || typeof r !== 'object' || typeof run.text !== 'string' ||
71
+ (run.bold !== undefined && typeof run.bold !== 'boolean') ||
72
+ (run.italic !== undefined && typeof run.italic !== 'boolean'));
73
+ });
74
+ if (bad)
75
+ report(issues, 'invalid-rich', `Node '${n.id}' has invalid rich text`, n.id);
76
+ }
77
+ if (n.textAlign !== undefined && !TEXT_ALIGNS.includes(n.textAlign)) {
78
+ report(issues, 'invalid-align', `Node '${n.id}' has invalid textAlign '${String(n.textAlign)}'`, n.id);
79
+ }
80
+ if (n.fontScale !== undefined && !FONT_SCALES.includes(n.fontScale)) {
81
+ report(issues, 'invalid-font-scale', `Node '${n.id}' has invalid fontScale '${String(n.fontScale)}'`, n.id);
82
+ }
83
+ if (n.image !== undefined && (typeof n.image !== 'string' || !(IMAGE_REF.test(n.image) || LIBRARY_IMAGE_REF.test(n.image)))) {
84
+ report(issues, 'invalid-image', `Node '${n.id}' has invalid image ref '${String(n.image)}'`, n.id);
85
+ }
86
+ if (n.shape !== undefined && (typeof n.shape !== 'string' || !(IMAGE_REF.test(n.shape) || LIBRARY_IMAGE_REF.test(n.shape)))) {
87
+ report(issues, 'invalid-shape', `Node '${n.id}' has invalid shape ref '${String(n.shape)}'`, n.id);
88
+ }
89
+ if (n.link !== undefined && (typeof n.link !== 'string' || n.link.trim() === '')) {
90
+ report(issues, 'invalid-link', `Node '${n.id}' has invalid link`, n.id);
91
+ }
92
+ if (n.key !== undefined) {
93
+ if (typeof n.key !== 'string' || !KEY_PATTERN.test(n.key)) {
94
+ report(issues, 'invalid-key', `Node '${n.id}' has invalid key '${String(n.key)}'`, n.id);
95
+ }
96
+ else if (keys.has(n.key)) {
97
+ report(issues, 'duplicate-key', `Nodes '${keys.get(n.key) ?? ''}' and '${n.id}' share key '${n.key}' in one diagram`, n.id);
98
+ }
99
+ else {
100
+ keys.set(n.key, n.id);
101
+ }
102
+ }
103
+ if (n.include !== undefined && (typeof n.include !== 'string' || n.include === '')) {
104
+ report(issues, 'invalid-include', `Node '${n.id}' has invalid include '${String(n.include)}'`, n.id);
105
+ }
106
+ if (n.includePlane !== undefined) {
107
+ if (typeof n.includePlane !== 'string' || n.includePlane === '') {
108
+ report(issues, 'invalid-include', `Node '${n.id}' has invalid includePlane '${String(n.includePlane)}'`, n.id);
109
+ }
110
+ else if (n.include === undefined) {
111
+ report(issues, 'invalid-include', `Node '${n.id}' has includePlane without include`, n.id);
112
+ }
113
+ }
114
+ if (n.includePlanes !== undefined) {
115
+ if (typeof n.includePlanes !== 'boolean') {
116
+ report(issues, 'invalid-include', `Node '${n.id}' has invalid includePlanes '${String(n.includePlanes)}'`, n.id);
117
+ }
118
+ else if (n.include === undefined) {
119
+ report(issues, 'invalid-include', `Node '${n.id}' has includePlanes without include`, n.id);
120
+ }
121
+ }
122
+ // node.plane must reference a declared plane; node.layer a declared layer
123
+ if (n.plane !== undefined && !ctx.planeIds.has(n.plane)) {
124
+ report(issues, 'unknown-plane', `Node '${n.id}' belongs to unknown plane '${n.plane}'`, n.id);
125
+ }
126
+ if (n.layer !== undefined && !ctx.layerIds.has(n.layer)) {
127
+ report(issues, 'unknown-layer', `Node '${n.id}' references unknown layer '${n.layer}'`, n.id);
128
+ }
129
+ if (n.columns !== undefined) {
130
+ if (!Array.isArray(n.columns)) {
131
+ report(issues, 'duplicate-column', `Node '${n.id}' columns must be a list`, n.id);
132
+ }
133
+ else {
134
+ const seen = new Set();
135
+ for (const c of n.columns) {
136
+ if (c === null || typeof c !== 'object' || typeof c.name !== 'string') {
137
+ report(issues, 'duplicate-column', `Node '${n.id}' has an invalid column`, n.id);
138
+ continue;
139
+ }
140
+ if (seen.has(c.name)) {
141
+ report(issues, 'duplicate-column', `Node '${n.id}' has duplicate column '${c.name}'`, n.id);
142
+ }
143
+ seen.add(c.name);
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ /** Duplicate layer ids, and `layerRules` that name a layer nobody declared (a
150
+ * rule is the one place a layer can be referenced without a node or relation
151
+ * carrying it, so it is checked here, right after the layers are known). */
152
+ function validateLayers(ctx) {
153
+ const { m, issues, layerIds } = ctx;
154
+ for (const l of m.layers) {
155
+ if (layerIds.has(l.id))
156
+ report(issues, 'duplicate-layer', `Duplicate layer id '${l.id}'`, l.id);
157
+ layerIds.add(l.id);
158
+ }
159
+ (m.layerRules ?? []).forEach((rule, i) => {
160
+ if (!layerIds.has(rule.layer)) {
161
+ report(issues, 'unknown-layer', `layerRules[${i}] references unknown layer '${rule.layer}'`);
162
+ }
163
+ });
164
+ }
165
+ /** Plane declarations: duplicate ids, notation, containmentOf borrowing (unknown
166
+ * target or chained borrow), and per-plane layer presets referencing a layer. */
167
+ function validatePlanes(ctx) {
168
+ const { issues, planeIds, layerIds, planes } = ctx;
169
+ for (const p of planes) {
170
+ if (planeIds.has(p.id))
171
+ report(issues, 'duplicate-plane', `Duplicate plane id '${p.id}'`, p.id);
172
+ planeIds.add(p.id);
173
+ }
174
+ for (const p of planes) {
175
+ if (p.notation !== undefined &&
176
+ (typeof p.notation !== 'string' || !BUILTIN_NOTATIONS.includes(p.notation))) {
177
+ report(issues, 'unknown-notation', `Plane '${p.id}' has unknown notation '${String(p.notation)}'`, p.id);
178
+ }
179
+ if (p.containmentOf !== undefined) {
180
+ const target = planes.find((t) => t.id === p.containmentOf);
181
+ if (target === undefined) {
182
+ report(issues, 'unknown-plane', `Plane '${p.id}' borrows containment from unknown plane '${p.containmentOf}'`, p.id);
183
+ }
184
+ else if (target.containmentOf !== undefined) {
185
+ report(issues, 'invalid-plane', `Plane '${p.id}' borrows containment from '${p.containmentOf}', which itself borrows — chains are not allowed`, p.id);
186
+ }
187
+ }
188
+ for (const layer of p.layers ?? []) {
189
+ if (!layerIds.has(layer)) {
190
+ report(issues, 'unknown-layer', `Plane '${p.id}' references unknown layer '${layer}'`, p.id);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ /** plane.hides and plane.hidesTree must reference existing, shared nodes. */
196
+ function validatePlaneHides(ctx) {
197
+ const { m, issues, nodeIds, planes } = ctx;
198
+ const scopedPlaneOf = new Map(m.nodes.map((n) => [n.id, n.plane]));
199
+ for (const p of planes) {
200
+ for (const id of [...(p.hides ?? []), ...(p.hidesTree ?? [])]) {
201
+ if (!nodeIds.has(id)) {
202
+ report(issues, 'unknown-hidden-node', `Plane '${p.id}' hides unknown node '${id}'`, p.id);
203
+ }
204
+ else if (scopedPlaneOf.get(id) !== undefined) {
205
+ report(issues, 'redundant-hide', `Plane '${p.id}' hides node '${id}', which is already scoped to a plane`, p.id);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ /** Containment edges: endpoints must exist; a plane-tagged edge must reference a
211
+ * declared plane (and there must be planes at all). */
212
+ function validateContainment(ctx) {
213
+ const { m, issues, nodeIds, planeIds, planes } = ctx;
214
+ for (const e of m.containment) {
215
+ for (const end of [e.parent, e.child]) {
216
+ if (!nodeIds.has(end)) {
217
+ report(issues, 'dangling-endpoint', `Containment references unknown node '${end}'`, end);
218
+ }
219
+ }
220
+ if (e.plane !== undefined) {
221
+ if (planes.length === 0) {
222
+ report(issues, 'unknown-plane', `Containment '${e.parent}'>'${e.child}' is tagged with plane '${e.plane}' but no planes are declared`, e.plane);
223
+ }
224
+ else if (!planeIds.has(e.plane)) {
225
+ report(issues, 'unknown-plane', `Containment '${e.parent}'>'${e.child}' references unknown plane '${e.plane}'`, e.plane);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ /** Relations: duplicate ids, endpoints, layer refs, polarity/delay types, labels,
231
+ * per-relation style overrides, and FK column references. */
232
+ function validateRelations(ctx) {
233
+ const { m, issues, nodeIds, layerIds } = ctx;
234
+ const relationIds = new Set();
235
+ for (const r of m.relations) {
236
+ if (relationIds.has(r.id))
237
+ report(issues, 'duplicate-relation', `Duplicate relation id '${r.id}'`, r.id);
238
+ relationIds.add(r.id);
239
+ for (const end of [r.from, r.to]) {
240
+ if (!nodeIds.has(end)) {
241
+ report(issues, 'dangling-endpoint', `Relation '${r.id}' references unknown node '${end}'`, r.id);
242
+ }
243
+ }
244
+ if (r.layer !== undefined && !layerIds.has(r.layer)) {
245
+ report(issues, 'unknown-layer', `Relation '${r.id}' references unknown layer '${r.layer}'`, r.id);
246
+ }
247
+ if (r.polarity !== undefined && r.polarity !== '+' && r.polarity !== '-') {
248
+ report(issues, 'invalid-polarity', `Relation '${r.id}' has invalid polarity '${String(r.polarity)}'`, r.id);
249
+ }
250
+ if (r.delay !== undefined && typeof r.delay !== 'boolean') {
251
+ report(issues, 'invalid-delay', `Relation '${r.id}' has invalid delay '${String(r.delay)}'`, r.id);
252
+ }
253
+ if (r.labels !== undefined) {
254
+ const badLabel = (what) => report(issues, 'invalid-edge-label', `Relation '${r.id}' has invalid label ${what}`, r.id);
255
+ if (!Array.isArray(r.labels))
256
+ badLabel('list');
257
+ else
258
+ for (const [i, lb] of r.labels.entries()) {
259
+ if (typeof lb?.text !== 'string')
260
+ badLabel(`text at ${i}`);
261
+ if (lb?.t !== undefined && !(typeof lb.t === 'number' && Number.isFinite(lb.t) && lb.t >= 0 && lb.t <= 1))
262
+ badLabel(`t at ${i}`);
263
+ if (lb?.side !== undefined && !EDGE_LABEL_SIDES.includes(lb.side))
264
+ badLabel(`side at ${i}`);
265
+ }
266
+ }
267
+ if (r.style !== undefined) {
268
+ const s = r.style;
269
+ const bad = (what) => report(issues, 'invalid-style', `Relation '${r.id}' has invalid style ${what}`, r.id);
270
+ if (s.shape !== undefined && !RELATION_SHAPES.includes(s.shape))
271
+ bad(`shape '${String(s.shape)}'`);
272
+ if (s.line !== undefined && !RELATION_LINES.includes(s.line))
273
+ bad(`line '${String(s.line)}'`);
274
+ if (s.end !== undefined && !RELATION_MARKERS.includes(s.end))
275
+ bad(`end '${String(s.end)}'`);
276
+ for (const [key, v] of [['fromSide', s.fromSide], ['toSide', s.toSide]]) {
277
+ if (v !== undefined && !SIDES.includes(v))
278
+ bad(`${key} '${String(v)}'`);
279
+ }
280
+ if (s.width !== undefined && !(typeof s.width === 'number' && Number.isFinite(s.width) && s.width > 0))
281
+ bad(`width '${String(s.width)}'`);
282
+ if (s.curvature !== undefined &&
283
+ !(typeof s.curvature === 'number' && Number.isFinite(s.curvature) && s.curvature > 0))
284
+ bad(`curvature '${String(s.curvature)}'`);
285
+ if (s.color !== undefined && typeof s.color !== 'string')
286
+ bad(`color '${String(s.color)}'`);
287
+ if (s.animated !== undefined && typeof s.animated !== 'boolean')
288
+ bad(`animated '${String(s.animated)}'`);
289
+ }
290
+ const colsOf = (id) => m.nodes.find((n) => n.id === id)?.columns ?? [];
291
+ if (r.fromColumn !== undefined && !colsOf(r.from).some((c) => c.name === r.fromColumn)) {
292
+ report(issues, 'unknown-column', `Relation '${r.id}' fromColumn '${r.fromColumn}' is not a column of '${r.from}'`, r.id);
293
+ }
294
+ if (r.toColumn !== undefined && !colsOf(r.to).some((c) => c.name === r.toColumn)) {
295
+ report(issues, 'unknown-column', `Relation '${r.id}' toColumn '${r.toColumn}' is not a column of '${r.to}'`, r.id);
296
+ }
297
+ }
298
+ }
299
+ /** `threats` is a generic field (any notation): each entry is checked for shape
300
+ * wherever it appears, one issue per fault, the element as `ref`. */
301
+ function validateThreats(ctx) {
302
+ const { issues, m } = ctx;
303
+ const check = (ref, threats) => {
304
+ if (threats === undefined)
305
+ return;
306
+ // Shape before contents: a hand-edited file can put anything here, and the
307
+ // derivations read it unguarded (threatSummary calls `.filter` on it), so a
308
+ // wrong shape is an issue rather than something to skip past. One issue for
309
+ // the element — the per-field checks below would only add noise about
310
+ // entries that are not threats at all.
311
+ if (!Array.isArray(threats) || threats.some((t) => t === null || typeof t !== 'object')) {
312
+ report(issues, 'invalid-threats', `'threats' on '${ref}' must be a list of threats`, ref);
313
+ return;
314
+ }
315
+ const seen = new Set();
316
+ for (const t of threats) {
317
+ const id = t.id;
318
+ if (typeof id !== 'string' || id === '')
319
+ report(issues, 'threat-id', `A threat on '${ref}' has no id`, ref);
320
+ else if (seen.has(id))
321
+ report(issues, 'threat-id', `Threat id '${id}' repeats on '${ref}'`, ref);
322
+ else
323
+ seen.add(id);
324
+ if (!STRIDE.includes(t.category))
325
+ report(issues, 'threat-category', `Threat '${String(id)}' on '${ref}': category must be one of ${STRIDE.join(', ')}`, ref);
326
+ if (typeof t.title !== 'string' || t.title === '')
327
+ report(issues, 'threat-title', `Threat '${String(id)}' on '${ref}' has no title`, ref);
328
+ if (t.status !== undefined && !THREAT_STATUSES.includes(t.status))
329
+ report(issues, 'threat-status', `Threat '${String(id)}' on '${ref}': status must be one of ${THREAT_STATUSES.join(', ')}`, ref);
330
+ if (t.severity !== undefined && !THREAT_SEVERITIES.includes(t.severity))
331
+ report(issues, 'threat-severity', `Threat '${String(id)}' on '${ref}': severity must be one of ${THREAT_SEVERITIES.join(', ')}`, ref);
332
+ }
333
+ };
334
+ for (const n of m.nodes)
335
+ check(n.id, n.threats);
336
+ for (const r of m.relations)
337
+ check(r.id, r.threats);
338
+ }
339
+ /** Containment cycles are checked per plane — an edge pair spanning two planes
340
+ * is legal. Emits one `containment-cycle` issue per offending plane. */
341
+ function validateCycles(ctx) {
342
+ const { m, issues, planes } = ctx;
343
+ const defaultPlane = planes[0]?.id;
344
+ const byPlane = new Map();
345
+ for (const e of m.containment) {
346
+ const key = e.plane ?? defaultPlane;
347
+ byPlane.set(key, [...(byPlane.get(key) ?? []), e]);
348
+ }
349
+ for (const [plane, edges] of byPlane) {
350
+ const cycle = findContainmentCycle(edges);
351
+ if (cycle) {
352
+ report(issues, 'containment-cycle', `Containment cycle${plane !== undefined ? ` in plane '${plane}'` : ''}: ${cycle.join(' -> ')}`, cycle[0]);
353
+ }
354
+ }
355
+ }
356
+ /**
357
+ * Git-graph conventions, applied wherever RENDERING would activate the git
358
+ * profile — the same resolution `activeNotation` (view/compile.ts) uses: a
359
+ * plane's own `notation` wins, otherwise the model-level `notation` applies.
360
+ * That includes the zero-plane case (a model-level 'git-graph' with no planes
361
+ * at all validates the whole, planeless model the way `gitLayout` draws it).
362
+ * The layout never throws on a malformed graph — it cuts cycles and parks
363
+ * strays — but an author should hear about it, so each convention is an issue
364
+ * here. Rules read the FIRST plane whose EFFECTIVE notation is git; several
365
+ * git planes per model is deferred.
366
+ */
367
+ function validateGit(ctx) {
368
+ const { issues, m } = ctx;
369
+ const plane = ctx.planes.find((p) => (p.notation ?? m.notation) === GIT_NOTATION);
370
+ const modelLevel = plane === undefined && ctx.planes.length === 0 && m.notation === GIT_NOTATION;
371
+ if (plane === undefined && !modelLevel)
372
+ return;
373
+ const g = gitGraph(m, plane?.id);
374
+ const typeOf = new Map(m.nodes.map((n) => [n.id, n.type]));
375
+ const isCommit = (id) => typeOf.get(id) === 'commit';
376
+ const parents = new Map();
377
+ for (const r of m.relations) {
378
+ if (!isGitKind(r.kind))
379
+ continue;
380
+ // dangling endpoints are validateRelations' finding — don't double-report
381
+ if (!ctx.nodeIds.has(r.from) || !ctx.nodeIds.has(r.to))
382
+ continue;
383
+ if (!isCommit(r.from) || !isCommit(r.to)) {
384
+ report(issues, 'git-link-endpoints', `Relation '${r.id}' (${r.kind}) must join two commit nodes`, r.id);
385
+ continue;
386
+ }
387
+ const a = g.laneOf.get(r.from);
388
+ const b = g.laneOf.get(r.to);
389
+ if (a === undefined || b === undefined)
390
+ continue; // reported per commit below
391
+ const sameLane = a === b;
392
+ if (r.kind === 'commit' && !sameLane) {
393
+ report(issues, 'git-commit-lane', `Relation '${r.id}' (commit) must stay within one lane`, r.id);
394
+ continue; // an out-of-lane link isn't a valid parent edge — don't also flag it as a git-parents conflict
395
+ }
396
+ if (r.kind !== 'commit' && sameLane) {
397
+ report(issues, 'git-commit-lane', `Relation '${r.id}' (${r.kind}) must join commits of different lanes`, r.id);
398
+ continue; // ditto
399
+ }
400
+ if (r.kind !== 'merge') {
401
+ const p = parents.get(r.to) ?? { commit: 0, branch: 0 };
402
+ p[r.kind] += 1;
403
+ parents.set(r.to, p);
404
+ }
405
+ }
406
+ for (const [id, p] of parents) {
407
+ if (p.commit > 1)
408
+ report(issues, 'git-parents', `Commit '${id}' has more than one incoming commit link`, id);
409
+ if (p.branch > 1)
410
+ report(issues, 'git-parents', `Commit '${id}' has more than one incoming branch link`, id);
411
+ }
412
+ const cut = g.cycleEdges[0];
413
+ if (cut !== undefined)
414
+ report(issues, 'git-cycle', `Git links form a cycle (cut at relation '${cut}')`, cut);
415
+ for (const s of g.strays) {
416
+ report(issues, 'git-commit-outside-lane', `Commit '${s.id}' is not contained by a branch${plane !== undefined ? ` on plane '${plane.id}'` : ''}`, s.id);
417
+ }
418
+ for (const n of m.nodes) {
419
+ if (n.type !== GIT_STAGE_TYPE)
420
+ continue;
421
+ const from = stageCommit(n, 'from');
422
+ if (from === undefined) {
423
+ report(issues, 'git-stage-span', `Stage '${n.id}' names no 'from' commit in its metadata`, n.id);
424
+ continue;
425
+ }
426
+ for (const id of [from, stageCommit(n, 'to')]) {
427
+ if (id !== undefined && !isCommit(id))
428
+ report(issues, 'git-stage-span', `Stage '${n.id}' spans '${id}', which is not a commit`, n.id);
429
+ }
430
+ }
431
+ for (const n of m.nodes) {
432
+ if (n.type !== 'commit')
433
+ continue;
434
+ const raw = n.metadata?.['gap'];
435
+ if (raw === undefined)
436
+ continue;
437
+ const ok = (typeof raw === 'number' && Number.isInteger(raw) && raw >= 0) || (typeof raw === 'string' && /^\d+$/.test(raw));
438
+ if (!ok)
439
+ report(issues, 'git-gap', `Commit '${n.id}' has invalid gap '${String(raw)}'`, n.id);
440
+ }
441
+ }
442
+ /**
443
+ * Activity-diagram structure, keyed purely on node types — no plane or
444
+ * notation involvement (activity frames are ordinary vocabulary; several can
445
+ * share a canvas). Deliberately minimal: the studio save API rejects invalid
446
+ * models, so no rule here may make a normal editing sequence unsaveable —
447
+ * loose leaf elements (palette drops not yet homed) are legal.
448
+ */
449
+ function validateActivity(ctx) {
450
+ const { issues, m } = ctx;
451
+ const typeOf = new Map(m.nodes.map((n) => [n.id, n.type]));
452
+ const parentsOf = new Map();
453
+ for (const e of m.containment) {
454
+ // dangling ids are validateContainment's finding — don't double-report
455
+ if (!ctx.nodeIds.has(e.parent) || !ctx.nodeIds.has(e.child))
456
+ continue;
457
+ if (typeOf.get(e.parent) === 'activity-frame' && typeOf.get(e.child) !== 'activity-lane') {
458
+ report(issues, 'activity-frame-children', `Activity frame '${e.parent}' may contain only lanes; '${e.child}' is not an activity-lane`, e.child);
459
+ }
460
+ const ct = typeOf.get(e.child);
461
+ if (ct === 'activity-lane' || ct === 'activity-region') {
462
+ parentsOf.set(e.child, [...(parentsOf.get(e.child) ?? []), e.parent]);
463
+ }
464
+ }
465
+ for (const n of m.nodes) {
466
+ if (n.type === 'activity-lane') {
467
+ const ps = parentsOf.get(n.id) ?? [];
468
+ if (ps.length === 0) {
469
+ report(issues, 'activity-lane-parent', `Activity lane '${n.id}' must be contained by an activity-frame`, n.id);
470
+ }
471
+ for (const p of ps) {
472
+ if (typeOf.get(p) !== 'activity-frame') {
473
+ report(issues, 'activity-lane-parent', `Activity lane '${n.id}' has non-frame parent '${p}'`, n.id);
474
+ }
475
+ }
476
+ }
477
+ else if (n.type === 'activity-region') {
478
+ // a loose region is legal (mid-edit); only a WRONG parent is a defect
479
+ for (const p of parentsOf.get(n.id) ?? []) {
480
+ if (typeOf.get(p) !== 'activity-lane') {
481
+ report(issues, 'activity-region-parent', `Interruptible region '${n.id}' must sit inside a lane; parent '${p}' is not an activity-lane`, n.id);
482
+ }
483
+ }
484
+ }
485
+ }
486
+ }
487
+ /** Every containment edge on the active plane whose child is one of `ids` gets ONE
488
+ * issue (`code`, `message(child, parent)`): the notation's arrangement and a group
489
+ * want the same rectangle, so nothing in `ids` may be grouped. Untagged containment
490
+ * belongs to the default plane. */
491
+ function reportContained(ctx, plane, ids, code, message) {
492
+ const { issues, m } = ctx;
493
+ const defaultPlane = ctx.planes[0]?.id;
494
+ const active = plane?.id ?? defaultPlane;
495
+ const reported = new Set();
496
+ for (const e of m.containment) {
497
+ if ((e.plane ?? defaultPlane) !== active || !ids.has(e.child) || reported.has(e.child))
498
+ continue;
499
+ reported.add(e.child);
500
+ report(issues, code, message(e.child, e.parent), e.child);
501
+ }
502
+ }
503
+ /**
504
+ * Second-order conventions, applied wherever RENDERING would activate the
505
+ * profile — the same plane pick as validateGit: a plane's own `notation` wins,
506
+ * otherwise the model-level one; the FIRST such plane is the one read.
507
+ * The derivation never throws on a malformed graph, so each convention an
508
+ * author should hear about is an issue here.
509
+ */
510
+ function validateSecondOrder(ctx) {
511
+ const { issues, m } = ctx;
512
+ const plane = ctx.planes.find((p) => (p.notation ?? m.notation) === SECOND_ORDER_NOTATION);
513
+ const modelLevel = plane === undefined && ctx.planes.length === 0 && m.notation === SECOND_ORDER_NOTATION;
514
+ if (plane === undefined && !modelLevel)
515
+ return;
516
+ const soIds = new Set(m.nodes.filter(isSecondOrderNode).map((n) => n.id));
517
+ // An empty diagram — or one holding only non-second-order nodes, e.g. a
518
+ // stray comment — is where every second-order diagram starts, and the
519
+ // studio never opens a model that already has issues: this must wait for
520
+ // there to be a second-order node to judge before it can want a decision
521
+ // among them.
522
+ if (soIds.size > 0 && !m.nodes.some((n) => n.type === SO_DECISION_TYPE)) {
523
+ report(issues, 'so-no-decision', 'A second-order diagram needs at least one decision (a node of type so-decision)', plane?.id ?? m.id);
524
+ }
525
+ const { cycle, unreachable } = consequenceOrders(m);
526
+ if (cycle !== undefined) {
527
+ report(issues, 'so-cycle', `Consequences form a loop (${cycle.join(' → ')}); a feedback loop is a causal-loop diagram — use that notation for it`, cycle[0]);
528
+ }
529
+ for (const id of unreachable) {
530
+ report(issues, 'so-unreachable', `Consequence '${id}' follows from no decision`, id);
531
+ }
532
+ // Bands and groups want the same rectangle; a group spanning two bands has no
533
+ // sensible picture.
534
+ reportContained(ctx, plane, soIds, 'so-contained', (child, parent) => `'${child}' sits inside '${parent}'; decisions and consequences cannot be grouped in a second-order diagram`);
535
+ }
536
+ /**
537
+ * Fishbone conventions, applied wherever RENDERING would activate the profile —
538
+ * the same plane pick as validateGit. The tree derivation never throws and
539
+ * simply leaves a malformed node off the fish; here each such node gets ONE
540
+ * issue naming why, in this order: its own parent has the wrong type for it
541
+ * (`fb-misplaced`); it hangs on a sub-cause (`fb-too-deep`); its chain never
542
+ * reaches the effect (`fb-unattached`). A cause under a misplaced category is
543
+ * therefore unattached, and the category is the misplaced one.
544
+ */
545
+ function validateFishbone(ctx) {
546
+ const { issues, m } = ctx;
547
+ const plane = ctx.planes.find((p) => (p.notation ?? m.notation) === FISHBONE_NOTATION);
548
+ const modelLevel = plane === undefined && ctx.planes.length === 0 && m.notation === FISHBONE_NOTATION;
549
+ if (plane === undefined && !modelLevel)
550
+ return;
551
+ const fb = m.nodes.filter(isFishboneNode);
552
+ // An empty diagram — or one holding only a stray comment — is where every
553
+ // fishbone diagram starts, and the studio never opens a model that already
554
+ // has issues: the head is only wanted once there is something to hang on it.
555
+ if (fb.length === 0)
556
+ return;
557
+ const effects = fb.filter((n) => n.type === FB_EFFECT_TYPE);
558
+ if (effects.length === 0) {
559
+ report(issues, 'fb-no-effect', 'A fishbone diagram needs an effect (a node of type fb-effect) at its head', plane?.id ?? m.id);
560
+ }
561
+ for (const extra of effects.slice(1)) {
562
+ report(issues, 'fb-many-effects', `'${extra.id}' is a second effect; a fishbone diagram has one head`, extra.id);
563
+ }
564
+ // The same parent pick fishboneTree makes — shared, so the rule can't drift between the two.
565
+ const typeOf = new Map(fb.map((n) => [n.id, n.type]));
566
+ const parentOf = fishboneParents(m);
567
+ const tree = fishboneTree(m);
568
+ const onFish = new Set(tree.effect !== undefined ? [tree.effect] : []);
569
+ const subIds = new Set();
570
+ for (const c of tree.categories) {
571
+ onFish.add(c.id);
572
+ for (const cause of c.causes) {
573
+ onFish.add(cause.id);
574
+ for (const s of cause.subs) {
575
+ onFish.add(s);
576
+ subIds.add(s);
577
+ }
578
+ }
579
+ }
580
+ for (const n of fb) {
581
+ const parent = parentOf.get(n.id);
582
+ if (n.type === FB_EFFECT_TYPE) {
583
+ if (n.id === tree.effect && parent !== undefined) {
584
+ report(issues, 'fb-misplaced', `'${n.id}' is the effect and hangs on '${parent}'; the effect is the head, nothing explains it`, n.id);
585
+ }
586
+ continue;
587
+ }
588
+ if (onFish.has(n.id))
589
+ continue;
590
+ const parentType = parent !== undefined ? typeOf.get(parent) : undefined;
591
+ if (n.type === FB_CATEGORY_TYPE && parent !== undefined && parentType !== FB_EFFECT_TYPE) {
592
+ report(issues, 'fb-misplaced', `Category '${n.id}' hangs on '${parent}'; a category hangs on the effect`, n.id);
593
+ }
594
+ else if (n.type === FB_CAUSE_TYPE && parentType === FB_EFFECT_TYPE) {
595
+ report(issues, 'fb-misplaced', `Cause '${n.id}' hangs on the effect; a cause hangs on a category or on another cause`, n.id);
596
+ }
597
+ else if (n.type === FB_CAUSE_TYPE && parent !== undefined && subIds.has(parent)) {
598
+ report(issues, 'fb-too-deep', `'${n.id}' hangs on the sub-cause '${parent}'; three levels below the effect is the limit`, n.id);
599
+ }
600
+ else {
601
+ report(issues, 'fb-unattached', `'${n.id}' does not reach the effect`, n.id);
602
+ }
603
+ }
604
+ // The fish and a group want the same rectangle.
605
+ const fbIds = new Set(fb.map((n) => n.id));
606
+ reportContained(ctx, plane, fbIds, 'fb-contained', (child, parent) => `'${child}' sits inside '${parent}'; nothing on a fishbone diagram can be grouped`);
607
+ }
608
+ /**
609
+ * Threat-model conventions, applied wherever RENDERING would activate the
610
+ * notation (a plane's, or the model's with no planes) — the same plane pick as
611
+ * validateGit. Deliberately lax, as C4 is: boundaries nest, elements may hold
612
+ * elements. The one structural rule is that a data flow connects elements,
613
+ * never a boundary — a boundary is a line around things, and an arrow into it
614
+ * says nothing.
615
+ */
616
+ function validateThreatModel(ctx) {
617
+ const { issues, m } = ctx;
618
+ const plane = ctx.planes.find((p) => (p.notation ?? m.notation) === TM_NOTATION);
619
+ const modelLevel = plane === undefined && ctx.planes.length === 0 && m.notation === TM_NOTATION;
620
+ if (plane === undefined && !modelLevel)
621
+ return;
622
+ const boundaries = new Set(m.nodes.filter((n) => n.type === TM_BOUNDARY_TYPE).map((n) => n.id));
623
+ for (const r of m.relations) {
624
+ if (r.kind !== TM_FLOW_KIND)
625
+ continue;
626
+ const end = boundaries.has(r.from) ? r.from : boundaries.has(r.to) ? r.to : undefined;
627
+ if (end !== undefined)
628
+ report(issues, 'tm-flow-boundary', `Data flow '${r.id}' touches the trust boundary '${end}'; flows connect elements, a boundary only surrounds them`, r.id);
629
+ }
630
+ }
631
+ export function validate(m) {
632
+ const ctx = {
633
+ issues: [],
634
+ m,
635
+ planes: m.planes ?? [],
636
+ nodeIds: new Set(),
637
+ layerIds: new Set(),
638
+ planeIds: new Set(),
639
+ };
640
+ validateModelStyle(ctx);
641
+ validateLegend(ctx);
642
+ // Declarations (layers, planes) run before the consumers (nodes' plane/layer
643
+ // tags, containment/relation refs, plane hides) so the derived id sets are
644
+ // fully populated when cross-reference checks read them.
645
+ validateLayers(ctx);
646
+ validatePlanes(ctx);
647
+ validateNodes(ctx);
648
+ validatePlaneHides(ctx);
649
+ validateContainment(ctx);
650
+ validateRelations(ctx);
651
+ validateThreats(ctx);
652
+ validateCycles(ctx);
653
+ validateGit(ctx);
654
+ validateActivity(ctx);
655
+ validateSecondOrder(ctx);
656
+ validateFishbone(ctx);
657
+ validateThreatModel(ctx);
658
+ return ctx.issues;
659
+ }
660
+ function findContainmentCycle(edges) {
661
+ const children = childrenOf(edges);
662
+ const state = new Map();
663
+ const stack = [];
664
+ function dfs(id) {
665
+ if (state.get(id) === 'done')
666
+ return null;
667
+ if (state.get(id) === 'visiting') {
668
+ const start = stack.indexOf(id);
669
+ return [...stack.slice(start), id];
670
+ }
671
+ state.set(id, 'visiting');
672
+ stack.push(id);
673
+ for (const c of children.get(id) ?? []) {
674
+ const found = dfs(c);
675
+ if (found)
676
+ return found;
677
+ }
678
+ stack.pop();
679
+ state.set(id, 'done');
680
+ return null;
681
+ }
682
+ for (const parent of children.keys()) {
683
+ const found = dfs(parent);
684
+ if (found)
685
+ return found;
686
+ }
687
+ return null;
688
+ }
689
+ /** Legend checks. Registry ids (`item.type` / `item.kind`) are deliberately NOT
690
+ * validated — unknown ids are legal everywhere else and fall back silently. */
691
+ function validateLegend(ctx) {
692
+ const { m, issues } = ctx;
693
+ const l = m.legend;
694
+ if (l === undefined)
695
+ return;
696
+ if (typeof l !== 'object' || Array.isArray(l)) {
697
+ issues.push({ code: 'invalid-legend', message: 'Legend must be an object' });
698
+ return;
699
+ }
700
+ if (l.title !== undefined && (typeof l.title !== 'string' || l.title === '')) {
701
+ issues.push({ code: 'invalid-legend', message: `Legend has invalid title '${String(l.title)}'` });
702
+ }
703
+ if (l.position !== undefined && !LEGEND_POSITIONS.includes(l.position)) {
704
+ issues.push({ code: 'invalid-legend', message: `Legend has unknown position '${String(l.position)}'` });
705
+ }
706
+ if (l.show !== undefined) {
707
+ if (!Array.isArray(l.show)) {
708
+ issues.push({ code: 'invalid-legend', message: 'Legend show must be a list' });
709
+ }
710
+ else {
711
+ for (const s of l.show) {
712
+ if (!LEGEND_SECTIONS.includes(s)) {
713
+ issues.push({ code: 'invalid-legend', message: `Legend has unknown section '${String(s)}'` });
714
+ }
715
+ }
716
+ }
717
+ }
718
+ if (l.items !== undefined) {
719
+ if (!Array.isArray(l.items)) {
720
+ issues.push({ code: 'invalid-legend', message: 'Legend items must be a list' });
721
+ return;
722
+ }
723
+ l.items.forEach((item, i) => {
724
+ if (item === null || typeof item !== 'object') {
725
+ issues.push({ code: 'invalid-legend', message: `Legend item ${i} must be an object` });
726
+ return;
727
+ }
728
+ if (typeof item.label !== 'string' || item.label === '') {
729
+ issues.push({ code: 'invalid-legend', message: `Legend item ${i} needs a non-empty label` });
730
+ }
731
+ if (item.color !== undefined && typeof item.color !== 'string') {
732
+ issues.push({ code: 'invalid-legend', message: `Legend item ${i} has invalid color '${String(item.color)}'` });
733
+ }
734
+ });
735
+ }
736
+ }