@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.
- package/LICENSE +709 -0
- package/README.md +27 -0
- package/dist/builder.d.ts +381 -0
- package/dist/builder.js +590 -0
- package/dist/children.d.ts +21 -0
- package/dist/children.js +45 -0
- package/dist/commands.d.ts +219 -0
- package/dist/commands.js +474 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +246 -0
- package/dist/drawings.d.ts +13 -0
- package/dist/drawings.js +36 -0
- package/dist/eject.d.ts +20 -0
- package/dist/eject.js +260 -0
- package/dist/fishbone.d.ts +66 -0
- package/dist/fishbone.js +95 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.js +159 -0
- package/dist/guards.d.ts +10 -0
- package/dist/guards.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +45 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +10 -0
- package/dist/layout-defaults.d.ts +20 -0
- package/dist/layout-defaults.js +20 -0
- package/dist/mutate.d.ts +106 -0
- package/dist/mutate.js +547 -0
- package/dist/second-order.d.ts +39 -0
- package/dist/second-order.js +86 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.js +25 -0
- package/dist/threat-model.d.ts +88 -0
- package/dist/threat-model.js +188 -0
- package/dist/types.d.ts +395 -0
- package/dist/types.js +27 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +13 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +736 -0
- package/dist/view/compile.d.ts +29 -0
- package/dist/view/compile.js +78 -0
- package/dist/view/edges.d.ts +4 -0
- package/dist/view/edges.js +118 -0
- package/dist/view/hierarchy.d.ts +41 -0
- package/dist/view/hierarchy.js +103 -0
- package/dist/view/layers.d.ts +7 -0
- package/dist/view/layers.js +17 -0
- package/dist/view/lod.d.ts +15 -0
- package/dist/view/lod.js +17 -0
- package/dist/view/scope.d.ts +23 -0
- package/dist/view/scope.js +106 -0
- package/dist/view/size.d.ts +8 -0
- package/dist/view/size.js +34 -0
- package/dist/view/tree.d.ts +12 -0
- package/dist/view/tree.js +147 -0
- package/dist/view/types.d.ts +68 -0
- package/dist/view/types.js +1 -0
- package/package.json +38 -0
package/dist/builder.js
ADDED
|
@@ -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>;
|
package/dist/children.js
ADDED
|
@@ -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
|
+
}
|