@diagc/core 0.1.0 → 0.2.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/README.md +3 -3
- package/dist/builder.d.ts +88 -2
- package/dist/builder.js +146 -1
- package/dist/commands.d.ts +25 -2
- package/dist/commands.js +25 -7
- package/dist/comments.d.ts +24 -0
- package/dist/comments.js +25 -0
- package/dist/dates.d.ts +1 -0
- package/dist/dates.js +12 -0
- package/dist/eject.js +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/mutate.d.ts +27 -2
- package/dist/mutate.js +115 -17
- package/dist/plan.d.ts +105 -0
- package/dist/plan.js +172 -0
- package/dist/types.d.ts +31 -3
- package/dist/types.js +4 -2
- package/dist/validate.d.ts +1 -1
- package/dist/validate.js +121 -0
- package/package.json +1 -1
package/dist/mutate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
1
|
+
import { type Column, type Comment, type DiagramLayer, type DiagramLegend, type DiagramModel, type DiagramNode, type DiagramPlane, type EdgeLabel, type FontScale, type Link, type Polarity, type RelationStyle, type StrideCategory, type TextAlign, type TextRun, type Threat, type ThreatSeverity, type ThreatStatus } from './types.js';
|
|
2
2
|
import type { ThreatTarget } from './threat-model.js';
|
|
3
3
|
export declare class CommandError extends Error {
|
|
4
4
|
constructor(message: string);
|
|
@@ -22,8 +22,20 @@ export interface NodeDetails {
|
|
|
22
22
|
metadata?: Record<string, unknown> | null;
|
|
23
23
|
plane?: string | null;
|
|
24
24
|
layer?: string | null;
|
|
25
|
+
links?: Link[] | null;
|
|
25
26
|
}
|
|
26
27
|
export declare function setNodeDetails(m: DiagramModel, id: string, details: NodeDetails): DiagramModel;
|
|
28
|
+
/** The dated keys of a plan node. A key left undefined is untouched. */
|
|
29
|
+
export interface PlanDates {
|
|
30
|
+
start?: string;
|
|
31
|
+
end?: string;
|
|
32
|
+
at?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Writes zone/event dates into `node.metadata`. Only the FORMAT is guarded
|
|
35
|
+
* here (a value that is not a real day can never be right); ordering and
|
|
36
|
+
* nesting are validation's findings, so a drag that momentarily crosses a
|
|
37
|
+
* bound still lands as a command and undo has something to undo. */
|
|
38
|
+
export declare function setPlanDates(m: DiagramModel, id: string, dates: PlanDates): DiagramModel;
|
|
27
39
|
/** Transitive containment descendants of `id` across every plane, plus `id`
|
|
28
40
|
* itself — the set a cascade delete destroys. */
|
|
29
41
|
export declare function subtreeOf(m: DiagramModel, id: string): Set<string>;
|
|
@@ -42,7 +54,20 @@ export interface ThreatPatch {
|
|
|
42
54
|
export declare function addThreat(m: DiagramModel, target: ThreatTarget, threat: Threat): DiagramModel;
|
|
43
55
|
export declare function updateThreat(m: DiagramModel, target: ThreatTarget, id: string, patch: ThreatPatch): DiagramModel;
|
|
44
56
|
export declare function removeThreat(m: DiagramModel, target: ThreatTarget, id: string): DiagramModel;
|
|
45
|
-
|
|
57
|
+
/** Patch for update-comment: `null` clears an optional field. `text` is
|
|
58
|
+
* required on a {@link Comment}, so it is set-only. */
|
|
59
|
+
export interface CommentPatch {
|
|
60
|
+
text?: string;
|
|
61
|
+
by?: string | null;
|
|
62
|
+
at?: string | null;
|
|
63
|
+
}
|
|
64
|
+
export declare function addComment(m: DiagramModel, target: ThreatTarget, comment: Comment): DiagramModel;
|
|
65
|
+
export declare function updateComment(m: DiagramModel, target: ThreatTarget, id: string, patch: CommentPatch): DiagramModel;
|
|
66
|
+
export declare function removeComment(m: DiagramModel, target: ThreatTarget, id: string): DiagramModel;
|
|
67
|
+
export declare function addContainment(m: DiagramModel, parent: string, child: string, plane?: string, beside?: {
|
|
68
|
+
sibling: string;
|
|
69
|
+
side: 'before' | 'after';
|
|
70
|
+
}): DiagramModel;
|
|
46
71
|
/**
|
|
47
72
|
* Group existing nodes under a new abstract parent: add `node`, then nest each
|
|
48
73
|
* member under it. `plane` scopes both the containment edges and (via the
|
package/dist/mutate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BUILTIN_NOTATIONS, } from './types.js';
|
|
2
2
|
import { normalizeRuns, runsToPlainText } from './text.js';
|
|
3
3
|
import { childrenOf } from './children.js';
|
|
4
|
+
import { isIsoDate } from './dates.js';
|
|
4
5
|
export class CommandError extends Error {
|
|
5
6
|
constructor(message) {
|
|
6
7
|
super(message);
|
|
@@ -77,6 +78,7 @@ const NODE_DETAIL_KEYS = [
|
|
|
77
78
|
'metadata',
|
|
78
79
|
'plane',
|
|
79
80
|
'layer',
|
|
81
|
+
'links',
|
|
80
82
|
];
|
|
81
83
|
const _assertNodeKeyCoverage = true;
|
|
82
84
|
void _assertNodeKeyCoverage;
|
|
@@ -95,6 +97,10 @@ export function setNodeDetails(m, id, details) {
|
|
|
95
97
|
if (details.layer != null && !m.layers.some((l) => l.id === details.layer)) {
|
|
96
98
|
throw new CommandError(`Unknown layer '${details.layer}'`);
|
|
97
99
|
}
|
|
100
|
+
// An emptied list is a cleared one. A panel sends `links` whole, so deleting
|
|
101
|
+
// the last row arrives as `[]` — and a saved file must no more hold
|
|
102
|
+
// `links: []` than the threat/comment lists mapList prunes.
|
|
103
|
+
const patch = details.links?.length === 0 ? { ...details, links: null } : details;
|
|
98
104
|
const nodes = m.nodes.map((n) => {
|
|
99
105
|
if (n.id !== id)
|
|
100
106
|
return n;
|
|
@@ -103,7 +109,7 @@ export function setNodeDetails(m, id, details) {
|
|
|
103
109
|
// 12 hand-written applyNullable calls — adding a field wires automatically
|
|
104
110
|
// and the coverage const above makes an omission a compile error.
|
|
105
111
|
for (const key of NODE_DETAIL_KEYS) {
|
|
106
|
-
next = applyNullable(next, key,
|
|
112
|
+
next = applyNullable(next, key, patch[key]);
|
|
107
113
|
}
|
|
108
114
|
return next;
|
|
109
115
|
});
|
|
@@ -116,6 +122,28 @@ export function setNodeDetails(m, id, details) {
|
|
|
116
122
|
}
|
|
117
123
|
return next;
|
|
118
124
|
}
|
|
125
|
+
/** Writes zone/event dates into `node.metadata`. Only the FORMAT is guarded
|
|
126
|
+
* here (a value that is not a real day can never be right); ordering and
|
|
127
|
+
* nesting are validation's findings, so a drag that momentarily crosses a
|
|
128
|
+
* bound still lands as a command and undo has something to undo. */
|
|
129
|
+
export function setPlanDates(m, id, dates) {
|
|
130
|
+
requireNode(m, id);
|
|
131
|
+
const patch = {};
|
|
132
|
+
for (const key of ['start', 'end', 'at']) {
|
|
133
|
+
const v = dates[key];
|
|
134
|
+
if (v === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
if (!isIsoDate(v))
|
|
137
|
+
throw new CommandError(`'${key}' must be a YYYY-MM-DD date, got '${v}'`);
|
|
138
|
+
patch[key] = v;
|
|
139
|
+
}
|
|
140
|
+
if (Object.keys(patch).length === 0)
|
|
141
|
+
return m;
|
|
142
|
+
return {
|
|
143
|
+
...m,
|
|
144
|
+
nodes: m.nodes.map((n) => (n.id === id ? { ...n, metadata: { ...(n.metadata ?? {}), ...patch } } : n)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
119
147
|
/** Drop every id satisfying `drop` from each plane's `hides`/`hidesTree`,
|
|
120
148
|
* omitting emptied lists; a plane with no change keeps its reference. */
|
|
121
149
|
function prunePlaneHides(planes, drop) {
|
|
@@ -173,29 +201,28 @@ export function setTableColumns(m, id, columns) {
|
|
|
173
201
|
const THREAT_NULLABLE_KEYS = ['description', 'severity', 'status', 'mitigation'];
|
|
174
202
|
const _assertThreatKeyCoverage = true;
|
|
175
203
|
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) {
|
|
204
|
+
function mapList(m, target, key, fn) {
|
|
184
205
|
const next = (items, id, what) => {
|
|
185
206
|
if (!items.some((x) => x.id === id))
|
|
186
207
|
throw new CommandError(`Unknown ${what} '${id}'`);
|
|
187
208
|
return items.map((x) => {
|
|
188
209
|
if (x.id !== id)
|
|
189
210
|
return x;
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
211
|
+
const record = x;
|
|
212
|
+
const list = fn(record[key] ?? []);
|
|
213
|
+
// Computed-key destructuring here defeats tsc's narrowing back to `T`
|
|
214
|
+
// (the omit doesn't provably overlap); spread-then-delete keeps the same
|
|
215
|
+
// "drop the key, no empty array survives" behavior without the cast.
|
|
216
|
+
const rest = { ...record };
|
|
217
|
+
delete rest[key];
|
|
218
|
+
return (list.length === 0 ? rest : { ...rest, [key]: list });
|
|
193
219
|
});
|
|
194
220
|
};
|
|
195
221
|
return 'node' in target
|
|
196
222
|
? { ...m, nodes: next(m.nodes, target.node, 'node') }
|
|
197
223
|
: { ...m, relations: next(m.relations, target.relation, 'relation') };
|
|
198
224
|
}
|
|
225
|
+
const mapThreats = (m, target, fn) => mapList(m, target, 'threats', fn);
|
|
199
226
|
export function addThreat(m, target, threat) {
|
|
200
227
|
return mapThreats(m, target, (threats) => {
|
|
201
228
|
if (threats.some((t) => t.id === threat.id))
|
|
@@ -229,6 +256,48 @@ export function removeThreat(m, target, id) {
|
|
|
229
256
|
return threats.filter((t) => t.id !== id);
|
|
230
257
|
});
|
|
231
258
|
}
|
|
259
|
+
const COMMENT_NULLABLE_KEYS = ['by', 'at'];
|
|
260
|
+
const _assertCommentKeyCoverage = true;
|
|
261
|
+
void _assertCommentKeyCoverage;
|
|
262
|
+
/** A date the studio's date input could not have produced is refused here, not
|
|
263
|
+
* only at compile time: the studio autosaves on a timer, and a model that fails
|
|
264
|
+
* validation wedges that save with a 400 while the user is still typing. */
|
|
265
|
+
const checkCommentDate = (at) => {
|
|
266
|
+
if (at !== undefined && at !== null && !isIsoDate(at))
|
|
267
|
+
throw new CommandError(`Comment date '${at}' is not a YYYY-MM-DD date`);
|
|
268
|
+
};
|
|
269
|
+
export function addComment(m, target, comment) {
|
|
270
|
+
checkCommentDate(comment.at);
|
|
271
|
+
return mapList(m, target, 'comments', (comments) => {
|
|
272
|
+
if (comments.some((c) => c.id === comment.id))
|
|
273
|
+
throw new CommandError(`Duplicate comment id '${comment.id}'`);
|
|
274
|
+
return [...comments, comment];
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export function updateComment(m, target, id, patch) {
|
|
278
|
+
checkCommentDate(patch.at);
|
|
279
|
+
return mapList(m, target, 'comments', (comments) => {
|
|
280
|
+
if (!comments.some((c) => c.id === id))
|
|
281
|
+
throw new CommandError(`Unknown comment '${id}'`);
|
|
282
|
+
return comments.map((c) => {
|
|
283
|
+
if (c.id !== id)
|
|
284
|
+
return c;
|
|
285
|
+
let out = { ...c };
|
|
286
|
+
if (patch.text !== undefined)
|
|
287
|
+
out.text = patch.text;
|
|
288
|
+
for (const key of COMMENT_NULLABLE_KEYS)
|
|
289
|
+
out = applyNullable(out, key, patch[key]);
|
|
290
|
+
return out;
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
export function removeComment(m, target, id) {
|
|
295
|
+
return mapList(m, target, 'comments', (comments) => {
|
|
296
|
+
if (!comments.some((c) => c.id === id))
|
|
297
|
+
throw new CommandError(`Unknown comment '${id}'`);
|
|
298
|
+
return comments.filter((c) => c.id !== id);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
232
301
|
function wouldCycle(m, parent, child, plane) {
|
|
233
302
|
const defaultPlane = (m.planes ?? [])[0]?.id;
|
|
234
303
|
const key = plane ?? defaultPlane;
|
|
@@ -253,7 +322,17 @@ function wouldCycle(m, parent, child, plane) {
|
|
|
253
322
|
* Resolve a plane argument to its canonical containment form: undefined stays
|
|
254
323
|
* undefined; a declared plane resolves its `containmentOf` borrow (one hop) and,
|
|
255
324
|
* if that lands on the first-declared (default) plane, collapses to `undefined`
|
|
256
|
-
* so
|
|
325
|
+
* so a NEW edge is always written in the untagged base form. Throws on unknowns.
|
|
326
|
+
*
|
|
327
|
+
* That collapse is a write-time convention only — an existing edge can still
|
|
328
|
+
* carry an explicit tag for the default plane (the builder DSL always names
|
|
329
|
+
* a plan's own plane on its containment, "so the plan need not be the first
|
|
330
|
+
* plane declared", even when it happens to be first — ZoneBuilder). A caller
|
|
331
|
+
* comparing against this function's result must therefore resolve THAT side
|
|
332
|
+
* too (`e.plane ?? defaultPlane`, the same normalisation `wouldCycle` above
|
|
333
|
+
* already does for its own read), not compare `e.plane` against `canon`
|
|
334
|
+
* directly, or a builder-tagged default-plane edge never matches a command
|
|
335
|
+
* that (correctly) canonicalizes to `undefined`.
|
|
257
336
|
*/
|
|
258
337
|
function canonicalPlane(m, plane) {
|
|
259
338
|
if (plane === undefined)
|
|
@@ -265,18 +344,33 @@ function canonicalPlane(m, plane) {
|
|
|
265
344
|
const resolved = p.containmentOf ?? p.id;
|
|
266
345
|
return resolved === planes[0]?.id ? undefined : resolved;
|
|
267
346
|
}
|
|
268
|
-
export function addContainment(m, parent, child, plane) {
|
|
347
|
+
export function addContainment(m, parent, child, plane, beside) {
|
|
269
348
|
requireNode(m, parent);
|
|
270
349
|
requireNode(m, child);
|
|
271
350
|
if (parent === child)
|
|
272
351
|
throw new CommandError(`Node '${parent}' cannot contain itself`);
|
|
273
352
|
const canon = canonicalPlane(m, plane);
|
|
274
|
-
|
|
353
|
+
// Resolved on both sides (see canonicalPlane's comment): an edge already in
|
|
354
|
+
// the model may carry an explicit tag for what is, today, the default
|
|
355
|
+
// plane (the builder DSL always tags a plan's containment), which a bare
|
|
356
|
+
// `e.plane === canon` would miss.
|
|
357
|
+
const defaultPlane = (m.planes ?? [])[0]?.id;
|
|
358
|
+
const key = canon ?? defaultPlane;
|
|
359
|
+
if (m.containment.some((e) => e.parent === parent && e.child === child && (e.plane ?? defaultPlane) === key))
|
|
275
360
|
return m;
|
|
276
361
|
if (wouldCycle(m, parent, child, canon)) {
|
|
277
362
|
throw new CommandError(`'${parent}' > '${child}' would create a containment cycle`);
|
|
278
363
|
}
|
|
279
|
-
|
|
364
|
+
const edge = { parent, child, ...(canon !== undefined ? { plane: canon } : {}) };
|
|
365
|
+
if (beside === undefined)
|
|
366
|
+
return { ...m, containment: [...m.containment, edge] };
|
|
367
|
+
// Children read in declaration order, so the slot in the flat array IS the
|
|
368
|
+
// sibling order — insert next to the sibling's own membership.
|
|
369
|
+
const at = m.containment.findIndex((e) => e.parent === parent && e.child === beside.sibling && e.plane === canon);
|
|
370
|
+
if (at === -1)
|
|
371
|
+
throw new CommandError(`'${beside.sibling}' is not a child of '${parent}'`);
|
|
372
|
+
const i = beside.side === 'before' ? at : at + 1;
|
|
373
|
+
return { ...m, containment: [...m.containment.slice(0, i), edge, ...m.containment.slice(i)] };
|
|
280
374
|
}
|
|
281
375
|
/**
|
|
282
376
|
* Group existing nodes under a new abstract parent: add `node`, then nest each
|
|
@@ -294,9 +388,13 @@ export function groupNodes(m, node, memberIds, plane) {
|
|
|
294
388
|
}
|
|
295
389
|
export function removeContainment(m, parent, child, plane) {
|
|
296
390
|
const canon = canonicalPlane(m, plane);
|
|
391
|
+
// Same resolved-both-sides comparison as addContainment's duplicate check —
|
|
392
|
+
// see canonicalPlane's comment.
|
|
393
|
+
const defaultPlane = (m.planes ?? [])[0]?.id;
|
|
394
|
+
const key = canon ?? defaultPlane;
|
|
297
395
|
return {
|
|
298
396
|
...m,
|
|
299
|
-
containment: m.containment.filter((e) => !(e.parent === parent && e.child === child && e.plane ===
|
|
397
|
+
containment: m.containment.filter((e) => !(e.parent === parent && e.child === child && (e.plane ?? defaultPlane) === key)),
|
|
300
398
|
};
|
|
301
399
|
}
|
|
302
400
|
/** Pin the diagram's visual style preset id, or clear it with null (the
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { DiagramModel, DiagramNode } from './types.js';
|
|
2
|
+
/** The notation id a plane (or the model) declares to be drawn as a schedule:
|
|
3
|
+
* a calendar left to right, zones as bars, events as diamonds, actors (people
|
|
4
|
+
* or teams) as a roster. Pure — the renderer and the studio derive everything
|
|
5
|
+
* from here. */
|
|
6
|
+
export declare const PLAN_NOTATION: "plan";
|
|
7
|
+
/** A zone is a CONTAINER with a span: nesting is containment, and whatever
|
|
8
|
+
* sits inside a zone is scheduled in it. */
|
|
9
|
+
export declare const PLAN_ZONE_TYPE: "plan-zone";
|
|
10
|
+
export declare const PLAN_EVENT_TYPE: "plan-event";
|
|
11
|
+
/** The type a person carries. Deliberately the generic `person` type, not a
|
|
12
|
+
* `plan-person` of its own — a person is an ordinary node the plan merely
|
|
13
|
+
* reads, and keeps its pill on every other notation. It is named here because
|
|
14
|
+
* the builder, the roster layout, the studio's quick-add and the role pickers
|
|
15
|
+
* all key on it, and a literal in four packages is a drift waiting to happen.
|
|
16
|
+
* It is NOT in `PLAN_TYPES`: that set gates the inspector's date fields, and a
|
|
17
|
+
* person has no dates. */
|
|
18
|
+
export declare const PLAN_PERSON_TYPE: "person";
|
|
19
|
+
/** The type a team carries — the same deal as `PLAN_PERSON_TYPE`: a generic
|
|
20
|
+
* `team`, not a `plan-team`, so it keeps its own pill everywhere else and a
|
|
21
|
+
* role relation reads it exactly like a person. Not in `PLAN_TYPES` either. */
|
|
22
|
+
export declare const PLAN_TEAM_TYPE: "team";
|
|
23
|
+
export declare const PLAN_TYPES: ReadonlySet<string>;
|
|
24
|
+
/** Everything that can hold a role: a person or a team. `rolesOf` doesn't
|
|
25
|
+
* check this (a role relation may point `from` any node), but the roster, the
|
|
26
|
+
* role pickers and the plan's drag rules all mean "an actor" when they say
|
|
27
|
+
* `PLAN_PERSON_TYPE` — this is the set they should key on instead. */
|
|
28
|
+
export declare const PLAN_ACTOR_TYPES: ReadonlySet<string>;
|
|
29
|
+
export declare const isPlanZone: (n: DiagramNode) => boolean;
|
|
30
|
+
export declare const isPlanEvent: (n: DiagramNode) => boolean;
|
|
31
|
+
export declare const isPlanActor: (n: DiagramNode) => boolean;
|
|
32
|
+
/** Actors (people or teams) attach to a zone through a RELATION of one of
|
|
33
|
+
* these kinds, actor → zone, never through containment — so one actor is on
|
|
34
|
+
* many zones. */
|
|
35
|
+
export declare const PLAN_ROLES: readonly ["owns", "executes", "checks"];
|
|
36
|
+
export type PlanRole = (typeof PLAN_ROLES)[number];
|
|
37
|
+
export declare const isPlanRole: (k: string) => k is PlanRole;
|
|
38
|
+
/** Days since 1970-01-01 (UTC) for a real `YYYY-MM-DD`; undefined otherwise.
|
|
39
|
+
* Whole days, parsed as UTC, so the reader's timezone never shifts a bar. */
|
|
40
|
+
export declare function dayOf(iso: unknown): number | undefined;
|
|
41
|
+
/** The inverse of dayOf. */
|
|
42
|
+
export declare function isoOf(day: number): string;
|
|
43
|
+
/** Inclusive day numbers. */
|
|
44
|
+
export interface PlanSpan {
|
|
45
|
+
start: number;
|
|
46
|
+
end: number;
|
|
47
|
+
}
|
|
48
|
+
/** A zone's span, or undefined when the node is not a zone or its dates are
|
|
49
|
+
* missing, malformed or reversed — each of those is a validation finding
|
|
50
|
+
* (`plan-missing`, `plan-date`, `plan-span`); here it just means "nothing to
|
|
51
|
+
* draw". */
|
|
52
|
+
export declare function spanOf(n: DiagramNode): PlanSpan | undefined;
|
|
53
|
+
/** An event's day, or undefined when the node is not an event or `at` is
|
|
54
|
+
* missing or malformed. */
|
|
55
|
+
export declare function atOf(n: DiagramNode): number | undefined;
|
|
56
|
+
export interface PlanRoles {
|
|
57
|
+
owns: string[];
|
|
58
|
+
executes: string[];
|
|
59
|
+
checks: string[];
|
|
60
|
+
}
|
|
61
|
+
/** People per role for one zone, from the role relations that point INTO it,
|
|
62
|
+
* in declaration order. */
|
|
63
|
+
export declare function rolesOf(model: DiagramModel, zoneId: string): PlanRoles;
|
|
64
|
+
export interface PlanChildren {
|
|
65
|
+
zones: string[];
|
|
66
|
+
events: string[];
|
|
67
|
+
others: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface PlanGraph {
|
|
70
|
+
/** zone ids in declaration order (visible on the plane) */
|
|
71
|
+
zones: string[];
|
|
72
|
+
events: string[];
|
|
73
|
+
/** every actor (person or team) holding a role — a role relation into a
|
|
74
|
+
* visible zone — in declaration order, deduplicated */
|
|
75
|
+
actors: string[];
|
|
76
|
+
/** zone/event/borrowed node → its zone parent on the plan plane */
|
|
77
|
+
parent: ReadonlyMap<string, string>;
|
|
78
|
+
/** zone → DIRECT children on the plan plane, split by what they are. A child
|
|
79
|
+
* with two zone parents (a DAG node, e.g. a service two phases both borrow)
|
|
80
|
+
* is listed under exactly ONE — the same zone `parent` resolves it to, which
|
|
81
|
+
* is also the one zone the compiled VIEW hosts it under (view/tree.ts's
|
|
82
|
+
* `host`) — never both, so the layout never schedules it relative to a box
|
|
83
|
+
* it is not actually drawn inside of. */
|
|
84
|
+
children: ReadonlyMap<string, PlanChildren>;
|
|
85
|
+
/** earliest start/at and latest end/at over the whole graph; undefined when nothing is dated */
|
|
86
|
+
range?: PlanSpan;
|
|
87
|
+
/** 1 January of range.start's year — the x origin of the drawing */
|
|
88
|
+
origin?: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The plan plane's structure, derived through buildHierarchy so `containmentOf`
|
|
92
|
+
* and `hides` behave exactly as the view does. `parent` picks each child's
|
|
93
|
+
* first zone parent by containment-EDGE declaration order — `h.parentsOf`
|
|
94
|
+
* already preserves that order (the same rule `boundaryOf` uses) — filtered to
|
|
95
|
+
* the parents that are zones, since a DAG child can have non-zone parents on
|
|
96
|
+
* the plane too. `children` is derived FROM `parent`, not from
|
|
97
|
+
* `h.childrenOf` directly: a zone's direct children are exactly the nodes
|
|
98
|
+
* `parent` resolves to it, so a DAG child (contained by two zones) lands under
|
|
99
|
+
* exactly one — the view hosts it under one parent too (view/tree.ts), and the
|
|
100
|
+
* layout must agree or it draws the child relative to a box it is not in.
|
|
101
|
+
*/
|
|
102
|
+
export declare function planGraph(model: DiagramModel, plane?: string): PlanGraph;
|
|
103
|
+
/** `id` plus every descendant zone and event, pre-order — what moves with a
|
|
104
|
+
* zone. Borrowed nodes are not listed: their place is their row. */
|
|
105
|
+
export declare function planSubtree(g: PlanGraph, id: string): string[];
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isIsoDate } from './dates.js';
|
|
2
|
+
import { buildHierarchy } from './view/hierarchy.js';
|
|
3
|
+
/** The notation id a plane (or the model) declares to be drawn as a schedule:
|
|
4
|
+
* a calendar left to right, zones as bars, events as diamonds, actors (people
|
|
5
|
+
* or teams) as a roster. Pure — the renderer and the studio derive everything
|
|
6
|
+
* from here. */
|
|
7
|
+
export const PLAN_NOTATION = 'plan';
|
|
8
|
+
/** A zone is a CONTAINER with a span: nesting is containment, and whatever
|
|
9
|
+
* sits inside a zone is scheduled in it. */
|
|
10
|
+
export const PLAN_ZONE_TYPE = 'plan-zone';
|
|
11
|
+
export const PLAN_EVENT_TYPE = 'plan-event';
|
|
12
|
+
/** The type a person carries. Deliberately the generic `person` type, not a
|
|
13
|
+
* `plan-person` of its own — a person is an ordinary node the plan merely
|
|
14
|
+
* reads, and keeps its pill on every other notation. It is named here because
|
|
15
|
+
* the builder, the roster layout, the studio's quick-add and the role pickers
|
|
16
|
+
* all key on it, and a literal in four packages is a drift waiting to happen.
|
|
17
|
+
* It is NOT in `PLAN_TYPES`: that set gates the inspector's date fields, and a
|
|
18
|
+
* person has no dates. */
|
|
19
|
+
export const PLAN_PERSON_TYPE = 'person';
|
|
20
|
+
/** The type a team carries — the same deal as `PLAN_PERSON_TYPE`: a generic
|
|
21
|
+
* `team`, not a `plan-team`, so it keeps its own pill everywhere else and a
|
|
22
|
+
* role relation reads it exactly like a person. Not in `PLAN_TYPES` either. */
|
|
23
|
+
export const PLAN_TEAM_TYPE = 'team';
|
|
24
|
+
export const PLAN_TYPES = new Set([PLAN_ZONE_TYPE, PLAN_EVENT_TYPE]);
|
|
25
|
+
/** Everything that can hold a role: a person or a team. `rolesOf` doesn't
|
|
26
|
+
* check this (a role relation may point `from` any node), but the roster, the
|
|
27
|
+
* role pickers and the plan's drag rules all mean "an actor" when they say
|
|
28
|
+
* `PLAN_PERSON_TYPE` — this is the set they should key on instead. */
|
|
29
|
+
export const PLAN_ACTOR_TYPES = new Set([PLAN_PERSON_TYPE, PLAN_TEAM_TYPE]);
|
|
30
|
+
export const isPlanZone = (n) => n.type === PLAN_ZONE_TYPE;
|
|
31
|
+
export const isPlanEvent = (n) => n.type === PLAN_EVENT_TYPE;
|
|
32
|
+
export const isPlanActor = (n) => n.type !== undefined && PLAN_ACTOR_TYPES.has(n.type);
|
|
33
|
+
/** Actors (people or teams) attach to a zone through a RELATION of one of
|
|
34
|
+
* these kinds, actor → zone, never through containment — so one actor is on
|
|
35
|
+
* many zones. */
|
|
36
|
+
export const PLAN_ROLES = ['owns', 'executes', 'checks'];
|
|
37
|
+
export const isPlanRole = (k) => PLAN_ROLES.includes(k);
|
|
38
|
+
const MS_PER_DAY = 86_400_000;
|
|
39
|
+
/** Days since 1970-01-01 (UTC) for a real `YYYY-MM-DD`; undefined otherwise.
|
|
40
|
+
* Whole days, parsed as UTC, so the reader's timezone never shifts a bar. */
|
|
41
|
+
export function dayOf(iso) {
|
|
42
|
+
if (!isIsoDate(iso))
|
|
43
|
+
return undefined;
|
|
44
|
+
return Date.parse(`${iso}T00:00:00Z`) / MS_PER_DAY;
|
|
45
|
+
}
|
|
46
|
+
/** The inverse of dayOf. */
|
|
47
|
+
export function isoOf(day) {
|
|
48
|
+
return new Date(day * MS_PER_DAY).toISOString().slice(0, 10);
|
|
49
|
+
}
|
|
50
|
+
/** A zone's span, or undefined when the node is not a zone or its dates are
|
|
51
|
+
* missing, malformed or reversed — each of those is a validation finding
|
|
52
|
+
* (`plan-missing`, `plan-date`, `plan-span`); here it just means "nothing to
|
|
53
|
+
* draw". */
|
|
54
|
+
export function spanOf(n) {
|
|
55
|
+
if (!isPlanZone(n))
|
|
56
|
+
return undefined;
|
|
57
|
+
const start = dayOf(n.metadata?.start);
|
|
58
|
+
const end = dayOf(n.metadata?.end);
|
|
59
|
+
return start === undefined || end === undefined || end < start ? undefined : { start, end };
|
|
60
|
+
}
|
|
61
|
+
/** An event's day, or undefined when the node is not an event or `at` is
|
|
62
|
+
* missing or malformed. */
|
|
63
|
+
export function atOf(n) {
|
|
64
|
+
return isPlanEvent(n) ? dayOf(n.metadata?.at) : undefined;
|
|
65
|
+
}
|
|
66
|
+
/** People per role for one zone, from the role relations that point INTO it,
|
|
67
|
+
* in declaration order. */
|
|
68
|
+
export function rolesOf(model, zoneId) {
|
|
69
|
+
const roles = { owns: [], executes: [], checks: [] };
|
|
70
|
+
for (const r of model.relations) {
|
|
71
|
+
if (r.to === zoneId && isPlanRole(r.kind))
|
|
72
|
+
roles[r.kind].push(r.from);
|
|
73
|
+
}
|
|
74
|
+
return roles;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The plan plane's structure, derived through buildHierarchy so `containmentOf`
|
|
78
|
+
* and `hides` behave exactly as the view does. `parent` picks each child's
|
|
79
|
+
* first zone parent by containment-EDGE declaration order — `h.parentsOf`
|
|
80
|
+
* already preserves that order (the same rule `boundaryOf` uses) — filtered to
|
|
81
|
+
* the parents that are zones, since a DAG child can have non-zone parents on
|
|
82
|
+
* the plane too. `children` is derived FROM `parent`, not from
|
|
83
|
+
* `h.childrenOf` directly: a zone's direct children are exactly the nodes
|
|
84
|
+
* `parent` resolves to it, so a DAG child (contained by two zones) lands under
|
|
85
|
+
* exactly one — the view hosts it under one parent too (view/tree.ts), and the
|
|
86
|
+
* layout must agree or it draws the child relative to a box it is not in.
|
|
87
|
+
*/
|
|
88
|
+
export function planGraph(model, plane) {
|
|
89
|
+
const byId = new Map(model.nodes.map((n) => [n.id, n]));
|
|
90
|
+
const h = buildHierarchy(model, plane);
|
|
91
|
+
// buildHierarchy seeds parentsOf (to []) for every visible node, so this is
|
|
92
|
+
// exactly "is this node visible on the plane", including shared nodes with
|
|
93
|
+
// no containment there (they surface as roots, not as absent).
|
|
94
|
+
const visible = (id) => h.parentsOf.has(id);
|
|
95
|
+
const zones = [];
|
|
96
|
+
const events = [];
|
|
97
|
+
for (const n of model.nodes) {
|
|
98
|
+
if (!visible(n.id))
|
|
99
|
+
continue;
|
|
100
|
+
if (isPlanZone(n))
|
|
101
|
+
zones.push(n.id);
|
|
102
|
+
else if (isPlanEvent(n))
|
|
103
|
+
events.push(n.id);
|
|
104
|
+
}
|
|
105
|
+
const zoneSet = new Set(zones);
|
|
106
|
+
const parent = new Map();
|
|
107
|
+
for (const [id, parents] of h.parentsOf) {
|
|
108
|
+
const zoneParent = parents.find((p) => zoneSet.has(p));
|
|
109
|
+
if (zoneParent !== undefined)
|
|
110
|
+
parent.set(id, zoneParent);
|
|
111
|
+
}
|
|
112
|
+
// Per zone, its h.childrenOf list keeps that zone's own containment-edge
|
|
113
|
+
// order; filtering by `parent.get(c) === z` drops a child here when another
|
|
114
|
+
// zone earlier in ITS OWN edge order already claimed it, leaving each DAG
|
|
115
|
+
// child in exactly the one zone `parent` (and the view) picked for it.
|
|
116
|
+
const children = new Map();
|
|
117
|
+
for (const z of zones) {
|
|
118
|
+
const split = { zones: [], events: [], others: [] };
|
|
119
|
+
for (const c of h.childrenOf.get(z) ?? []) {
|
|
120
|
+
if (parent.get(c) !== z)
|
|
121
|
+
continue;
|
|
122
|
+
const node = byId.get(c);
|
|
123
|
+
if (node === undefined)
|
|
124
|
+
continue;
|
|
125
|
+
if (isPlanZone(node))
|
|
126
|
+
split.zones.push(c);
|
|
127
|
+
else if (isPlanEvent(node))
|
|
128
|
+
split.events.push(c);
|
|
129
|
+
else
|
|
130
|
+
split.others.push(c);
|
|
131
|
+
}
|
|
132
|
+
children.set(z, split);
|
|
133
|
+
}
|
|
134
|
+
const actors = [];
|
|
135
|
+
for (const r of model.relations) {
|
|
136
|
+
if (isPlanRole(r.kind) && zoneSet.has(r.to) && !actors.includes(r.from) && byId.has(r.from))
|
|
137
|
+
actors.push(r.from);
|
|
138
|
+
}
|
|
139
|
+
let range;
|
|
140
|
+
const widen = (start, end) => {
|
|
141
|
+
range = range === undefined ? { start, end } : { start: Math.min(range.start, start), end: Math.max(range.end, end) };
|
|
142
|
+
};
|
|
143
|
+
for (const z of zones) {
|
|
144
|
+
const s = spanOf(byId.get(z));
|
|
145
|
+
if (s !== undefined)
|
|
146
|
+
widen(s.start, s.end);
|
|
147
|
+
}
|
|
148
|
+
for (const e of events) {
|
|
149
|
+
const at = atOf(byId.get(e));
|
|
150
|
+
if (at !== undefined)
|
|
151
|
+
widen(at, at);
|
|
152
|
+
}
|
|
153
|
+
const origin = range === undefined ? undefined : dayOf(`${isoOf(range.start).slice(0, 4)}-01-01`);
|
|
154
|
+
return { zones, events, actors, parent, children, ...(range !== undefined ? { range } : {}), ...(origin !== undefined ? { origin } : {}) };
|
|
155
|
+
}
|
|
156
|
+
/** `id` plus every descendant zone and event, pre-order — what moves with a
|
|
157
|
+
* zone. Borrowed nodes are not listed: their place is their row. */
|
|
158
|
+
export function planSubtree(g, id) {
|
|
159
|
+
const out = [id];
|
|
160
|
+
const walk = (z) => {
|
|
161
|
+
const c = g.children.get(z);
|
|
162
|
+
if (c === undefined)
|
|
163
|
+
return;
|
|
164
|
+
for (const child of c.zones) {
|
|
165
|
+
out.push(child);
|
|
166
|
+
walk(child);
|
|
167
|
+
}
|
|
168
|
+
out.push(...c.events);
|
|
169
|
+
};
|
|
170
|
+
walk(id);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -76,6 +76,10 @@ export interface DiagramNode {
|
|
|
76
76
|
technology?: string;
|
|
77
77
|
/** STRIDE findings against this node (see Threat) */
|
|
78
78
|
threats?: Threat[];
|
|
79
|
+
/** remarks shown in the element's bubble (see Comment) */
|
|
80
|
+
comments?: Comment[];
|
|
81
|
+
/** resources listed in the element's bubble (see Link) */
|
|
82
|
+
links?: Link[];
|
|
79
83
|
description?: string;
|
|
80
84
|
/** rich multiline label; when present, name === rich.map(r => r.text).join('') */
|
|
81
85
|
rich?: TextRun[];
|
|
@@ -181,6 +185,24 @@ export interface Threat {
|
|
|
181
185
|
status?: ThreatStatus;
|
|
182
186
|
mitigation?: string;
|
|
183
187
|
}
|
|
188
|
+
/** A remark on an element: what was said, by whom, when. Generic like
|
|
189
|
+
* `threats` — any node or relation in any notation can carry a list. Shown in
|
|
190
|
+
* the element's bubble on the canvas; never affects layout. */
|
|
191
|
+
export interface Comment {
|
|
192
|
+
/** unique within its element's list (`c1`, `c2`, … when synthesized) */
|
|
193
|
+
id: string;
|
|
194
|
+
text: string;
|
|
195
|
+
/** author, free text */
|
|
196
|
+
by?: string;
|
|
197
|
+
/** `YYYY-MM-DD` */
|
|
198
|
+
at?: string;
|
|
199
|
+
}
|
|
200
|
+
/** A resource an element points at, beyond the single navigation `link`: a
|
|
201
|
+
* ticket, a design doc, a repo. Listed in the element's bubble. */
|
|
202
|
+
export interface Link {
|
|
203
|
+
label: string;
|
|
204
|
+
url: string;
|
|
205
|
+
}
|
|
184
206
|
export interface DiagramRelation {
|
|
185
207
|
id: string;
|
|
186
208
|
from: string;
|
|
@@ -192,6 +214,8 @@ export interface DiagramRelation {
|
|
|
192
214
|
labels?: EdgeLabel[];
|
|
193
215
|
/** STRIDE findings against this flow (see Threat) */
|
|
194
216
|
threats?: Threat[];
|
|
217
|
+
/** remarks shown in the relation's bubble (see Comment) */
|
|
218
|
+
comments?: Comment[];
|
|
195
219
|
style?: RelationStyle;
|
|
196
220
|
description?: string;
|
|
197
221
|
layer?: string;
|
|
@@ -220,7 +244,9 @@ export interface LegendItem {
|
|
|
220
244
|
color?: string;
|
|
221
245
|
icon?: string;
|
|
222
246
|
}
|
|
223
|
-
|
|
247
|
+
/** `marks` is what is neither a node type nor a line kind: a threat badge, a table's
|
|
248
|
+
* key and foreign-key column tags. */
|
|
249
|
+
export declare const LEGEND_SECTIONS: readonly ["layers", "kinds", "types", "marks"];
|
|
224
250
|
export type LegendSection = (typeof LEGEND_SECTIONS)[number];
|
|
225
251
|
export declare const LEGEND_POSITIONS: readonly ["top-left", "top-right", "bottom-left", "bottom-right"];
|
|
226
252
|
export type LegendPosition = (typeof LEGEND_POSITIONS)[number];
|
|
@@ -231,7 +257,9 @@ export interface DiagramLegend {
|
|
|
231
257
|
title?: string;
|
|
232
258
|
/** default 'bottom-right' — the only corner not already occupied by chrome */
|
|
233
259
|
position?: LegendPosition;
|
|
234
|
-
/** derived sections to include
|
|
260
|
+
/** derived sections to include, exactly. Absent = `layers`, `kinds`, `marks`, plus the
|
|
261
|
+
* element shapes that carry no words of their own on the canvas (a start dot, a DFD
|
|
262
|
+
* process); every other element waits for an explicit `types`. */
|
|
235
263
|
show?: LegendSection[];
|
|
236
264
|
/** hand-written rows, appended after the derived ones */
|
|
237
265
|
items?: LegendItem[];
|
|
@@ -390,6 +418,6 @@ export interface Drawings {
|
|
|
390
418
|
}
|
|
391
419
|
/** Pen width when a stroke names none. In core so editor and renderer cannot drift. */
|
|
392
420
|
export declare const DEFAULT_STROKE_WIDTH = 3;
|
|
393
|
-
export declare const BUILTIN_NOTATIONS: readonly ["causal-loop", "git-graph", "c4", "second-order", "fishbone", "threat-model"];
|
|
421
|
+
export declare const BUILTIN_NOTATIONS: readonly ["causal-loop", "git-graph", "c4", "second-order", "fishbone", "threat-model", "plan"];
|
|
394
422
|
export type NotationId = (typeof BUILTIN_NOTATIONS)[number];
|
|
395
423
|
export type Polarity = '+' | '-';
|
package/dist/types.js
CHANGED
|
@@ -20,8 +20,10 @@ export const EDGE_LABEL_SIDES = ['top', 'bottom', 'center'];
|
|
|
20
20
|
export const STRIDE = ['S', 'T', 'R', 'I', 'D', 'E'];
|
|
21
21
|
export const THREAT_STATUSES = ['open', 'mitigated', 'accepted', 'not-applicable'];
|
|
22
22
|
export const THREAT_SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
23
|
-
|
|
23
|
+
/** `marks` is what is neither a node type nor a line kind: a threat badge, a table's
|
|
24
|
+
* key and foreign-key column tags. */
|
|
25
|
+
export const LEGEND_SECTIONS = ['layers', 'kinds', 'types', 'marks'];
|
|
24
26
|
export const LEGEND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
25
27
|
/** Pen width when a stroke names none. In core so editor and renderer cannot drift. */
|
|
26
28
|
export const DEFAULT_STROKE_WIDTH = 3;
|
|
27
|
-
export const BUILTIN_NOTATIONS = ['causal-loop', 'git-graph', 'c4', 'second-order', 'fishbone', 'threat-model'];
|
|
29
|
+
export const BUILTIN_NOTATIONS = ['causal-loop', 'git-graph', 'c4', 'second-order', 'fishbone', 'threat-model', 'plan'];
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DiagramModel } from './types.js';
|
|
2
2
|
export interface ValidationIssue {
|
|
3
|
-
code: 'duplicate-node' | 'reserved-node-id' | 'duplicate-layer' | 'duplicate-plane' | 'duplicate-relation' | 'containment-cycle' | 'dangling-endpoint' | 'unknown-layer' | 'unknown-plane' | 'unknown-hidden-node' | 'redundant-hide' | 'invalid-plane' | 'invalid-style' | 'invalid-legend' | 'invalid-image' | 'invalid-shape' | 'invalid-link' | 'invalid-key' | 'duplicate-key' | 'invalid-include' | 'unknown-notation' | 'invalid-polarity' | 'invalid-delay' | 'invalid-rich' | 'invalid-align' | 'invalid-font-scale' | 'invalid-edge-label' | 'duplicate-column' | 'unknown-column' | 'git-link-endpoints' | 'git-commit-lane' | 'git-parents' | 'git-cycle' | 'git-commit-outside-lane' | 'git-gap' | 'git-stage-span' | 'activity-lane-parent' | 'activity-frame-children' | 'activity-region-parent' | 'so-no-decision' | 'so-cycle' | 'so-unreachable' | 'so-contained' | 'fb-no-effect' | 'fb-many-effects' | 'fb-unattached' | 'fb-misplaced' | 'fb-too-deep' | 'fb-contained' | 'invalid-threats' | 'threat-id' | 'threat-title' | 'threat-category' | 'threat-status' | 'threat-severity' | 'tm-flow-boundary';
|
|
3
|
+
code: 'duplicate-node' | 'reserved-node-id' | 'duplicate-layer' | 'duplicate-plane' | 'duplicate-relation' | 'containment-cycle' | 'dangling-endpoint' | 'unknown-layer' | 'unknown-plane' | 'unknown-hidden-node' | 'redundant-hide' | 'invalid-plane' | 'invalid-style' | 'invalid-legend' | 'invalid-image' | 'invalid-shape' | 'invalid-link' | 'invalid-key' | 'duplicate-key' | 'invalid-include' | 'unknown-notation' | 'invalid-polarity' | 'invalid-delay' | 'invalid-rich' | 'invalid-align' | 'invalid-font-scale' | 'invalid-edge-label' | 'duplicate-column' | 'unknown-column' | 'git-link-endpoints' | 'git-commit-lane' | 'git-parents' | 'git-cycle' | 'git-commit-outside-lane' | 'git-gap' | 'git-stage-span' | 'activity-lane-parent' | 'activity-frame-children' | 'activity-region-parent' | 'so-no-decision' | 'so-cycle' | 'so-unreachable' | 'so-contained' | 'fb-no-effect' | 'fb-many-effects' | 'fb-unattached' | 'fb-misplaced' | 'fb-too-deep' | 'fb-contained' | 'invalid-threats' | 'threat-id' | 'threat-title' | 'threat-category' | 'threat-status' | 'threat-severity' | 'invalid-comments' | 'comment-id' | 'comment-text' | 'comment-at' | 'invalid-links' | 'tm-flow-boundary' | 'plan-date' | 'plan-missing' | 'plan-span' | 'plan-nested' | 'plan-role-target';
|
|
4
4
|
message: string;
|
|
5
5
|
ref?: string;
|
|
6
6
|
}
|