@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,106 @@
1
+ import { type Column, type DiagramLayer, type DiagramLegend, type DiagramModel, type DiagramNode, type DiagramPlane, type EdgeLabel, type FontScale, type Polarity, type RelationStyle, type StrideCategory, type TextAlign, type TextRun, type Threat, type ThreatSeverity, type ThreatStatus } from './types.js';
2
+ import type { ThreatTarget } from './threat-model.js';
3
+ export declare class CommandError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ export declare function uniqueNodeId(m: DiagramModel, base: string): string;
7
+ export declare function addNode(m: DiagramModel, node: DiagramNode): DiagramModel;
8
+ export declare function renameNode(m: DiagramModel, id: string, name: string): DiagramModel;
9
+ export declare function setNodeRich(m: DiagramModel, id: string, runs: TextRun[]): DiagramModel;
10
+ export interface NodeDetails {
11
+ type?: string | null;
12
+ icon?: string | null;
13
+ image?: string | null;
14
+ shape?: string | null;
15
+ color?: string | null;
16
+ textColor?: string | null;
17
+ technology?: string | null;
18
+ link?: string | null;
19
+ textAlign?: TextAlign | null;
20
+ fontScale?: FontScale | null;
21
+ description?: string | null;
22
+ metadata?: Record<string, unknown> | null;
23
+ plane?: string | null;
24
+ layer?: string | null;
25
+ }
26
+ export declare function setNodeDetails(m: DiagramModel, id: string, details: NodeDetails): DiagramModel;
27
+ /** Transitive containment descendants of `id` across every plane, plus `id`
28
+ * itself — the set a cascade delete destroys. */
29
+ export declare function subtreeOf(m: DiagramModel, id: string): Set<string>;
30
+ export declare function deleteNode(m: DiagramModel, id: string, cascade?: boolean): DiagramModel;
31
+ export declare function setTableColumns(m: DiagramModel, id: string, columns: Column[]): DiagramModel;
32
+ /** Patch for update-threat: `null` clears an optional field. `category` and
33
+ * `title` are required on a {@link Threat}, so they are set-only. */
34
+ export interface ThreatPatch {
35
+ category?: StrideCategory;
36
+ title?: string;
37
+ description?: string | null;
38
+ severity?: ThreatSeverity | null;
39
+ status?: ThreatStatus | null;
40
+ mitigation?: string | null;
41
+ }
42
+ export declare function addThreat(m: DiagramModel, target: ThreatTarget, threat: Threat): DiagramModel;
43
+ export declare function updateThreat(m: DiagramModel, target: ThreatTarget, id: string, patch: ThreatPatch): DiagramModel;
44
+ export declare function removeThreat(m: DiagramModel, target: ThreatTarget, id: string): DiagramModel;
45
+ export declare function addContainment(m: DiagramModel, parent: string, child: string, plane?: string): DiagramModel;
46
+ /**
47
+ * Group existing nodes under a new abstract parent: add `node`, then nest each
48
+ * member under it. `plane` scopes both the containment edges and (via the
49
+ * caller setting `node.plane`) the abstract node, so the grouping can live in a
50
+ * single plane's view. Atomic: a bad member id throws before any partial model
51
+ * escapes (the intermediate is a local value, never returned).
52
+ */
53
+ export declare function groupNodes(m: DiagramModel, node: DiagramNode, memberIds: readonly string[], plane?: string): DiagramModel;
54
+ export declare function removeContainment(m: DiagramModel, parent: string, child: string, plane?: string): DiagramModel;
55
+ /** Pin the diagram's visual style preset id, or clear it with null (the
56
+ * app-level preference applies again). Unknown ids are intentionally
57
+ * accepted — the renderer treats them as unpinned. */
58
+ export declare function setDiagramStyle(m: DiagramModel, style: string | null): DiagramModel;
59
+ /** Pin the diagram's notation id, or clear it with null. Unlike
60
+ * `setDiagramStyle`, unknown ids are rejected — a model-level notation drives
61
+ * structural validation rules the same way a plane's own notation does (e.g.
62
+ * `validateGit` in validate.ts resolves `plane.notation ?? model.notation`),
63
+ * so it must resolve to a known one. */
64
+ export declare function setDiagramNotation(m: DiagramModel, notation: string | null): DiagramModel;
65
+ /** Declare the diagram's legend, or clear it entirely with null. */
66
+ export declare function setDiagramLegend(m: DiagramModel, legend: DiagramLegend | null): DiagramModel;
67
+ export interface RelationOptsInput {
68
+ kind: string;
69
+ label?: string;
70
+ layer?: string;
71
+ description?: string;
72
+ style?: RelationStyle;
73
+ polarity?: Polarity;
74
+ delay?: boolean;
75
+ fromColumn?: string;
76
+ toColumn?: string;
77
+ }
78
+ export declare function addRelation(m: DiagramModel, from: string, to: string, opts: RelationOptsInput): {
79
+ model: DiagramModel;
80
+ id: string;
81
+ };
82
+ export interface RelationPatch {
83
+ /** move an endpoint (edge reconnection); the relation id is unchanged */
84
+ from?: string;
85
+ to?: string;
86
+ kind?: string;
87
+ label?: string | null;
88
+ labels?: EdgeLabel[] | null;
89
+ layer?: string | null;
90
+ description?: string | null;
91
+ style?: RelationStyle | null;
92
+ polarity?: Polarity | null;
93
+ delay?: boolean | null;
94
+ fromColumn?: string | null;
95
+ toColumn?: string | null;
96
+ }
97
+ export declare function updateRelation(m: DiagramModel, id: string, patch: RelationPatch): DiagramModel;
98
+ export declare function deleteRelation(m: DiagramModel, id: string): DiagramModel;
99
+ export declare function upsertLayer(m: DiagramModel, layer: DiagramLayer): DiagramModel;
100
+ export declare function deleteLayer(m: DiagramModel, id: string): DiagramModel;
101
+ export declare function mergeLayers(m: DiagramModel, sourceIds: string[], targetId?: string): DiagramModel;
102
+ export declare function upsertPlane(m: DiagramModel, plane: DiagramPlane): DiagramModel;
103
+ /** Add/remove a shared node from a plane's `hides`. A node already scoped to a
104
+ * plane is view-local, so hiding it is a no-op (validation flags a stray one). */
105
+ export declare function setNodePlaneHidden(m: DiagramModel, nodeId: string, planeId: string, hidden: boolean): DiagramModel;
106
+ export declare function deletePlane(m: DiagramModel, id: string): DiagramModel;
package/dist/mutate.js ADDED
@@ -0,0 +1,547 @@
1
+ import { BUILTIN_NOTATIONS, } from './types.js';
2
+ import { normalizeRuns, runsToPlainText } from './text.js';
3
+ import { childrenOf } from './children.js';
4
+ export class CommandError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'CommandError';
8
+ }
9
+ }
10
+ const requireNode = (m, id) => {
11
+ const n = m.nodes.find((x) => x.id === id);
12
+ if (n === undefined)
13
+ throw new CommandError(`Unknown node '${id}'`);
14
+ return n;
15
+ };
16
+ export function uniqueNodeId(m, base) {
17
+ const slug = base
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-+|-+$/g, '') || 'node';
21
+ const taken = new Set(m.nodes.map((n) => n.id));
22
+ if (!taken.has(slug))
23
+ return slug;
24
+ for (let i = 2;; i++) {
25
+ if (!taken.has(`${slug}-${i}`))
26
+ return `${slug}-${i}`;
27
+ }
28
+ }
29
+ export function addNode(m, node) {
30
+ if (m.nodes.some((n) => n.id === node.id))
31
+ throw new CommandError(`Duplicate node id '${node.id}'`);
32
+ return { ...m, nodes: [...m.nodes, node] };
33
+ }
34
+ export function renameNode(m, id, name) {
35
+ requireNode(m, id);
36
+ return {
37
+ ...m,
38
+ nodes: m.nodes.map((n) => {
39
+ if (n.id !== id)
40
+ return n;
41
+ const { rich: _rich, ...rest } = n;
42
+ return { ...rest, name };
43
+ }),
44
+ };
45
+ }
46
+ export function setNodeRich(m, id, runs) {
47
+ requireNode(m, id);
48
+ const norm = normalizeRuns(runs);
49
+ const name = runsToPlainText(norm);
50
+ const first = norm[0];
51
+ const isPlain = norm.length <= 1 && (first === undefined || (first.bold === undefined && first.italic === undefined));
52
+ return {
53
+ ...m,
54
+ nodes: m.nodes.map((n) => {
55
+ if (n.id !== id)
56
+ return n;
57
+ const { rich: _rich, ...rest } = n;
58
+ return isPlain ? { ...rest, name } : { ...rest, name, rich: norm };
59
+ }),
60
+ };
61
+ }
62
+ /** Whitelist of {@link NodeDetails} fields wired through the loop below. Every
63
+ * field is flat (optional, null-clearable), so one typed pass replaces the
64
+ * hand-rolled `applyNullable` chain. */
65
+ const NODE_DETAIL_KEYS = [
66
+ 'type',
67
+ 'icon',
68
+ 'image',
69
+ 'shape',
70
+ 'color',
71
+ 'textColor',
72
+ 'technology',
73
+ 'link',
74
+ 'textAlign',
75
+ 'fontScale',
76
+ 'description',
77
+ 'metadata',
78
+ 'plane',
79
+ 'layer',
80
+ ];
81
+ const _assertNodeKeyCoverage = true;
82
+ void _assertNodeKeyCoverage;
83
+ const applyNullable = (obj, key, value) => {
84
+ if (value === undefined)
85
+ return obj;
86
+ const next = { ...obj };
87
+ if (value === null)
88
+ delete next[key];
89
+ else
90
+ next[key] = value;
91
+ return next;
92
+ };
93
+ export function setNodeDetails(m, id, details) {
94
+ requireNode(m, id);
95
+ if (details.layer != null && !m.layers.some((l) => l.id === details.layer)) {
96
+ throw new CommandError(`Unknown layer '${details.layer}'`);
97
+ }
98
+ const nodes = m.nodes.map((n) => {
99
+ if (n.id !== id)
100
+ return n;
101
+ let next = { ...n };
102
+ // One typed loop over the field whitelist (see NODE_DETAIL_KEYS) instead of
103
+ // 12 hand-written applyNullable calls — adding a field wires automatically
104
+ // and the coverage const above makes an omission a compile error.
105
+ for (const key of NODE_DETAIL_KEYS) {
106
+ next = applyNullable(next, key, details[key]);
107
+ }
108
+ return next;
109
+ });
110
+ let next = { ...m, nodes };
111
+ // Scoping a node to a plane makes it view-local; drop it from every plane's
112
+ // `hides`/`hidesTree` so a formerly-hidden shared node doesn't linger there
113
+ // with no way to clear it via the UI (and to avoid tripping `redundant-hide`).
114
+ if (typeof details.plane === 'string') {
115
+ next = { ...next, planes: prunePlaneHides(next.planes, (h) => h === id) };
116
+ }
117
+ return next;
118
+ }
119
+ /** Drop every id satisfying `drop` from each plane's `hides`/`hidesTree`,
120
+ * omitting emptied lists; a plane with no change keeps its reference. */
121
+ function prunePlaneHides(planes, drop) {
122
+ const without = (list) => list === undefined ? undefined : list.filter((h) => !drop(h));
123
+ return planes.map((p) => {
124
+ const hides = without(p.hides);
125
+ const hidesTree = without(p.hidesTree);
126
+ if (hides?.length === p.hides?.length && hidesTree?.length === p.hidesTree?.length)
127
+ return p;
128
+ const { hides: _h, hidesTree: _t, ...rest } = p;
129
+ return {
130
+ ...rest,
131
+ ...(hides !== undefined && hides.length > 0 ? { hides } : {}),
132
+ ...(hidesTree !== undefined && hidesTree.length > 0 ? { hidesTree } : {}),
133
+ };
134
+ });
135
+ }
136
+ /** Transitive containment descendants of `id` across every plane, plus `id`
137
+ * itself — the set a cascade delete destroys. */
138
+ export function subtreeOf(m, id) {
139
+ const children = childrenOf(m.containment);
140
+ const doomed = new Set();
141
+ const stack = [id];
142
+ while (stack.length > 0) {
143
+ const cur = stack.pop();
144
+ if (cur === undefined || doomed.has(cur))
145
+ continue;
146
+ doomed.add(cur);
147
+ stack.push(...(children.get(cur) ?? []));
148
+ }
149
+ return doomed;
150
+ }
151
+ export function deleteNode(m, id, cascade = false) {
152
+ requireNode(m, id);
153
+ const doomed = cascade ? subtreeOf(m, id) : new Set([id]);
154
+ return {
155
+ ...m,
156
+ nodes: m.nodes.filter((n) => !doomed.has(n.id)),
157
+ containment: m.containment.filter((e) => !doomed.has(e.parent) && !doomed.has(e.child)),
158
+ relations: m.relations.filter((r) => !doomed.has(r.from) && !doomed.has(r.to)),
159
+ // A destroyed node must not linger in any plane's hides/hidesTree — the
160
+ // stale entry would fail `unknown-hidden-node` validation on the next save.
161
+ planes: prunePlaneHides(m.planes, (h) => doomed.has(h)),
162
+ };
163
+ }
164
+ export function setTableColumns(m, id, columns) {
165
+ requireNode(m, id);
166
+ // Deliberately NOT rejecting duplicate names: edit-mode inputs commit per
167
+ // keystroke, so a transient collision must not throw. `validate` reports
168
+ // duplicate-column at publish time.
169
+ return { ...m, nodes: m.nodes.map((n) => (n.id === id ? { ...n, columns } : n)) };
170
+ }
171
+ /** Whitelist of the null-clearable {@link ThreatPatch} fields, applied through
172
+ * `applyNullable` below — same shape as RELATION_NULLABLE_KEYS. */
173
+ const THREAT_NULLABLE_KEYS = ['description', 'severity', 'status', 'mitigation'];
174
+ const _assertThreatKeyCoverage = true;
175
+ void _assertThreatKeyCoverage;
176
+ /**
177
+ * Apply `fn` to the threat list of the element `target` names. Threats hang off
178
+ * nodes and relations alike and the list is identical on both, so one seam
179
+ * serves the two; only the named element is replaced, every sibling keeps its
180
+ * reference. A result with no threats drops the key entirely, keeping saved
181
+ * files free of empty arrays.
182
+ */
183
+ function mapThreats(m, target, fn) {
184
+ const next = (items, id, what) => {
185
+ if (!items.some((x) => x.id === id))
186
+ throw new CommandError(`Unknown ${what} '${id}'`);
187
+ return items.map((x) => {
188
+ if (x.id !== id)
189
+ return x;
190
+ const threats = fn(x.threats ?? []);
191
+ const { threats: _dropped, ...rest } = x;
192
+ return (threats.length === 0 ? rest : { ...rest, threats });
193
+ });
194
+ };
195
+ return 'node' in target
196
+ ? { ...m, nodes: next(m.nodes, target.node, 'node') }
197
+ : { ...m, relations: next(m.relations, target.relation, 'relation') };
198
+ }
199
+ export function addThreat(m, target, threat) {
200
+ return mapThreats(m, target, (threats) => {
201
+ if (threats.some((t) => t.id === threat.id))
202
+ throw new CommandError(`Duplicate threat id '${threat.id}'`);
203
+ return [...threats, threat];
204
+ });
205
+ }
206
+ export function updateThreat(m, target, id, patch) {
207
+ return mapThreats(m, target, (threats) => {
208
+ if (!threats.some((t) => t.id === id))
209
+ throw new CommandError(`Unknown threat '${id}'`);
210
+ return threats.map((t) => {
211
+ if (t.id !== id)
212
+ return t;
213
+ let out = { ...t };
214
+ if (patch.category !== undefined)
215
+ out.category = patch.category;
216
+ if (patch.title !== undefined)
217
+ out.title = patch.title;
218
+ for (const key of THREAT_NULLABLE_KEYS) {
219
+ out = applyNullable(out, key, patch[key]);
220
+ }
221
+ return out;
222
+ });
223
+ });
224
+ }
225
+ export function removeThreat(m, target, id) {
226
+ return mapThreats(m, target, (threats) => {
227
+ if (!threats.some((t) => t.id === id))
228
+ throw new CommandError(`Unknown threat '${id}'`);
229
+ return threats.filter((t) => t.id !== id);
230
+ });
231
+ }
232
+ function wouldCycle(m, parent, child, plane) {
233
+ const defaultPlane = (m.planes ?? [])[0]?.id;
234
+ const key = plane ?? defaultPlane;
235
+ // Children index over this plane's edges, plus the candidate edge being added.
236
+ const children = childrenOf(m.containment.filter((e) => (e.plane ?? defaultPlane) === key));
237
+ children.set(parent, [...(children.get(parent) ?? []), child]);
238
+ // child must not reach parent
239
+ const stack = [child];
240
+ const seen = new Set();
241
+ while (stack.length > 0) {
242
+ const cur = stack.pop();
243
+ if (cur === undefined || seen.has(cur))
244
+ continue;
245
+ if (cur === parent && seen.size > 0)
246
+ return true;
247
+ seen.add(cur);
248
+ stack.push(...(children.get(cur) ?? []));
249
+ }
250
+ return false;
251
+ }
252
+ /**
253
+ * Resolve a plane argument to its canonical containment form: undefined stays
254
+ * undefined; a declared plane resolves its `containmentOf` borrow (one hop) and,
255
+ * if that lands on the first-declared (default) plane, collapses to `undefined`
256
+ * so edges are stored/compared in the untagged base form. Throws on unknowns.
257
+ */
258
+ function canonicalPlane(m, plane) {
259
+ if (plane === undefined)
260
+ return undefined;
261
+ const planes = m.planes ?? [];
262
+ const p = planes.find((x) => x.id === plane);
263
+ if (p === undefined)
264
+ throw new CommandError(`Unknown plane '${plane}'`);
265
+ const resolved = p.containmentOf ?? p.id;
266
+ return resolved === planes[0]?.id ? undefined : resolved;
267
+ }
268
+ export function addContainment(m, parent, child, plane) {
269
+ requireNode(m, parent);
270
+ requireNode(m, child);
271
+ if (parent === child)
272
+ throw new CommandError(`Node '${parent}' cannot contain itself`);
273
+ const canon = canonicalPlane(m, plane);
274
+ if (m.containment.some((e) => e.parent === parent && e.child === child && e.plane === canon))
275
+ return m;
276
+ if (wouldCycle(m, parent, child, canon)) {
277
+ throw new CommandError(`'${parent}' > '${child}' would create a containment cycle`);
278
+ }
279
+ return { ...m, containment: [...m.containment, { parent, child, ...(canon !== undefined ? { plane: canon } : {}) }] };
280
+ }
281
+ /**
282
+ * Group existing nodes under a new abstract parent: add `node`, then nest each
283
+ * member under it. `plane` scopes both the containment edges and (via the
284
+ * caller setting `node.plane`) the abstract node, so the grouping can live in a
285
+ * single plane's view. Atomic: a bad member id throws before any partial model
286
+ * escapes (the intermediate is a local value, never returned).
287
+ */
288
+ export function groupNodes(m, node, memberIds, plane) {
289
+ let next = addNode(m, node);
290
+ for (const child of memberIds) {
291
+ next = addContainment(next, node.id, child, plane);
292
+ }
293
+ return next;
294
+ }
295
+ export function removeContainment(m, parent, child, plane) {
296
+ const canon = canonicalPlane(m, plane);
297
+ return {
298
+ ...m,
299
+ containment: m.containment.filter((e) => !(e.parent === parent && e.child === child && e.plane === canon)),
300
+ };
301
+ }
302
+ /** Pin the diagram's visual style preset id, or clear it with null (the
303
+ * app-level preference applies again). Unknown ids are intentionally
304
+ * accepted — the renderer treats them as unpinned. */
305
+ export function setDiagramStyle(m, style) {
306
+ if (style === null) {
307
+ const { style: _dropped, ...rest } = m;
308
+ return rest;
309
+ }
310
+ return { ...m, style };
311
+ }
312
+ /** Pin the diagram's notation id, or clear it with null. Unlike
313
+ * `setDiagramStyle`, unknown ids are rejected — a model-level notation drives
314
+ * structural validation rules the same way a plane's own notation does (e.g.
315
+ * `validateGit` in validate.ts resolves `plane.notation ?? model.notation`),
316
+ * so it must resolve to a known one. */
317
+ export function setDiagramNotation(m, notation) {
318
+ if (notation === null) {
319
+ if (m.notation === undefined)
320
+ return m;
321
+ const { notation: _dropped, ...rest } = m;
322
+ return rest;
323
+ }
324
+ if (!BUILTIN_NOTATIONS.includes(notation)) {
325
+ throw new CommandError(`Unknown notation '${notation}'`);
326
+ }
327
+ return m.notation === notation ? m : { ...m, notation };
328
+ }
329
+ /** Declare the diagram's legend, or clear it entirely with null. */
330
+ export function setDiagramLegend(m, legend) {
331
+ if (legend === null) {
332
+ const { legend: _dropped, ...rest } = m;
333
+ return rest;
334
+ }
335
+ return { ...m, legend };
336
+ }
337
+ export function addRelation(m, from, to, opts) {
338
+ requireNode(m, from);
339
+ requireNode(m, to);
340
+ if (opts.layer !== undefined && !m.layers.some((l) => l.id === opts.layer)) {
341
+ throw new CommandError(`Unknown layer '${opts.layer}'`);
342
+ }
343
+ // First free suffix — counting existing pairs collides after a middle delete
344
+ // (delete `a->b#0`, then adding again would reuse `#1`).
345
+ let i = 0;
346
+ while (m.relations.some((r) => r.id === `${from}->${to}#${i}`))
347
+ i++;
348
+ const id = `${from}->${to}#${i}`;
349
+ const { kind, ...rest } = opts;
350
+ const relation = {
351
+ id,
352
+ from,
353
+ to,
354
+ kind,
355
+ ...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
356
+ };
357
+ return { model: { ...m, relations: [...m.relations, relation] }, id };
358
+ }
359
+ /** Whitelist of {@link RelationPatch} fields that are null-clearable and applied
360
+ * through the loop below. `from`/`to`/`kind` are excluded — they are required
361
+ * relation keys set with a plain `!== undefined` guard, never null-cleared. */
362
+ const RELATION_NULLABLE_KEYS = [
363
+ 'label',
364
+ 'labels',
365
+ 'layer',
366
+ 'description',
367
+ 'style',
368
+ 'polarity',
369
+ 'delay',
370
+ 'fromColumn',
371
+ 'toColumn',
372
+ ];
373
+ const _assertRelationKeyCoverage = true;
374
+ void _assertRelationKeyCoverage;
375
+ export function updateRelation(m, id, patch) {
376
+ if (!m.relations.some((r) => r.id === id))
377
+ throw new CommandError(`Unknown relation '${id}'`);
378
+ if (patch.layer != null && !m.layers.some((l) => l.id === patch.layer)) {
379
+ throw new CommandError(`Unknown layer '${patch.layer}'`);
380
+ }
381
+ for (const end of [patch.from, patch.to]) {
382
+ if (end !== undefined && !m.nodes.some((n) => n.id === end)) {
383
+ throw new CommandError(`Unknown node '${end}'`);
384
+ }
385
+ }
386
+ return {
387
+ ...m,
388
+ relations: m.relations.map((r) => {
389
+ if (r.id !== id)
390
+ return r;
391
+ let next = { ...r };
392
+ if (patch.from !== undefined)
393
+ next.from = patch.from;
394
+ if (patch.to !== undefined)
395
+ next.to = patch.to;
396
+ if (patch.kind !== undefined)
397
+ next.kind = patch.kind;
398
+ // The null-clearable fields go through one typed loop over the whitelist
399
+ // (see RELATION_NULLABLE_KEYS), preserving the same behavior as the prior
400
+ // hand-written chain.
401
+ for (const key of RELATION_NULLABLE_KEYS) {
402
+ next = applyNullable(next, key, patch[key]);
403
+ }
404
+ // `labels` supersedes the legacy single `label`: any labels write drops the
405
+ // legacy string, so removing the last label truly removes it (relationLabels
406
+ // won't re-synthesize) and upgraded models don't persist a redundant `label`.
407
+ if (patch.labels !== undefined)
408
+ next = applyNullable(next, 'label', null);
409
+ return next;
410
+ }),
411
+ };
412
+ }
413
+ export function deleteRelation(m, id) {
414
+ if (!m.relations.some((r) => r.id === id))
415
+ throw new CommandError(`Unknown relation '${id}'`);
416
+ return { ...m, relations: m.relations.filter((r) => r.id !== id) };
417
+ }
418
+ export function upsertLayer(m, layer) {
419
+ const exists = m.layers.some((l) => l.id === layer.id);
420
+ return {
421
+ ...m,
422
+ layers: exists ? m.layers.map((l) => (l.id === layer.id ? layer : l)) : [...m.layers, layer],
423
+ };
424
+ }
425
+ export function deleteLayer(m, id) {
426
+ if (!m.layers.some((l) => l.id === id))
427
+ throw new CommandError(`Unknown layer '${id}'`);
428
+ // Destructive: remove the layer's tagged nodes + relations. Cascade like
429
+ // deleteNode — a destroyed node's containment is severed (untagged children
430
+ // survive top-level) and relations touching it are dropped.
431
+ const doomed = new Set(m.nodes.filter((n) => n.layer === id).map((n) => n.id));
432
+ return {
433
+ ...m,
434
+ layers: m.layers.filter((l) => l.id !== id),
435
+ nodes: m.nodes.filter((n) => n.layer !== id),
436
+ relations: m.relations.filter((r) => r.layer !== id && !doomed.has(r.from) && !doomed.has(r.to)),
437
+ containment: m.containment.filter((e) => !doomed.has(e.parent) && !doomed.has(e.child)),
438
+ planes: (m.planes ?? []).map((p) => {
439
+ const touchesHides = [...(p.hides ?? []), ...(p.hidesTree ?? [])].some((h) => doomed.has(h));
440
+ if (p.layers === undefined && !touchesHides)
441
+ return p;
442
+ return {
443
+ ...p,
444
+ ...(p.layers !== undefined ? { layers: p.layers.filter((l) => l !== id) } : {}),
445
+ ...(p.hides !== undefined ? { hides: p.hides.filter((h) => !doomed.has(h)) } : {}),
446
+ ...(p.hidesTree !== undefined ? { hidesTree: p.hidesTree.filter((h) => !doomed.has(h)) } : {}),
447
+ };
448
+ }),
449
+ };
450
+ }
451
+ export function mergeLayers(m, sourceIds, targetId) {
452
+ if (targetId !== undefined && !m.layers.some((l) => l.id === targetId)) {
453
+ throw new CommandError(`Unknown layer '${targetId}'`);
454
+ }
455
+ for (const id of sourceIds) {
456
+ if (!m.layers.some((l) => l.id === id))
457
+ throw new CommandError(`Unknown layer '${id}'`);
458
+ if (id === targetId)
459
+ throw new CommandError('Cannot merge a layer into itself');
460
+ }
461
+ if (sourceIds.length === 0)
462
+ return m;
463
+ const sources = new Set(sourceIds);
464
+ // Retag a source-tagged node/relation onto the target, or drop the tag
465
+ // entirely when merging to the base sheet (targetId omitted).
466
+ const retag = (x) => {
467
+ if (x.layer === undefined || !sources.has(x.layer))
468
+ return x;
469
+ if (targetId === undefined) {
470
+ const { layer: _layer, ...rest } = x;
471
+ return rest;
472
+ }
473
+ return { ...x, layer: targetId };
474
+ };
475
+ return {
476
+ ...m,
477
+ layers: m.layers.filter((l) => !sources.has(l.id)),
478
+ nodes: m.nodes.map(retag),
479
+ relations: m.relations.map(retag),
480
+ planes: (m.planes ?? []).map((p) => {
481
+ if (p.layers === undefined)
482
+ return p;
483
+ const layers = targetId === undefined
484
+ ? p.layers.filter((l) => !sources.has(l))
485
+ : [...new Set(p.layers.map((l) => (sources.has(l) ? targetId : l)))];
486
+ return { ...p, layers };
487
+ }),
488
+ };
489
+ }
490
+ export function upsertPlane(m, plane) {
491
+ if (plane.notation !== undefined && !BUILTIN_NOTATIONS.includes(plane.notation)) {
492
+ throw new CommandError(`Unknown notation '${plane.notation}'`);
493
+ }
494
+ if (plane.containmentOf !== undefined) {
495
+ if (plane.containmentOf === plane.id) {
496
+ throw new CommandError(`Plane '${plane.id}' cannot borrow containment from itself`);
497
+ }
498
+ const target = (m.planes ?? []).find((p) => p.id === plane.containmentOf);
499
+ if (target === undefined) {
500
+ throw new CommandError(`Unknown plane '${plane.containmentOf}'`);
501
+ }
502
+ if (target.containmentOf !== undefined) {
503
+ throw new CommandError(`Plane '${plane.containmentOf}' itself borrows containment — chains are not allowed`);
504
+ }
505
+ }
506
+ const planes = m.planes ?? [];
507
+ const exists = planes.some((p) => p.id === plane.id);
508
+ return { ...m, planes: exists ? planes.map((p) => (p.id === plane.id ? plane : p)) : [...planes, plane] };
509
+ }
510
+ /** Add/remove a shared node from a plane's `hides`. A node already scoped to a
511
+ * plane is view-local, so hiding it is a no-op (validation flags a stray one). */
512
+ export function setNodePlaneHidden(m, nodeId, planeId, hidden) {
513
+ const node = requireNode(m, nodeId);
514
+ if (hidden && node.plane !== undefined)
515
+ return m;
516
+ return {
517
+ ...m,
518
+ planes: (m.planes ?? []).map((p) => {
519
+ if (p.id !== planeId)
520
+ return p;
521
+ const set = new Set(p.hides ?? []);
522
+ if (hidden)
523
+ set.add(nodeId);
524
+ else
525
+ set.delete(nodeId);
526
+ const { hides: _drop, ...rest } = p;
527
+ return set.size > 0 ? { ...rest, hides: [...set] } : rest;
528
+ }),
529
+ };
530
+ }
531
+ export function deletePlane(m, id) {
532
+ const planes = m.planes ?? [];
533
+ if (!planes.some((p) => p.id === id))
534
+ throw new CommandError(`Unknown plane '${id}'`);
535
+ const borrower = planes.find((p) => p.containmentOf === id);
536
+ if (borrower !== undefined) {
537
+ throw new CommandError(`Plane '${borrower.id}' borrows containment from '${id}' — delete or repoint it first`);
538
+ }
539
+ // The first-declared plane owns the untagged (base) edges; drop them with it so
540
+ // structure doesn't silently migrate to whichever plane becomes first next.
541
+ const isFirst = planes[0]?.id === id;
542
+ return {
543
+ ...m,
544
+ planes: planes.filter((p) => p.id !== id),
545
+ containment: m.containment.filter((e) => e.plane !== id && !(isFirst && e.plane === undefined)),
546
+ };
547
+ }
@@ -0,0 +1,39 @@
1
+ import type { DiagramModel } from './types.js';
2
+ /** The notation id a plane (or the model) declares to be drawn as a consequence tree. */
3
+ export declare const SECOND_ORDER_NOTATION: "second-order";
4
+ export declare const SO_DECISION_TYPE: "so-decision";
5
+ /** Valence is a TYPE VARIANT, not a field: a registry and palette entry, no
6
+ * schema change — the same trick C4 uses for its `-external` stencils. */
7
+ export declare const SO_CONSEQUENCE_TYPES: readonly ["so-consequence-positive", "so-consequence-negative", "so-consequence-neutral"];
8
+ /** The kind the builder and the studio create. The derivation below does NOT
9
+ * filter by it (see consequenceOrders). */
10
+ export declare const SO_LEADS_TO_KIND: "leads-to";
11
+ /** good, bad, neutral */
12
+ export type Valence = '+' | '-' | '0';
13
+ export declare const consequenceTypeOf: (v: Valence) => string;
14
+ export declare function valenceOf(type: string | undefined): Valence | undefined;
15
+ export declare const isSecondOrderNode: (n: {
16
+ type?: string;
17
+ }) => boolean;
18
+ export interface ConsequenceOrders {
19
+ /** node id → order. A decision nothing leads to is 0. */
20
+ orders: ReadonlyMap<string, number>;
21
+ /** ids ON a loop, in declaration order, when there is one — `orders` is then empty */
22
+ cycle?: string[];
23
+ /** consequences no decision leads to, in declaration order — they get no order */
24
+ unreachable: string[];
25
+ }
26
+ /**
27
+ * Which band each decision and consequence belongs to. This is the ONE place
28
+ * that answers it, so the layout's partitions, the band overlay, the studio
29
+ * panel and validation cannot disagree.
30
+ *
31
+ * The order is the LONGEST path from a decision: every cause then sits in an
32
+ * earlier band than its effect, and no arrow ever runs inside a band — which is
33
+ * also exactly what lets elk take the orders as layer partitions.
34
+ *
35
+ * Reads the MODEL's relations, never drawn edges: toggling a layer must not
36
+ * move a box. Any kind of relation between two second-order nodes counts, so
37
+ * restyling an arrow can never drop a node out of its band. Never throws.
38
+ */
39
+ export declare function consequenceOrders(model: DiagramModel): ConsequenceOrders;