@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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # @diagc/core
2
2
 
3
- The diagram model behind [`diagc`](https://www.npmjs.com/package/diagc): the `DiagramModel` types, `validate()`, the builder DSL, and the view compiler. No React, no filesystem — it does not know it is going to be drawn.
3
+ The diagram model behind [`diagc`](https://www.npmjs.com/package/@diagc/cli): the `DiagramModel` types, `validate()`, the builder DSL, and the view compiler. No React, no filesystem — it does not know it is going to be drawn.
4
4
 
5
5
  ```bash
6
6
  npm i -D @diagc/core
7
7
  ```
8
8
 
9
- Install it alongside `diagc` to get types and completion when authoring `.diagram.ts` files:
9
+ Install it alongside the CLI (`@diagc/cli`) to get types and completion when authoring `.diagram.ts` files:
10
10
 
11
11
  ```ts
12
12
  import { model } from '@diagc/core';
@@ -24,4 +24,4 @@ Docs and the full model reference: <https://github.com/Ferroman/diagc>
24
24
 
25
25
  ## License
26
26
 
27
- MIT
27
+ [AGPL-3.0-only](https://github.com/Ferroman/diagc/blob/main/LICENSE), with additional permissions under section 7: diagrams you author, and the pages and images built from them, are yours to license however you like. A commercial license is available. See the [project README](https://github.com/Ferroman/diagc#license).
package/dist/builder.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Column, DiagramLegend, DiagramModel, EdgeLabel, FontScale, LayerRule, NotationId, Polarity, RelationStyle, TextAlign, TextRun, Threat } from './types.js';
1
+ import type { Column, Comment, DiagramLegend, DiagramModel, EdgeLabel, FontScale, LayerRule, Link, NotationId, Polarity, RelationStyle, TextAlign, TextRun, Threat } from './types.js';
2
+ import { type ElementTarget } from './comments.js';
2
3
  import { type FishbonePreset } from './fishbone.js';
3
4
  import { type Valence } from './second-order.js';
4
5
  import { type ThreatTarget } from './threat-model.js';
@@ -42,6 +43,10 @@ export interface NodeOpts {
42
43
  columns?: Column[];
43
44
  /** STRIDE findings (see DiagramNode.threats) */
44
45
  threats?: Threat[];
46
+ /** remarks shown in this node's bubble (see DiagramNode.comments) */
47
+ comments?: Comment[];
48
+ /** resources this node points at, listed in its bubble (see DiagramNode.links) */
49
+ links?: Link[];
45
50
  }
46
51
  export interface RelateOpts {
47
52
  kind: string;
@@ -63,11 +68,19 @@ export interface RelateOpts {
63
68
  toColumn?: string;
64
69
  /** STRIDE findings (see DiagramRelation.threats) */
65
70
  threats?: Threat[];
71
+ /** remarks shown in this relation's bubble (see DiagramRelation.comments) */
72
+ comments?: Comment[];
66
73
  }
67
74
  /** A threat as authored: the id is synthesized (`t1`, `t2`, …) unless given. */
68
75
  export type ThreatOpts = Omit<Threat, 'id'> & {
69
76
  id?: string;
70
77
  };
78
+ /** A comment as authored: the id is synthesized (`c1`, `c2`, …) unless given. */
79
+ export interface CommentOpts {
80
+ id?: string;
81
+ by?: string;
82
+ at?: string;
83
+ }
71
84
  export interface ContainsOpts {
72
85
  /** plane the containment belongs to; defaults to the model's first-declared plane */
73
86
  plane?: string;
@@ -82,6 +95,10 @@ export declare class NodeRef {
82
95
  * threat-model ones: threat-modelling an existing C4 or ER diagram annotates
83
96
  * the nodes it already has. */
84
97
  threat(opts: ThreatOpts): this;
98
+ /** A remark on this element, shown in its bubble. */
99
+ comment(text: string, opts?: CommentOpts): this;
100
+ /** A resource this element points at, listed in its bubble. */
101
+ link(label: string, url: string): this;
85
102
  }
86
103
  export interface CommitOpts {
87
104
  /** default `${branchId}-${n}`, n = this branch's 1-based commit count */
@@ -189,12 +206,14 @@ export declare class FishboneBuilder {
189
206
  /** the bones of a standard set, keyed by their slug ids (`presetId`) */
190
207
  categories(preset: FishbonePreset): Record<string, CategoryRef>;
191
208
  }
192
- /** A data flow: a relation ref that takes threats, the way a NodeRef does. */
209
+ /** A data flow: a relation ref that takes threats and comments, the way a NodeRef does. */
193
210
  export declare class FlowRef {
194
211
  readonly id: string;
195
212
  private readonly m;
196
213
  constructor(id: string, m: ModelBuilder);
197
214
  threat(opts: ThreatOpts): this;
215
+ /** A remark on this flow, shown in its bubble. */
216
+ comment(text: string, opts?: CommentOpts): this;
198
217
  }
199
218
  /** A DFD element's node options, minus the two its helper already supplies:
200
219
  * `type` from the helper itself, `name` from its second argument. */
@@ -217,6 +236,58 @@ export declare class ThreatModelBuilder {
217
236
  /** a data flow; a string is its label */
218
237
  flow(from: NodeRef, to: NodeRef, labelOrOpts?: string | Omit<RelateOpts, 'kind'>): FlowRef;
219
238
  }
239
+ /** A zone's node options: the two dates are required, `type`/`plane`/`metadata`
240
+ * are the builder's. */
241
+ export interface ZoneOpts extends Omit<NodeOpts, 'type' | 'metadata' | 'plane'> {
242
+ start: string;
243
+ end: string;
244
+ }
245
+ export interface EventOpts extends Omit<NodeOpts, 'type' | 'metadata' | 'plane'> {
246
+ at: string;
247
+ }
248
+ /**
249
+ * A zone: a NodeRef (so comment/link/threat compose) whose helpers nest on the
250
+ * PLAN plane. Everything a zone creates is scoped to that plane — a plan added
251
+ * to an architecture model must not leak bars into the architecture view — and
252
+ * every containment edge names the plane, so the plan need not be the first
253
+ * plane declared.
254
+ */
255
+ export declare class ZoneBuilder extends NodeRef {
256
+ private readonly m;
257
+ /** the plan plane's id */
258
+ readonly plane: string;
259
+ constructor(id: string, m: ModelBuilder,
260
+ /** the plan plane's id */
261
+ plane: string);
262
+ /** a nested zone */
263
+ zone(id: string, opts: ZoneOpts): ZoneBuilder;
264
+ /** an event inside this zone */
265
+ event(id: string, opts: EventOpts): NodeRef;
266
+ /** schedule any node (a C4 container, an ER table…) inside this zone */
267
+ contains(...nodes: NodeRef[]): this;
268
+ private role;
269
+ owner(n: NodeRef): this;
270
+ executor(n: NodeRef): this;
271
+ checker(n: NodeRef): this;
272
+ }
273
+ /** Root-level plan helpers; see ZoneBuilder for the nested ones. */
274
+ export declare class PlanBuilder {
275
+ private readonly m;
276
+ readonly plane: string;
277
+ constructor(m: ModelBuilder, plane: string);
278
+ /** a top-level zone */
279
+ zone(id: string, opts: ZoneOpts): ZoneBuilder;
280
+ /** a top-level event, drawn in the header */
281
+ event(id: string, opts: EventOpts): NodeRef;
282
+ /** a person to hand roles to (`zone.owner(p)` …). `plane` is omitted, not
283
+ * just overridden: a person is always scoped to the plan plane, and `...opts`
284
+ * spreads after `plane: this.plane`, so a merely-overridden plane would
285
+ * silently win over the forced one. */
286
+ person(id: string, name?: string, opts?: Omit<ElementOpts, 'plane'>): NodeRef;
287
+ /** a team to hand roles to, same deal as `person` — an actor that holds a
288
+ * role but is never an individual. Same forced-plane guard. */
289
+ team(id: string, name?: string, opts?: Omit<ElementOpts, 'plane'>): NodeRef;
290
+ }
220
291
  export interface ActivityElementOpts {
221
292
  color?: string;
222
293
  }
@@ -281,6 +352,7 @@ export declare class ModelBuilder {
281
352
  private so;
282
353
  private fb;
283
354
  private tm;
355
+ private pl;
284
356
  constructor(id: string, name: string);
285
357
  node(id: string, opts?: NodeOpts): NodeRef;
286
358
  /** ER table: a node of type 'db-table' carrying `columns`. */
@@ -298,6 +370,11 @@ export declare class ModelBuilder {
298
370
  /** internal — appends a threat to the node or relation `target` names; used by
299
371
  * NodeRef.threat() and FlowRef.threat() */
300
372
  addThreat(target: ThreatTarget, opts: ThreatOpts): void;
373
+ /** internal — appends a comment to the node or relation `target` names; used by
374
+ * NodeRef.comment() and FlowRef.comment() */
375
+ addComment(target: ElementTarget, text: string, opts: CommentOpts): void;
376
+ /** internal — appends a link to a node; used by NodeRef.link() */
377
+ addLink(nodeId: string, link: Link): void;
301
378
  relate(from: NodeRef, to: NodeRef, opts: RelateOpts): this;
302
379
  /** internal — like relate(), but returns the new relation's id (FlowRef needs
303
380
  * it to hang threats off the flow) */
@@ -353,6 +430,15 @@ export declare class ModelBuilder {
353
430
  plane?: string;
354
431
  name?: string;
355
432
  }): ThreatModelBuilder;
433
+ /**
434
+ * Declare a plan (schedule) plane. Always a plane — zones are containers with
435
+ * dates, and the plan's containment must not be the architecture's. Need not
436
+ * be the first plane: the intended use is a plan plane added to an existing
437
+ * model, scheduling that model's nodes inside its zones.
438
+ */
439
+ plan(id?: string, opts?: {
440
+ name?: string;
441
+ }): PlanBuilder;
356
442
  /** Declare an activity diagram: a framed swimlane flow. Repeatable — each
357
443
  * call is one frame; frames are ordinary containers on whatever plane the
358
444
  * model uses (no notation, no plane creation). */
package/dist/builder.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { nextCommentId } from './comments.js';
1
2
  import { FB_CATEGORY_TYPE, FB_CAUSE_OF_KIND, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_PRESETS, presetId } from './fishbone.js';
2
3
  import { GIT_STAGE_TYPE } from './git.js';
3
4
  import { SO_DECISION_TYPE, SO_LEADS_TO_KIND, consequenceTypeOf } from './second-order.js';
4
5
  import { TM_BOUNDARY_TYPE, TM_ENTITY_TYPE, TM_FLOW_KIND, TM_NOTATION, TM_PROCESS_TYPE, TM_STORE_TYPE, } from './threat-model.js';
5
6
  import { DiagramValidationError, validate } from './validate.js';
7
+ import { PLAN_EVENT_TYPE, PLAN_NOTATION, PLAN_PERSON_TYPE, PLAN_TEAM_TYPE, PLAN_ZONE_TYPE } from './plan.js';
6
8
  export class NodeRef {
7
9
  id;
8
10
  builder;
@@ -27,6 +29,16 @@ export class NodeRef {
27
29
  this.builder.addThreat({ node: this.id }, opts);
28
30
  return this;
29
31
  }
32
+ /** A remark on this element, shown in its bubble. */
33
+ comment(text, opts = {}) {
34
+ this.builder.addComment({ node: this.id }, text, opts);
35
+ return this;
36
+ }
37
+ /** A resource this element points at, listed in its bubble. */
38
+ link(label, url) {
39
+ this.builder.addLink(this.id, { label, url });
40
+ return this;
41
+ }
30
42
  }
31
43
  export class CommitRef extends NodeRef {
32
44
  branch;
@@ -201,7 +213,7 @@ export class FishboneBuilder {
201
213
  }));
202
214
  }
203
215
  }
204
- /** A data flow: a relation ref that takes threats, the way a NodeRef does. */
216
+ /** A data flow: a relation ref that takes threats and comments, the way a NodeRef does. */
205
217
  export class FlowRef {
206
218
  id;
207
219
  m;
@@ -213,6 +225,11 @@ export class FlowRef {
213
225
  this.m.addThreat({ relation: this.id }, opts);
214
226
  return this;
215
227
  }
228
+ /** A remark on this flow, shown in its bubble. */
229
+ comment(text, opts = {}) {
230
+ this.m.addComment({ relation: this.id }, text, opts);
231
+ return this;
232
+ }
216
233
  }
217
234
  /** The four DFD element kinds plus flows. Everything it hands back is an
218
235
  * ordinary NodeRef/FlowRef, so the rest of the builder — contains, relate,
@@ -247,6 +264,93 @@ export class ThreatModelBuilder {
247
264
  return new FlowRef(this.m.addRelation(from, to, { kind: TM_FLOW_KIND, ...opts }), this.m);
248
265
  }
249
266
  }
267
+ /**
268
+ * A zone: a NodeRef (so comment/link/threat compose) whose helpers nest on the
269
+ * PLAN plane. Everything a zone creates is scoped to that plane — a plan added
270
+ * to an architecture model must not leak bars into the architecture view — and
271
+ * every containment edge names the plane, so the plan need not be the first
272
+ * plane declared.
273
+ */
274
+ export class ZoneBuilder extends NodeRef {
275
+ m;
276
+ plane;
277
+ constructor(id, m,
278
+ /** the plan plane's id */
279
+ plane) {
280
+ super(id, m);
281
+ this.m = m;
282
+ this.plane = plane;
283
+ }
284
+ /** a nested zone */
285
+ zone(id, opts) {
286
+ const z = planZone(this.m, this.plane, id, opts);
287
+ this.m.addContainment(this.id, id, this.plane);
288
+ return z;
289
+ }
290
+ /** an event inside this zone */
291
+ event(id, opts) {
292
+ const e = planEvent(this.m, this.plane, id, opts);
293
+ this.m.addContainment(this.id, id, this.plane);
294
+ return e;
295
+ }
296
+ /** schedule any node (a C4 container, an ER table…) inside this zone */
297
+ contains(...nodes) {
298
+ for (const n of nodes)
299
+ this.m.addContainment(this.id, n.id, this.plane);
300
+ return this;
301
+ }
302
+ role(n, kind) {
303
+ this.m.addRelation(n, this, { kind });
304
+ return this;
305
+ }
306
+ owner(n) {
307
+ return this.role(n, 'owns');
308
+ }
309
+ executor(n) {
310
+ return this.role(n, 'executes');
311
+ }
312
+ checker(n) {
313
+ return this.role(n, 'checks');
314
+ }
315
+ }
316
+ function planZone(m, plane, id, opts) {
317
+ const { start, end, ...rest } = opts;
318
+ m.node(id, { type: PLAN_ZONE_TYPE, plane, metadata: { start, end }, ...rest });
319
+ return new ZoneBuilder(id, m, plane);
320
+ }
321
+ function planEvent(m, plane, id, opts) {
322
+ const { at, ...rest } = opts;
323
+ return m.node(id, { type: PLAN_EVENT_TYPE, plane, metadata: { at }, ...rest });
324
+ }
325
+ /** Root-level plan helpers; see ZoneBuilder for the nested ones. */
326
+ export class PlanBuilder {
327
+ m;
328
+ plane;
329
+ constructor(m, plane) {
330
+ this.m = m;
331
+ this.plane = plane;
332
+ }
333
+ /** a top-level zone */
334
+ zone(id, opts) {
335
+ return planZone(this.m, this.plane, id, opts);
336
+ }
337
+ /** a top-level event, drawn in the header */
338
+ event(id, opts) {
339
+ return planEvent(this.m, this.plane, id, opts);
340
+ }
341
+ /** a person to hand roles to (`zone.owner(p)` …). `plane` is omitted, not
342
+ * just overridden: a person is always scoped to the plan plane, and `...opts`
343
+ * spreads after `plane: this.plane`, so a merely-overridden plane would
344
+ * silently win over the forced one. */
345
+ person(id, name, opts = {}) {
346
+ return this.m.node(id, { type: PLAN_PERSON_TYPE, plane: this.plane, ...(name !== undefined ? { name } : {}), ...opts });
347
+ }
348
+ /** a team to hand roles to, same deal as `person` — an actor that holds a
349
+ * role but is never an individual. Same forced-plane guard. */
350
+ team(id, name, opts = {}) {
351
+ return this.m.node(id, { type: PLAN_TEAM_TYPE, plane: this.plane, ...(name !== undefined ? { name } : {}), ...opts });
352
+ }
353
+ }
250
354
  /** Shared element surface of a lane and a region: each helper creates a typed
251
355
  * node contained by this scope and hands back a plain NodeRef, so everything
252
356
  * composes with the rest of the builder (relate, layers, contains). */
@@ -361,6 +465,7 @@ export class ModelBuilder {
361
465
  so;
362
466
  fb;
363
467
  tm;
468
+ pl;
364
469
  constructor(id, name) {
365
470
  this.id = id;
366
471
  this.name = name;
@@ -419,6 +524,33 @@ export class ModelBuilder {
419
524
  }
420
525
  element.threats = [...threats, threat];
421
526
  }
527
+ /** internal — appends a comment to the node or relation `target` names; used by
528
+ * NodeRef.comment() and FlowRef.comment() */
529
+ addComment(target, text, opts) {
530
+ const element = 'node' in target
531
+ ? this.nodes.find((n) => n.id === target.node)
532
+ : this.relations.find((r) => r.id === target.relation);
533
+ if (element === undefined) {
534
+ throw new Error('node' in target
535
+ ? `comment(): unknown node '${target.node}'`
536
+ : `comment(): unknown relation '${target.relation}'`);
537
+ }
538
+ const comments = element.comments ?? [];
539
+ const { id, ...rest } = opts;
540
+ // per-element ids, as threats: two elements' first comments are both c1
541
+ const comment = { id: id ?? nextCommentId(comments), text, ...pruneUndefined(rest) };
542
+ if (comments.some((c) => c.id === comment.id)) {
543
+ throw new Error(`comment(): duplicate comment id '${comment.id}' on '${element.id}'`);
544
+ }
545
+ element.comments = [...comments, comment];
546
+ }
547
+ /** internal — appends a link to a node; used by NodeRef.link() */
548
+ addLink(nodeId, link) {
549
+ const node = this.nodes.find((n) => n.id === nodeId);
550
+ if (node === undefined)
551
+ throw new Error(`link(): unknown node '${nodeId}'`);
552
+ node.links = [...(node.links ?? []), link];
553
+ }
422
554
  relate(from, to, opts) {
423
555
  this.addRelation(from, to, opts);
424
556
  return this;
@@ -523,6 +655,19 @@ export class ModelBuilder {
523
655
  this.tm = new ThreatModelBuilder(this);
524
656
  return this.tm;
525
657
  }
658
+ /**
659
+ * Declare a plan (schedule) plane. Always a plane — zones are containers with
660
+ * dates, and the plan's containment must not be the architecture's. Need not
661
+ * be the first plane: the intended use is a plan plane added to an existing
662
+ * model, scheduling that model's nodes inside its zones.
663
+ */
664
+ plan(id = 'plan', opts = {}) {
665
+ if (this.pl !== undefined)
666
+ throw new Error('plan() already declared');
667
+ this.plane(id, { name: opts.name ?? 'Plan', notation: PLAN_NOTATION });
668
+ this.pl = new PlanBuilder(this, id);
669
+ return this.pl;
670
+ }
526
671
  /** Declare an activity diagram: a framed swimlane flow. Repeatable — each
527
672
  * call is one frame; frames are ordinary containers on whatever plane the
528
673
  * model uses (no notation, no plane creation). */
@@ -1,6 +1,6 @@
1
- import type { Column, DiagramLayer, DiagramLegend, DiagramModel, DiagramNode, DiagramPlane, Drawings, EdgeLabelPlacement, LayoutOverlay, LayoutSettings, Stroke, TextRun, Threat } from './types.js';
1
+ import type { Column, Comment, DiagramLayer, DiagramLegend, DiagramModel, DiagramNode, DiagramPlane, Drawings, EdgeLabelPlacement, LayoutOverlay, LayoutSettings, Stroke, TextRun, Threat } from './types.js';
2
2
  import { type ThreatTarget } from './threat-model.js';
3
- import { type NodeDetails, type RelationOptsInput, type RelationPatch, type ThreatPatch } from './mutate.js';
3
+ import { type CommentPatch, type NodeDetails, type PlanDates, type RelationOptsInput, type RelationPatch, type ThreatPatch } from './mutate.js';
4
4
  export interface EditorState {
5
5
  model: DiagramModel;
6
6
  layout: LayoutOverlay;
@@ -18,9 +18,13 @@ export declare function openingPins(layout: LayoutOverlay | undefined, m: Diagra
18
18
  export type EditorCommand = {
19
19
  type: 'add-node';
20
20
  node: DiagramNode;
21
+ /** `before`/`after` name a sibling to slot the new membership beside:
22
+ * child order is containment declaration order (activity lanes, git lanes) */
21
23
  parent?: {
22
24
  id: string;
23
25
  plane?: string;
26
+ before?: string;
27
+ after?: string;
24
28
  };
25
29
  } | {
26
30
  type: 'rename-node';
@@ -30,6 +34,10 @@ export type EditorCommand = {
30
34
  type: 'set-node-details';
31
35
  id: string;
32
36
  details: NodeDetails;
37
+ } | {
38
+ type: 'set-plan-dates';
39
+ id: string;
40
+ dates: PlanDates;
33
41
  } | {
34
42
  type: 'set-node-rich';
35
43
  id: string;
@@ -54,6 +62,21 @@ export type EditorCommand = {
54
62
  type: 'remove-threat';
55
63
  target: ThreatTarget;
56
64
  id: string;
65
+ }
66
+ /** Comments ride on the element too — same target type as the threat trio. */
67
+ | {
68
+ type: 'add-comment';
69
+ target: ThreatTarget;
70
+ comment: Comment;
71
+ } | {
72
+ type: 'update-comment';
73
+ target: ThreatTarget;
74
+ id: string;
75
+ patch: CommentPatch;
76
+ } | {
77
+ type: 'remove-comment';
78
+ target: ThreatTarget;
79
+ id: string;
57
80
  } | {
58
81
  type: 'set-node-plane-hidden';
59
82
  nodeId: string;
package/dist/commands.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { threatTargetKey } from './threat-model.js';
2
+ import { hasNoteContent } from './comments.js';
2
3
  import { resolveContainmentPlane } from './view/compile.js';
3
4
  import { addStroke, deleteStroke, pruneDrawingsPlane } from './drawings.js';
4
5
  import { relationLabels } from './labels.js';
5
- import { addContainment, addNode, addRelation, addThreat, CommandError, deleteLayer, deleteNode, deletePlane, deleteRelation, groupNodes, mergeLayers, removeContainment, removeThreat, renameNode, setDiagramLegend, setDiagramNotation, setDiagramStyle, setNodeDetails, setNodePlaneHidden, setNodeRich, setTableColumns, subtreeOf, updateRelation, updateThreat, upsertLayer, upsertPlane, } from './mutate.js';
6
+ import { addComment, addContainment, addNode, addRelation, addThreat, CommandError, deleteLayer, deleteNode, deletePlane, deleteRelation, groupNodes, mergeLayers, removeComment, removeContainment, removeThreat, renameNode, setDiagramLegend, setDiagramNotation, setDiagramStyle, setNodeDetails, setNodePlaneHidden, setNodeRich, setPlanDates, setTableColumns, subtreeOf, updateComment, updateRelation, updateThreat, upsertLayer, upsertPlane, } from './mutate.js';
6
7
  export const emptyLayout = () => ({ version: 1, planes: {} });
7
8
  export function layoutPlaneKey(m, plane) {
8
9
  return resolveContainmentPlane(m, plane) ?? 'default';
@@ -122,19 +123,22 @@ function withOpen(p, open) {
122
123
  }
123
124
  /**
124
125
  * Mirror hygiene for `notes` after a command changed the model: a note exists
125
- * only while its element has a threat, so an offset for an element that lost
126
- * its last threat — or was deleted — is dead data. Identity is kept when
127
- * nothing is dropped, like pruneEdgeLabels.
126
+ * only while its element has something to show, so an offset for an element
127
+ * that lost its last threat/comment/link — or was deleted — is dead data.
128
+ * `hasNoteContent` is the renderer's own test for drawing a bubble; reusing it
129
+ * is what stops a command that merely rewrote `nodes` (a rename, an edited
130
+ * comment) from throwing away a live bubble's saved place. Identity is kept
131
+ * when nothing is dropped, like pruneEdgeLabels.
128
132
  */
129
133
  function pruneNotes(layout, before, after) {
130
134
  if (layout.notes === undefined || (before.nodes === after.nodes && before.relations === after.relations))
131
135
  return layout;
132
136
  const alive = new Set();
133
137
  for (const n of after.nodes)
134
- if ((n.threats?.length ?? 0) > 0)
138
+ if (hasNoteContent(n))
135
139
  alive.add(threatTargetKey({ node: n.id }));
136
140
  for (const r of after.relations)
137
- if ((r.threats?.length ?? 0) > 0)
141
+ if (hasNoteContent(r))
138
142
  alive.add(threatTargetKey({ relation: r.id }));
139
143
  let changed = false;
140
144
  const planes = {};
@@ -241,7 +245,13 @@ function applyModelLayout(state, command) {
241
245
  case 'add-node': {
242
246
  let next = addNode(model, command.node);
243
247
  if (command.parent !== undefined) {
244
- next = addContainment(next, command.parent.id, command.node.id, command.parent.plane);
248
+ const { id, plane, before, after } = command.parent;
249
+ const beside = before !== undefined
250
+ ? { sibling: before, side: 'before' }
251
+ : after !== undefined
252
+ ? { sibling: after, side: 'after' }
253
+ : undefined;
254
+ next = addContainment(next, id, command.node.id, plane, beside);
245
255
  }
246
256
  return { model: next, layout };
247
257
  }
@@ -249,6 +259,8 @@ function applyModelLayout(state, command) {
249
259
  return { model: renameNode(model, command.id, command.name), layout };
250
260
  case 'set-node-details':
251
261
  return { model: setNodeDetails(model, command.id, command.details), layout };
262
+ case 'set-plan-dates':
263
+ return { model: setPlanDates(model, command.id, command.dates), layout };
252
264
  case 'set-node-rich':
253
265
  return { model: setNodeRich(model, command.id, command.runs), layout };
254
266
  case 'set-table-columns':
@@ -259,6 +271,12 @@ function applyModelLayout(state, command) {
259
271
  return { model: updateThreat(model, command.target, command.id, command.patch), layout };
260
272
  case 'remove-threat':
261
273
  return { model: removeThreat(model, command.target, command.id), layout };
274
+ case 'add-comment':
275
+ return { model: addComment(model, command.target, command.comment), layout };
276
+ case 'update-comment':
277
+ return { model: updateComment(model, command.target, command.id, command.patch), layout };
278
+ case 'remove-comment':
279
+ return { model: removeComment(model, command.target, command.id), layout };
262
280
  case 'set-node-plane-hidden':
263
281
  return { model: setNodePlaneHidden(model, command.nodeId, command.plane, command.hidden), layout };
264
282
  case 'set-diagram-style':
@@ -0,0 +1,24 @@
1
+ import type { ThreatTarget } from './threat-model.js';
2
+ import type { Comment, DiagramModel } from './types.js';
3
+ /** Which element a comment command or a bubble refers to. The same two-way key
4
+ * threats use (`threatTargetKey` files both under `LayoutOverlay.notes`): an
5
+ * element has ONE bubble, whatever it holds. */
6
+ export type ElementTarget = ThreatTarget;
7
+ /** The element's comment list — `[]` when it carries none, undefined when there
8
+ * is no such element (the two are different answers; see threatsOf). */
9
+ export declare function commentsOf(m: DiagramModel, t: ElementTarget): readonly Comment[] | undefined;
10
+ /**
11
+ * Whether an element gets a note bubble: threats, comments or links. The
12
+ * renderer derives bubbles from this and layout hygiene keeps note entries by
13
+ * it — the two must never disagree, or an edit silently drops a bubble's saved
14
+ * place (which is what happened when they did). Structurally typed rather than
15
+ * taking `DiagramNode | DiagramRelation`, so a relation (which carries no
16
+ * `links`) answers the same question without a second predicate.
17
+ */
18
+ export declare function hasNoteContent(el: {
19
+ threats?: readonly unknown[];
20
+ comments?: readonly unknown[];
21
+ links?: readonly unknown[];
22
+ }): boolean;
23
+ /** First free `c<n>` — scoped to the element, like threat ids. */
24
+ export declare function nextCommentId(comments: readonly Comment[]): string;
@@ -0,0 +1,25 @@
1
+ /** The element's comment list — `[]` when it carries none, undefined when there
2
+ * is no such element (the two are different answers; see threatsOf). */
3
+ export function commentsOf(m, t) {
4
+ const el = 'node' in t ? m.nodes.find((n) => n.id === t.node) : m.relations.find((r) => r.id === t.relation);
5
+ return el === undefined ? undefined : (el.comments ?? []);
6
+ }
7
+ /**
8
+ * Whether an element gets a note bubble: threats, comments or links. The
9
+ * renderer derives bubbles from this and layout hygiene keeps note entries by
10
+ * it — the two must never disagree, or an edit silently drops a bubble's saved
11
+ * place (which is what happened when they did). Structurally typed rather than
12
+ * taking `DiagramNode | DiagramRelation`, so a relation (which carries no
13
+ * `links`) answers the same question without a second predicate.
14
+ */
15
+ export function hasNoteContent(el) {
16
+ return (el.threats?.length ?? 0) > 0 || (el.comments?.length ?? 0) > 0 || (el.links?.length ?? 0) > 0;
17
+ }
18
+ /** First free `c<n>` — scoped to the element, like threat ids. */
19
+ export function nextCommentId(comments) {
20
+ const taken = new Set(comments.map((c) => c.id));
21
+ let n = 1;
22
+ while (taken.has(`c${n}`))
23
+ n += 1;
24
+ return `c${n}`;
25
+ }
@@ -0,0 +1 @@
1
+ export declare function isIsoDate(s: unknown): s is string;
package/dist/dates.js ADDED
@@ -0,0 +1,12 @@
1
+ /** Calendar dates in the model are `YYYY-MM-DD` strings: readable in a diff,
2
+ * sortable as text, and free of the timezone a `Date` would smuggle in. Parsed
3
+ * as UTC so "2026-02-30" is caught by the round trip (Date.parse would roll it
4
+ * to March 2nd) rather than accepted. Shared by comments (`at`) today and by the
5
+ * plan notation's zones and events next. */
6
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
7
+ export function isIsoDate(s) {
8
+ if (typeof s !== 'string' || !ISO_DAY.test(s))
9
+ return false;
10
+ const t = Date.parse(`${s}T00:00:00Z`);
11
+ return !Number.isNaN(t) && new Date(t).toISOString().slice(0, 10) === s;
12
+ }
package/dist/eject.js CHANGED
@@ -85,7 +85,7 @@ function quoted(s) {
85
85
  const NODE_OPT_KEYS = [
86
86
  'type', 'name', 'icon', 'shape', 'image', 'color', 'textColor', 'technology',
87
87
  'link', 'description', 'rich', 'textAlign', 'fontScale', 'metadata', 'key', 'include',
88
- 'includePlane', 'includePlanes', 'plane', 'layer', 'columns', 'threats',
88
+ 'includePlane', 'includePlanes', 'plane', 'layer', 'columns', 'threats', 'comments', 'links',
89
89
  ];
90
90
  // Drift guard: a field added to DiagramNode without a matching entry above
91
91
  // fails this line at `pnpm typecheck` — a future model field silently
@@ -98,7 +98,7 @@ void _nodeOptCoverage;
98
98
  * — mirrors the interface declaration in builder.ts. */
99
99
  const RELATE_OPT_KEYS = [
100
100
  'label', 'labels', 'style', 'description', 'layer', 'polarity', 'delay',
101
- 'fromColumn', 'toColumn', 'threats',
101
+ 'fromColumn', 'toColumn', 'threats', 'comments',
102
102
  ];
103
103
  // Drift guard, same shape as _nodeOptCoverage above. `id` and `kind` are
104
104
  // emitted explicitly ahead of the opts object; `from`/`to` are the node refs
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@ export declare const CORE_VERSION = 1;
2
2
  export { ejectSource } from './eject.js';
3
3
  export * from './types.js';
4
4
  export * from './mutate.js';
5
+ export { commentsOf, hasNoteContent, nextCommentId, type ElementTarget } from './comments.js';
6
+ export { isIsoDate } from './dates.js';
5
7
  export { normalizeRuns, runsToPlainText } from './text.js';
6
8
  export { relationLabels } from './labels.js';
7
9
  export { defaultLayoutDirection, type LayoutDirection } from './layout-defaults.js';
8
- export { model, ModelBuilder, NodeRef, BranchRef, CommitRef, GitGraphBuilder, ActivityBuilder, ActivityScope, LaneRef, RegionRef, ConsequenceRef, SecondOrderBuilder, FishboneBuilder, CategoryRef, CauseRef, ThreatModelBuilder, FlowRef, type NodeOpts, type RelateOpts, type CommitOpts, type StageOpts, type MergeOpts, type ActivityElementOpts, type ConsequenceOpts, type FishboneOpts, type ThreatOpts, type ElementOpts, } from './builder.js';
10
+ export { model, ModelBuilder, NodeRef, BranchRef, CommitRef, GitGraphBuilder, ActivityBuilder, ActivityScope, LaneRef, RegionRef, ConsequenceRef, SecondOrderBuilder, FishboneBuilder, CategoryRef, CauseRef, ThreatModelBuilder, FlowRef, PlanBuilder, ZoneBuilder, type NodeOpts, type RelateOpts, type CommitOpts, type StageOpts, type MergeOpts, type ActivityElementOpts, type ConsequenceOpts, type FishboneOpts, type ThreatOpts, type CommentOpts, type ElementOpts, type ZoneOpts, type EventOpts, } from './builder.js';
9
11
  export { validate, DiagramValidationError, IMAGE_REF, LIBRARY_IMAGE_REF, type ValidationIssue } from './validate.js';
10
12
  export { isDrawings, isLayoutOverlay } from './guards.js';
11
13
  export { addStroke, deleteStroke, emptyDrawings, pruneDrawingsPlane, uniqueStrokeId } from './drawings.js';
@@ -23,3 +25,4 @@ export { GIT_KINDS, GIT_NOTATION, GIT_STAGE_TYPE, gapOf, gitGraph, isGitKind, la
23
25
  export { SECOND_ORDER_NOTATION, SO_CONSEQUENCE_TYPES, SO_DECISION_TYPE, SO_LEADS_TO_KIND, consequenceOrders, consequenceTypeOf, isSecondOrderNode, valenceOf, type ConsequenceOrders, type Valence, } from './second-order.js';
24
26
  export { FB_CATEGORY_TYPE, FB_CAUSE_OF_KIND, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_NOTATION, FISHBONE_PRESET_NAMES, FISHBONE_PRESETS, FISHBONE_TYPES, fishboneParents, fishboneTree, isFishboneNode, presetId, type FishboneCategory, type FishboneCause, type FishbonePreset, type FishboneTree, } from './fishbone.js';
25
27
  export { TM_NOTATION, TM_ENTITY_TYPE, TM_PROCESS_TYPE, TM_STORE_TYPE, TM_BOUNDARY_TYPE, TM_FLOW_KIND, TM_TYPES, STRIDE_NAMES, NEW_THREAT_TITLE, isThreatModelNode, isOpen, strideFor, boundaryOf, boundaryName, crossings, crossingLabel, threatRegister, threatSummary, threatTargetKey, threatsOf, nextThreatId, nextThreatStatus, allNotesOpen, type ThreatTarget, type Crossing, type ThreatRow, } from './threat-model.js';
28
+ export { PLAN_NOTATION, PLAN_ZONE_TYPE, PLAN_EVENT_TYPE, PLAN_PERSON_TYPE, PLAN_TEAM_TYPE, PLAN_ACTOR_TYPES, PLAN_TYPES, PLAN_ROLES, isPlanZone, isPlanEvent, isPlanActor, isPlanRole, dayOf, isoOf, spanOf, atOf, rolesOf, planGraph, planSubtree, type PlanRole, type PlanSpan, type PlanRoles, type PlanGraph, type PlanChildren, } from './plan.js';
package/dist/index.js CHANGED
@@ -23,10 +23,12 @@ export const CORE_VERSION = 1;
23
23
  export { ejectSource } from './eject.js';
24
24
  export * from './types.js';
25
25
  export * from './mutate.js';
26
+ export { commentsOf, hasNoteContent, nextCommentId } from './comments.js';
27
+ export { isIsoDate } from './dates.js';
26
28
  export { normalizeRuns, runsToPlainText } from './text.js';
27
29
  export { relationLabels } from './labels.js';
28
30
  export { defaultLayoutDirection } from './layout-defaults.js';
29
- export { model, ModelBuilder, NodeRef, BranchRef, CommitRef, GitGraphBuilder, ActivityBuilder, ActivityScope, LaneRef, RegionRef, ConsequenceRef, SecondOrderBuilder, FishboneBuilder, CategoryRef, CauseRef, ThreatModelBuilder, FlowRef, } from './builder.js';
31
+ export { model, ModelBuilder, NodeRef, BranchRef, CommitRef, GitGraphBuilder, ActivityBuilder, ActivityScope, LaneRef, RegionRef, ConsequenceRef, SecondOrderBuilder, FishboneBuilder, CategoryRef, CauseRef, ThreatModelBuilder, FlowRef, PlanBuilder, ZoneBuilder, } from './builder.js';
30
32
  export { validate, DiagramValidationError, IMAGE_REF, LIBRARY_IMAGE_REF } from './validate.js';
31
33
  export { isDrawings, isLayoutOverlay } from './guards.js';
32
34
  export { addStroke, deleteStroke, emptyDrawings, pruneDrawingsPlane, uniqueStrokeId } from './drawings.js';
@@ -43,3 +45,4 @@ export { GIT_KINDS, GIT_NOTATION, GIT_STAGE_TYPE, gapOf, gitGraph, isGitKind, la
43
45
  export { SECOND_ORDER_NOTATION, SO_CONSEQUENCE_TYPES, SO_DECISION_TYPE, SO_LEADS_TO_KIND, consequenceOrders, consequenceTypeOf, isSecondOrderNode, valenceOf, } from './second-order.js';
44
46
  export { FB_CATEGORY_TYPE, FB_CAUSE_OF_KIND, FB_CAUSE_TYPE, FB_EFFECT_TYPE, FISHBONE_NOTATION, FISHBONE_PRESET_NAMES, FISHBONE_PRESETS, FISHBONE_TYPES, fishboneParents, fishboneTree, isFishboneNode, presetId, } from './fishbone.js';
45
47
  export { TM_NOTATION, TM_ENTITY_TYPE, TM_PROCESS_TYPE, TM_STORE_TYPE, TM_BOUNDARY_TYPE, TM_FLOW_KIND, TM_TYPES, STRIDE_NAMES, NEW_THREAT_TITLE, isThreatModelNode, isOpen, strideFor, boundaryOf, boundaryName, crossings, crossingLabel, threatRegister, threatSummary, threatTargetKey, threatsOf, nextThreatId, nextThreatStatus, allNotesOpen, } from './threat-model.js';
48
+ export { PLAN_NOTATION, PLAN_ZONE_TYPE, PLAN_EVENT_TYPE, PLAN_PERSON_TYPE, PLAN_TEAM_TYPE, PLAN_ACTOR_TYPES, PLAN_TYPES, PLAN_ROLES, isPlanZone, isPlanEvent, isPlanActor, isPlanRole, dayOf, isoOf, spanOf, atOf, rolesOf, planGraph, planSubtree, } from './plan.js';