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