@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/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
- export declare function addContainment(m: DiagramModel, parent: string, child: string, plane?: string): DiagramModel;
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, details[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 threats = fn(x.threats ?? []);
191
- const { threats: _dropped, ...rest } = x;
192
- return (threats.length === 0 ? rest : { ...rest, threats });
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 edges are stored/compared in the untagged base form. Throws on unknowns.
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
- if (m.containment.some((e) => e.parent === parent && e.child === child && e.plane === canon))
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
- return { ...m, containment: [...m.containment, { parent, child, ...(canon !== undefined ? { plane: canon } : {}) }] };
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 === canon)),
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
- export declare const LEGEND_SECTIONS: readonly ["layers", "kinds", "types"];
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; default ['layers', 'kinds'] */
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
- export const LEGEND_SECTIONS = ['layers', 'kinds', 'types'];
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'];
@@ -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
  }