@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,590 @@
1
+ import { FB_CATEGORY_TYPE, FB_CAUSE_OF_KIND, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_PRESETS, presetId } from './fishbone.js';
2
+ import { GIT_STAGE_TYPE } from './git.js';
3
+ import { SO_DECISION_TYPE, SO_LEADS_TO_KIND, consequenceTypeOf } from './second-order.js';
4
+ import { TM_BOUNDARY_TYPE, TM_ENTITY_TYPE, TM_FLOW_KIND, TM_NOTATION, TM_PROCESS_TYPE, TM_STORE_TYPE, } from './threat-model.js';
5
+ import { DiagramValidationError, validate } from './validate.js';
6
+ export class NodeRef {
7
+ id;
8
+ builder;
9
+ constructor(id, builder) {
10
+ this.id = id;
11
+ this.builder = builder;
12
+ }
13
+ /** children, optionally followed by a trailing `{ plane }` options object */
14
+ contains(...args) {
15
+ const last = args[args.length - 1];
16
+ const opts = last !== undefined && !(last instanceof NodeRef) ? last : undefined;
17
+ for (const child of args) {
18
+ if (child instanceof NodeRef)
19
+ this.builder.addContainment(this.id, child.id, opts?.plane);
20
+ }
21
+ return this;
22
+ }
23
+ /** A STRIDE finding against this element. On every node ref, not just the
24
+ * threat-model ones: threat-modelling an existing C4 or ER diagram annotates
25
+ * the nodes it already has. */
26
+ threat(opts) {
27
+ this.builder.addThreat({ node: this.id }, opts);
28
+ return this;
29
+ }
30
+ }
31
+ export class CommitRef extends NodeRef {
32
+ branch;
33
+ constructor(id, builder, branch) {
34
+ super(id, builder);
35
+ this.branch = branch;
36
+ }
37
+ }
38
+ /** One lane of a git graph. `commit()` chains from the lane's last commit; `merge()`
39
+ * adds a commit that absorbs another lane's. Both hand back CommitRefs, which are
40
+ * NodeRefs — relate them, layer them, describe them like any node. */
41
+ export class BranchRef extends NodeRef {
42
+ m;
43
+ count = 0;
44
+ latest;
45
+ constructor(id, m) {
46
+ super(id, m);
47
+ this.m = m;
48
+ }
49
+ commit(tagOrOpts = {}) {
50
+ const opts = typeof tagOrOpts === 'string' ? { tag: tagOrOpts } : tagOrOpts;
51
+ if (opts.from !== undefined && opts.from.branch === this) {
52
+ throw new Error(`branch '${this.id}': use commit() to continue a lane — 'from' must name a commit on another branch`);
53
+ }
54
+ const prev = this.latest;
55
+ const c = this.create(opts);
56
+ if (opts.from !== undefined)
57
+ this.m.relate(opts.from, c, { kind: 'branch' });
58
+ else if (prev !== undefined)
59
+ this.m.relate(prev, c, { kind: 'commit' });
60
+ return c;
61
+ }
62
+ merge(src, opts = {}) {
63
+ if (src.branch === this)
64
+ throw new Error(`branch '${this.id}': cannot merge a lane into itself`);
65
+ const prev = this.latest;
66
+ const c = this.create(opts);
67
+ this.m.relate(src, c, { kind: 'merge' });
68
+ if (prev !== undefined)
69
+ this.m.relate(prev, c, { kind: 'commit' });
70
+ return c;
71
+ }
72
+ create(opts) {
73
+ this.count += 1;
74
+ const id = opts.id ?? `${this.id}-${this.count}`;
75
+ this.m.node(id, {
76
+ type: 'commit',
77
+ name: opts.tag ?? '',
78
+ ...(opts.color !== undefined ? { color: opts.color } : {}),
79
+ ...(opts.gap !== undefined && opts.gap > 0 ? { metadata: { gap: opts.gap } } : {}),
80
+ });
81
+ this.m.addContainment(this.id, id);
82
+ const ref = new CommitRef(id, this.m, this);
83
+ this.latest = ref;
84
+ return ref;
85
+ }
86
+ }
87
+ export class GitGraphBuilder {
88
+ m;
89
+ constructor(m) {
90
+ this.m = m;
91
+ }
92
+ /** A named frame across every lane, spanning the columns of `from`…`to` —
93
+ * a phase of the history ("Development", "Release candidates"). Declare it
94
+ * after the commits it names. */
95
+ stage(id, opts) {
96
+ return this.m.node(id, {
97
+ type: GIT_STAGE_TYPE,
98
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
99
+ ...(opts.color !== undefined ? { color: opts.color } : {}),
100
+ metadata: { from: opts.from.id, ...(opts.to !== undefined ? { to: opts.to.id } : {}) },
101
+ });
102
+ }
103
+ /** lanes are drawn top-to-bottom in the order they are declared */
104
+ branch(id, opts = {}) {
105
+ this.m.node(id, {
106
+ type: 'branch',
107
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
108
+ ...(opts.color !== undefined ? { color: opts.color } : {}),
109
+ });
110
+ return new BranchRef(id, this.m);
111
+ }
112
+ }
113
+ /** A decision or a consequence. `then()` IS the method of second-order
114
+ * thinking — "and then what?" — so a chain of calls reads as the reasoning. */
115
+ export class ConsequenceRef extends NodeRef {
116
+ m;
117
+ constructor(id, m) {
118
+ super(id, m);
119
+ this.m = m;
120
+ }
121
+ /** what follows from this: a new consequence, and the arrow that leads to it */
122
+ then(id, name, opts = {}) {
123
+ const { valence, label, ...rest } = opts;
124
+ this.m.node(id, { type: consequenceTypeOf(valence ?? '0'), ...(name !== undefined ? { name } : {}), ...rest });
125
+ const ref = new ConsequenceRef(id, this.m);
126
+ this.leadsTo(ref, label !== undefined ? { label } : {});
127
+ return ref;
128
+ }
129
+ /** join two branches: this also leads to a consequence declared elsewhere */
130
+ leadsTo(to, opts = {}) {
131
+ this.m.relate(this, to, { kind: SO_LEADS_TO_KIND, ...(opts.label !== undefined ? { label: opts.label } : {}) });
132
+ return this;
133
+ }
134
+ }
135
+ export class SecondOrderBuilder {
136
+ m;
137
+ constructor(m) {
138
+ this.m = m;
139
+ }
140
+ /** the root of a tree; several decisions share one set of bands */
141
+ decision(id, name, opts = {}) {
142
+ this.m.node(id, { type: SO_DECISION_TYPE, ...(name !== undefined ? { name } : {}), ...opts });
143
+ return new ConsequenceRef(id, this.m);
144
+ }
145
+ }
146
+ /** A cause (level 2) or a sub-cause (level 3). The level rides on the ref so a
147
+ * fourth `.cause()` fails at build time, where the author is, rather than as a
148
+ * validation issue at compile time. */
149
+ export class CauseRef extends NodeRef {
150
+ m;
151
+ level;
152
+ constructor(id, m, level) {
153
+ super(id, m);
154
+ this.m = m;
155
+ this.level = level;
156
+ }
157
+ /** a sub-cause of this cause, and the arrow from it to here */
158
+ cause(id, name, opts = {}) {
159
+ if (this.level === 3) {
160
+ throw new Error(`fishbone: '${id}' would be a fourth level below the effect; three levels (category, cause, sub-cause) is the limit`);
161
+ }
162
+ this.m.node(id, { type: FB_CAUSE_TYPE, ...(name !== undefined ? { name } : {}), ...opts });
163
+ const ref = new CauseRef(id, this.m, 3);
164
+ this.m.relate(ref, this, { kind: FB_CAUSE_OF_KIND });
165
+ return ref;
166
+ }
167
+ }
168
+ export class CategoryRef extends NodeRef {
169
+ m;
170
+ constructor(id, m) {
171
+ super(id, m);
172
+ this.m = m;
173
+ }
174
+ /** a cause on this bone, and the arrow from it to here */
175
+ cause(id, name, opts = {}) {
176
+ this.m.node(id, { type: FB_CAUSE_TYPE, ...(name !== undefined ? { name } : {}), ...opts });
177
+ const ref = new CauseRef(id, this.m, 2);
178
+ this.m.relate(ref, this, { kind: FB_CAUSE_OF_KIND });
179
+ return ref;
180
+ }
181
+ }
182
+ export class FishboneBuilder {
183
+ m;
184
+ effect;
185
+ constructor(m, effect) {
186
+ this.m = m;
187
+ this.effect = effect;
188
+ }
189
+ /** a major bone, and the arrow from it to the effect */
190
+ category(id, name, opts = {}) {
191
+ this.m.node(id, { type: FB_CATEGORY_TYPE, ...(name !== undefined ? { name } : {}), ...opts });
192
+ const ref = new CategoryRef(id, this.m);
193
+ this.m.relate(ref, this.effect, { kind: FB_CAUSE_OF_KIND });
194
+ return ref;
195
+ }
196
+ /** the bones of a standard set, keyed by their slug ids (`presetId`) */
197
+ categories(preset) {
198
+ return Object.fromEntries(FISHBONE_PRESETS[preset].map((name) => {
199
+ const id = presetId(name);
200
+ return [id, this.category(id, name)];
201
+ }));
202
+ }
203
+ }
204
+ /** A data flow: a relation ref that takes threats, the way a NodeRef does. */
205
+ export class FlowRef {
206
+ id;
207
+ m;
208
+ constructor(id, m) {
209
+ this.id = id;
210
+ this.m = m;
211
+ }
212
+ threat(opts) {
213
+ this.m.addThreat({ relation: this.id }, opts);
214
+ return this;
215
+ }
216
+ }
217
+ /** The four DFD element kinds plus flows. Everything it hands back is an
218
+ * ordinary NodeRef/FlowRef, so the rest of the builder — contains, relate,
219
+ * layers, threat — composes with it unchanged. */
220
+ export class ThreatModelBuilder {
221
+ m;
222
+ constructor(m) {
223
+ this.m = m;
224
+ }
225
+ element(type, id, name, opts) {
226
+ return this.m.node(id, { type, ...(name !== undefined ? { name } : {}), ...opts });
227
+ }
228
+ /** an external entity: a user, a third party, anything outside the system */
229
+ entity(id, name, opts = {}) {
230
+ return this.element(TM_ENTITY_TYPE, id, name, opts);
231
+ }
232
+ /** a process: something the system does with the data */
233
+ process(id, name, opts = {}) {
234
+ return this.element(TM_PROCESS_TYPE, id, name, opts);
235
+ }
236
+ /** a data store: where the data rests */
237
+ store(id, name, opts = {}) {
238
+ return this.element(TM_STORE_TYPE, id, name, opts);
239
+ }
240
+ /** a trust boundary: nest elements with `.contains()` */
241
+ boundary(id, name, opts = {}) {
242
+ return this.element(TM_BOUNDARY_TYPE, id, name, opts);
243
+ }
244
+ /** a data flow; a string is its label */
245
+ flow(from, to, labelOrOpts = {}) {
246
+ const opts = typeof labelOrOpts === 'string' ? { label: labelOrOpts } : labelOrOpts;
247
+ return new FlowRef(this.m.addRelation(from, to, { kind: TM_FLOW_KIND, ...opts }), this.m);
248
+ }
249
+ }
250
+ /** Shared element surface of a lane and a region: each helper creates a typed
251
+ * node contained by this scope and hands back a plain NodeRef, so everything
252
+ * composes with the rest of the builder (relate, layers, contains). */
253
+ export class ActivityScope extends NodeRef {
254
+ m;
255
+ counters = new Map();
256
+ constructor(id, m) {
257
+ super(id, m);
258
+ this.m = m;
259
+ }
260
+ /** `${scopeId}-<suffix>` for the first of a kind, `-<n>` after — deterministic
261
+ * from declaration order, so layout-overlay keys stay stable. */
262
+ autoId(suffix) {
263
+ const n = (this.counters.get(suffix) ?? 0) + 1;
264
+ this.counters.set(suffix, n);
265
+ return n === 1 ? `${this.id}-${suffix}` : `${this.id}-${suffix}-${n}`;
266
+ }
267
+ element(id, type, name, opts = {}) {
268
+ const ref = this.m.node(id, { type, name, ...(opts.color !== undefined ? { color: opts.color } : {}) });
269
+ this.m.addContainment(this.id, id);
270
+ return ref;
271
+ }
272
+ action(id, name, opts) {
273
+ return this.element(id, 'activity-action', name, opts);
274
+ }
275
+ object(id, name, opts) {
276
+ return this.element(id, 'activity-object', name, opts);
277
+ }
278
+ send(id, name, opts) {
279
+ return this.element(id, 'activity-send', name, opts);
280
+ }
281
+ receive(id, name, opts) {
282
+ return this.element(id, 'activity-receive', name, opts);
283
+ }
284
+ note(id, text) {
285
+ return this.element(id, 'activity-note', text);
286
+ }
287
+ decision(id, name = '') {
288
+ return this.element(id ?? this.autoId('decision'), 'activity-decision', name);
289
+ }
290
+ bar(id) {
291
+ return this.element(id ?? this.autoId('bar'), 'activity-bar', '');
292
+ }
293
+ start(id) {
294
+ return this.element(id ?? this.autoId('start'), 'activity-start', '');
295
+ }
296
+ end(id) {
297
+ return this.element(id ?? this.autoId('end'), 'activity-end', '');
298
+ }
299
+ }
300
+ export class RegionRef extends ActivityScope {
301
+ }
302
+ export class LaneRef extends ActivityScope {
303
+ /** interruptible region: a dashed container inside this lane */
304
+ region(id, name = '') {
305
+ const rid = id ?? this.autoId('region');
306
+ this.element(rid, 'activity-region', name);
307
+ return new RegionRef(rid, this.m);
308
+ }
309
+ }
310
+ /** One activity diagram: the frame node itself (this IS its NodeRef) plus lane
311
+ * and cross-lane flow helpers. Call m.activity() once per frame — several
312
+ * frames coexist on one canvas. */
313
+ export class ActivityBuilder extends NodeRef {
314
+ b;
315
+ constructor(id, b) {
316
+ super(id, b);
317
+ this.b = b;
318
+ }
319
+ /** lanes are drawn top-to-bottom in the order they are declared */
320
+ lane(id, opts = {}) {
321
+ this.b.node(id, {
322
+ type: 'activity-lane',
323
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
324
+ ...(opts.color !== undefined ? { color: opts.color } : {}),
325
+ });
326
+ this.b.addContainment(this.id, id);
327
+ return new LaneRef(id, this.b);
328
+ }
329
+ flow(from, to, label) {
330
+ this.b.relate(from, to, { kind: 'control', ...(label !== undefined ? { label } : {}) });
331
+ return this;
332
+ }
333
+ objectFlow(from, to, label) {
334
+ this.b.relate(from, to, { kind: 'object-flow', ...(label !== undefined ? { label } : {}) });
335
+ return this;
336
+ }
337
+ interrupt(from, to, label) {
338
+ this.b.relate(from, to, { kind: 'interrupt', ...(label !== undefined ? { label } : {}) });
339
+ return this;
340
+ }
341
+ noteLink(note, target) {
342
+ this.b.relate(note, target, { kind: 'note-link' });
343
+ return this;
344
+ }
345
+ }
346
+ export class ModelBuilder {
347
+ id;
348
+ name;
349
+ nodes = [];
350
+ containment = [];
351
+ relations = [];
352
+ layers = [];
353
+ planes = [];
354
+ legendConfig;
355
+ typeColorMap;
356
+ layerRuleList;
357
+ modelNotation;
358
+ modelStyle;
359
+ pairCounters = new Map();
360
+ git;
361
+ so;
362
+ fb;
363
+ tm;
364
+ constructor(id, name) {
365
+ this.id = id;
366
+ this.name = name;
367
+ }
368
+ node(id, opts = {}) {
369
+ const { name, ...rest } = opts;
370
+ this.nodes.push({ id, name: name ?? id, ...pruneUndefined(rest) });
371
+ return new NodeRef(id, this);
372
+ }
373
+ /** ER table: a node of type 'db-table' carrying `columns`. */
374
+ table(id, opts) {
375
+ return this.node(id, { type: 'db-table', ...opts });
376
+ }
377
+ /**
378
+ * Foreign key: a `kind:'fk'` relation from `from.fromColumn` to `to.toColumn`.
379
+ * `toColumn` defaults to the target table's single primary-key column; declare
380
+ * the target table (with its PK) before calling.
381
+ */
382
+ fk(from, fromColumn, to, toColumn, opts = {}) {
383
+ let resolved = toColumn;
384
+ if (resolved === undefined) {
385
+ const target = this.nodes.find((n) => n.id === to.id);
386
+ const pks = (target?.columns ?? []).filter((c) => c.pk === true);
387
+ if (pks.length !== 1) {
388
+ throw new Error(`m.fk: target table '${to.id}' must have exactly one primary-key column (or pass toColumn); found ${pks.length}`);
389
+ }
390
+ resolved = pks[0].name;
391
+ }
392
+ return this.relate(from, to, { kind: 'fk', ...opts, fromColumn, toColumn: resolved });
393
+ }
394
+ /** internal — used by NodeRef */
395
+ addContainment(parent, child, plane) {
396
+ const exists = this.containment.some((e) => e.parent === parent && e.child === child && e.plane === plane);
397
+ if (!exists)
398
+ this.containment.push({ parent, child, ...pruneUndefined({ plane }) });
399
+ }
400
+ /** internal — appends a threat to the node or relation `target` names; used by
401
+ * NodeRef.threat() and FlowRef.threat() */
402
+ addThreat(target, opts) {
403
+ const element = 'node' in target
404
+ ? this.nodes.find((n) => n.id === target.node)
405
+ : this.relations.find((r) => r.id === target.relation);
406
+ if (element === undefined) {
407
+ throw new Error('node' in target
408
+ ? `threat(): unknown node '${target.node}'`
409
+ : `threat(): unknown relation '${target.relation}'`);
410
+ }
411
+ const threats = element.threats ?? [];
412
+ const { id, category, title, ...rest } = opts;
413
+ // Synthesized from the list's length, not a model-wide counter: an id only
414
+ // has to be unique within its own element (see Threat.id), so two elements'
415
+ // first findings are both `t1` and neither shifts when the other changes.
416
+ const threat = { id: id ?? `t${threats.length + 1}`, category, title, ...pruneUndefined(rest) };
417
+ if (threats.some((t) => t.id === threat.id)) {
418
+ throw new Error(`threat(): duplicate threat id '${threat.id}' on '${element.id}'`);
419
+ }
420
+ element.threats = [...threats, threat];
421
+ }
422
+ relate(from, to, opts) {
423
+ this.addRelation(from, to, opts);
424
+ return this;
425
+ }
426
+ /** internal — like relate(), but returns the new relation's id (FlowRef needs
427
+ * it to hang threats off the flow) */
428
+ addRelation(from, to, opts) {
429
+ const pair = `${from.id}->${to.id}`;
430
+ const n = this.pairCounters.get(pair) ?? 0;
431
+ this.pairCounters.set(pair, n + 1);
432
+ const { kind, id, ...rest } = opts;
433
+ const relationId = id ?? `${pair}#${n}`;
434
+ this.relations.push({
435
+ id: relationId,
436
+ from: from.id,
437
+ to: to.id,
438
+ kind,
439
+ ...pruneUndefined(rest),
440
+ });
441
+ return relationId;
442
+ }
443
+ layer(id, opts = {}) {
444
+ this.layers.push({ id, name: opts.name ?? id, ...pruneUndefined({ tint: opts.tint }) });
445
+ return this;
446
+ }
447
+ plane(id, opts = {}) {
448
+ this.planes.push({
449
+ id,
450
+ name: opts.name ?? id,
451
+ ...pruneUndefined({
452
+ containmentOf: opts.containmentOf,
453
+ layers: opts.layers,
454
+ baseRelations: opts.baseRelations,
455
+ notation: opts.notation,
456
+ hides: opts.hides,
457
+ hidesTree: opts.hidesTree,
458
+ }),
459
+ });
460
+ return this;
461
+ }
462
+ /**
463
+ * Declare this model a git graph: a plane with the `git-graph` notation that
464
+ * must be the default (first-declared) plane, so the lanes' containment needs
465
+ * no plane tag. Returns the builder for lanes; see BranchRef.
466
+ */
467
+ gitGraph(opts = {}) {
468
+ if (this.git !== undefined)
469
+ throw new Error('gitGraph() already declared');
470
+ if (this.planes.length > 0) {
471
+ throw new Error('gitGraph() must come before plane(): the git plane has to be the default (first-declared) plane');
472
+ }
473
+ this.plane(opts.plane ?? 'git-graph', { name: opts.name ?? 'Git graph', notation: 'git-graph' });
474
+ this.git = new GitGraphBuilder(this);
475
+ return this.git;
476
+ }
477
+ /**
478
+ * Declare a second-order thinking diagram. With no `plane` the NOTATION is
479
+ * model-wide (nothing about it needs a plane — the notation is flat); name a
480
+ * plane to keep it beside other views of the same model.
481
+ */
482
+ secondOrder(opts = {}) {
483
+ if (this.so !== undefined)
484
+ throw new Error('secondOrder() already declared');
485
+ if (opts.plane !== undefined)
486
+ this.plane(opts.plane, { name: opts.name ?? 'Consequences', notation: 'second-order' });
487
+ else
488
+ this.notation('second-order');
489
+ this.so = new SecondOrderBuilder(this);
490
+ return this.so;
491
+ }
492
+ /**
493
+ * Declare a fishbone diagram: the effect at the head, then `.category()` /
494
+ * `.cause()` to hang bones on it. With no `plane` the NOTATION is model-wide
495
+ * (the notation is flat); name a plane to keep it beside other views of the
496
+ * same model. The effect's own options ride in `opts` too.
497
+ */
498
+ fishbone(id, name, opts = {}) {
499
+ if (this.fb !== undefined)
500
+ throw new Error('fishbone() already declared');
501
+ const { plane, planeName, ...rest } = opts;
502
+ if (plane !== undefined)
503
+ this.plane(plane, { name: planeName ?? 'Causes', notation: 'fishbone' });
504
+ else
505
+ this.notation('fishbone');
506
+ const effect = this.node(id, { type: FB_EFFECT_TYPE, ...(name !== undefined ? { name } : {}), ...rest });
507
+ this.fb = new FishboneBuilder(this, effect);
508
+ return this.fb;
509
+ }
510
+ /**
511
+ * Declare a threat model (STRIDE data-flow diagram). With no `plane` the
512
+ * NOTATION is model-wide; name a plane to threat-model an existing
513
+ * architecture beside its other views — the plane holds its own boundary
514
+ * containment over the same nodes.
515
+ */
516
+ threatModel(opts = {}) {
517
+ if (this.tm !== undefined)
518
+ throw new Error('threatModel() already declared');
519
+ if (opts.plane !== undefined)
520
+ this.plane(opts.plane, { name: opts.name ?? 'Threat model', notation: TM_NOTATION });
521
+ else
522
+ this.notation(TM_NOTATION);
523
+ this.tm = new ThreatModelBuilder(this);
524
+ return this.tm;
525
+ }
526
+ /** Declare an activity diagram: a framed swimlane flow. Repeatable — each
527
+ * call is one frame; frames are ordinary containers on whatever plane the
528
+ * model uses (no notation, no plane creation). */
529
+ activity(id, opts = {}) {
530
+ this.node(id, { type: 'activity-frame', ...(opts.name !== undefined ? { name: opts.name } : {}) });
531
+ return new ActivityBuilder(id, this);
532
+ }
533
+ /** Declare a legend. Bare `legend()` means derived sections only. */
534
+ legend(opts = {}) {
535
+ this.legendConfig = opts;
536
+ return this;
537
+ }
538
+ /** Default accent colour per node type; `*` is the fallback for the rest.
539
+ * The one way to colour nodes a composed diagram did not author. Successive
540
+ * calls merge, last wins per key; a node's own `color` still wins over both. */
541
+ typeColors(map) {
542
+ this.typeColorMap = { ...(this.typeColorMap ?? {}), ...map };
543
+ return this;
544
+ }
545
+ /** Put unlayered relations on layers by class (`kind` and/or `style.color`),
546
+ * first match wins. The one way a composed diagram can layer relations an
547
+ * include brought in. Successive calls append; an explicit relation `layer`
548
+ * still beats every rule. */
549
+ layerRules(rules) {
550
+ this.layerRuleList = [...(this.layerRuleList ?? []), ...rules];
551
+ return this;
552
+ }
553
+ /** pin the whole diagram's visual language (see DiagramModel.notation) */
554
+ notation(id) {
555
+ this.modelNotation = id;
556
+ return this;
557
+ }
558
+ /** pin the model-level renderer style preset (see DiagramModel.style) */
559
+ style(id) {
560
+ this.modelStyle = id;
561
+ return this;
562
+ }
563
+ toJSON() {
564
+ const json = {
565
+ version: 1,
566
+ id: this.id,
567
+ name: this.name,
568
+ nodes: this.nodes,
569
+ containment: this.containment,
570
+ relations: this.relations,
571
+ layers: this.layers,
572
+ planes: this.planes,
573
+ ...(this.legendConfig !== undefined ? { legend: this.legendConfig } : {}),
574
+ ...(this.typeColorMap !== undefined ? { typeColors: this.typeColorMap } : {}),
575
+ ...(this.layerRuleList !== undefined ? { layerRules: this.layerRuleList } : {}),
576
+ ...(this.modelNotation !== undefined ? { notation: this.modelNotation } : {}),
577
+ ...(this.modelStyle !== undefined ? { style: this.modelStyle } : {}),
578
+ };
579
+ const issues = validate(json);
580
+ if (issues.length > 0)
581
+ throw new DiagramValidationError(issues);
582
+ return json;
583
+ }
584
+ }
585
+ export function model(id, opts = {}) {
586
+ return new ModelBuilder(id, opts.name ?? id);
587
+ }
588
+ function pruneUndefined(obj) {
589
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
590
+ }
@@ -0,0 +1,21 @@
1
+ import type { DiagramModel } from './types.js';
2
+ import type { CompiledView } from './view/types.js';
3
+ /**
4
+ * Build a parent → children index over a set of containment edges. Shared by the
5
+ * two cycle detectors — mutate's BFS-reachability guard (`wouldCycle`) and
6
+ * validate's DFS-coloring check (`findContainmentCycle`) — which agree on this
7
+ * map shape even though they walk it differently. Absent parents (nodes with no
8
+ * children) do not appear; callers read with `?? []`.
9
+ */
10
+ export declare function childrenOf(edges: DiagramModel['containment']): Map<string, string[]>;
11
+ /**
12
+ * Per-node hidden-descendant counts over a compiled view, for the collapse
13
+ * badge (`hiddenCount` on collapsed containers). A node's "hidden" set is
14
+ * exactly the model nodes that are not visible in the compiled view; each is
15
+ * attributed to the collapsed visible container that absorbed it. The compiled
16
+ * view does not expose anchors, so this approximates by subtree size: for a
17
+ * collapsed visible container, hidden descendants = reachable containment
18
+ * descendants that are not visible. Uses the shared `childrenOf` containment
19
+ * index — hierarchy knowledge lives in core, not the renderer.
20
+ */
21
+ export declare function countAnchored(model: DiagramModel, view: CompiledView): Map<string, number>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Build a parent → children index over a set of containment edges. Shared by the
3
+ * two cycle detectors — mutate's BFS-reachability guard (`wouldCycle`) and
4
+ * validate's DFS-coloring check (`findContainmentCycle`) — which agree on this
5
+ * map shape even though they walk it differently. Absent parents (nodes with no
6
+ * children) do not appear; callers read with `?? []`.
7
+ */
8
+ export function childrenOf(edges) {
9
+ const children = new Map();
10
+ for (const e of edges) {
11
+ children.set(e.parent, [...(children.get(e.parent) ?? []), e.child]);
12
+ }
13
+ return children;
14
+ }
15
+ /**
16
+ * Per-node hidden-descendant counts over a compiled view, for the collapse
17
+ * badge (`hiddenCount` on collapsed containers). A node's "hidden" set is
18
+ * exactly the model nodes that are not visible in the compiled view; each is
19
+ * attributed to the collapsed visible container that absorbed it. The compiled
20
+ * view does not expose anchors, so this approximates by subtree size: for a
21
+ * collapsed visible container, hidden descendants = reachable containment
22
+ * descendants that are not visible. Uses the shared `childrenOf` containment
23
+ * index — hierarchy knowledge lives in core, not the renderer.
24
+ */
25
+ export function countAnchored(model, view) {
26
+ const visible = new Set();
27
+ const walk = (n) => {
28
+ visible.add(n.id);
29
+ n.children.forEach(walk);
30
+ };
31
+ view.roots.forEach(walk);
32
+ const counts = new Map();
33
+ const children = childrenOf(model.containment);
34
+ const countHidden = (id) => {
35
+ let n = 0;
36
+ for (const c of children.get(id) ?? []) {
37
+ if (!visible.has(c))
38
+ n += 1 + countHidden(c);
39
+ }
40
+ return n;
41
+ };
42
+ for (const id of visible)
43
+ counts.set(id, countHidden(id));
44
+ return counts;
45
+ }