@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/mutate.d.ts
ADDED
|
@@ -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;
|