@diagc/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +709 -0
  2. package/README.md +27 -0
  3. package/dist/builder.d.ts +381 -0
  4. package/dist/builder.js +590 -0
  5. package/dist/children.d.ts +21 -0
  6. package/dist/children.js +45 -0
  7. package/dist/commands.d.ts +219 -0
  8. package/dist/commands.js +474 -0
  9. package/dist/compose.d.ts +19 -0
  10. package/dist/compose.js +246 -0
  11. package/dist/drawings.d.ts +13 -0
  12. package/dist/drawings.js +36 -0
  13. package/dist/eject.d.ts +20 -0
  14. package/dist/eject.js +260 -0
  15. package/dist/fishbone.d.ts +66 -0
  16. package/dist/fishbone.js +95 -0
  17. package/dist/git.d.ts +65 -0
  18. package/dist/git.js +159 -0
  19. package/dist/guards.d.ts +10 -0
  20. package/dist/guards.js +98 -0
  21. package/dist/index.d.ts +25 -0
  22. package/dist/index.js +45 -0
  23. package/dist/labels.d.ts +5 -0
  24. package/dist/labels.js +10 -0
  25. package/dist/layout-defaults.d.ts +20 -0
  26. package/dist/layout-defaults.js +20 -0
  27. package/dist/mutate.d.ts +106 -0
  28. package/dist/mutate.js +547 -0
  29. package/dist/second-order.d.ts +39 -0
  30. package/dist/second-order.js +86 -0
  31. package/dist/text.d.ts +5 -0
  32. package/dist/text.js +25 -0
  33. package/dist/threat-model.d.ts +88 -0
  34. package/dist/threat-model.js +188 -0
  35. package/dist/types.d.ts +395 -0
  36. package/dist/types.js +27 -0
  37. package/dist/util.d.ts +11 -0
  38. package/dist/util.js +13 -0
  39. package/dist/validate.d.ts +19 -0
  40. package/dist/validate.js +736 -0
  41. package/dist/view/compile.d.ts +29 -0
  42. package/dist/view/compile.js +78 -0
  43. package/dist/view/edges.d.ts +4 -0
  44. package/dist/view/edges.js +118 -0
  45. package/dist/view/hierarchy.d.ts +41 -0
  46. package/dist/view/hierarchy.js +103 -0
  47. package/dist/view/layers.d.ts +7 -0
  48. package/dist/view/layers.js +17 -0
  49. package/dist/view/lod.d.ts +15 -0
  50. package/dist/view/lod.js +17 -0
  51. package/dist/view/scope.d.ts +23 -0
  52. package/dist/view/scope.js +106 -0
  53. package/dist/view/size.d.ts +8 -0
  54. package/dist/view/size.js +34 -0
  55. package/dist/view/tree.d.ts +12 -0
  56. package/dist/view/tree.js +147 -0
  57. package/dist/view/types.d.ts +68 -0
  58. package/dist/view/types.js +1 -0
  59. package/package.json +38 -0
@@ -0,0 +1,86 @@
1
+ /** The notation id a plane (or the model) declares to be drawn as a consequence tree. */
2
+ export const SECOND_ORDER_NOTATION = 'second-order';
3
+ export const SO_DECISION_TYPE = 'so-decision';
4
+ /** Valence is a TYPE VARIANT, not a field: a registry and palette entry, no
5
+ * schema change — the same trick C4 uses for its `-external` stencils. */
6
+ export const SO_CONSEQUENCE_TYPES = ['so-consequence-positive', 'so-consequence-negative', 'so-consequence-neutral'];
7
+ /** The kind the builder and the studio create. The derivation below does NOT
8
+ * filter by it (see consequenceOrders). */
9
+ export const SO_LEADS_TO_KIND = 'leads-to';
10
+ const TYPE_OF = {
11
+ '+': 'so-consequence-positive',
12
+ '-': 'so-consequence-negative',
13
+ '0': 'so-consequence-neutral',
14
+ };
15
+ export const consequenceTypeOf = (v) => TYPE_OF[v];
16
+ export function valenceOf(type) {
17
+ return Object.keys(TYPE_OF).find((v) => TYPE_OF[v] === type);
18
+ }
19
+ export const isSecondOrderNode = (n) => n.type === SO_DECISION_TYPE || valenceOf(n.type) !== undefined;
20
+ /**
21
+ * Which band each decision and consequence belongs to. This is the ONE place
22
+ * that answers it, so the layout's partitions, the band overlay, the studio
23
+ * panel and validation cannot disagree.
24
+ *
25
+ * The order is the LONGEST path from a decision: every cause then sits in an
26
+ * earlier band than its effect, and no arrow ever runs inside a band — which is
27
+ * also exactly what lets elk take the orders as layer partitions.
28
+ *
29
+ * Reads the MODEL's relations, never drawn edges: toggling a layer must not
30
+ * move a box. Any kind of relation between two second-order nodes counts, so
31
+ * restyling an arrow can never drop a node out of its band. Never throws.
32
+ */
33
+ export function consequenceOrders(model) {
34
+ const nodes = model.nodes.filter(isSecondOrderNode);
35
+ const ids = new Set(nodes.map((n) => n.id));
36
+ const next = new Map(nodes.map((n) => [n.id, []]));
37
+ const prev = new Map(nodes.map((n) => [n.id, []]));
38
+ const seen = new Set();
39
+ for (const r of model.relations) {
40
+ if (r.from === r.to || !ids.has(r.from) || !ids.has(r.to))
41
+ continue;
42
+ const pair = `${r.from}\u0000${r.to}`;
43
+ if (seen.has(pair))
44
+ continue;
45
+ seen.add(pair);
46
+ next.get(r.from).push(r.to);
47
+ prev.get(r.to).push(r.from);
48
+ }
49
+ // Kahn: whatever is never released sits on a loop or behind one.
50
+ const waiting = new Map(nodes.map((n) => [n.id, prev.get(n.id).length]));
51
+ const topo = nodes.filter((n) => waiting.get(n.id) === 0).map((n) => n.id);
52
+ for (let i = 0; i < topo.length; i++) {
53
+ for (const to of next.get(topo[i])) {
54
+ const left = waiting.get(to) - 1;
55
+ waiting.set(to, left);
56
+ if (left === 0)
57
+ topo.push(to);
58
+ }
59
+ }
60
+ if (topo.length < nodes.length) {
61
+ // Peel what merely hangs OFF the loop (nothing stuck follows from it), so
62
+ // the report names the loop itself.
63
+ const released = new Set(topo);
64
+ const stuck = new Set(nodes.map((n) => n.id).filter((id) => !released.has(id)));
65
+ for (let peeled = true; peeled;) {
66
+ peeled = false;
67
+ for (const id of stuck) {
68
+ if (next.get(id).some((to) => stuck.has(to)))
69
+ continue;
70
+ stuck.delete(id);
71
+ peeled = true;
72
+ }
73
+ }
74
+ return { orders: new Map(), cycle: nodes.map((n) => n.id).filter((id) => stuck.has(id)), unreachable: [] };
75
+ }
76
+ const typeOf = new Map(nodes.map((n) => [n.id, n.type]));
77
+ const orders = new Map();
78
+ for (const id of topo) {
79
+ const causes = prev.get(id).map((p) => orders.get(p)).filter((o) => o !== undefined);
80
+ if (causes.length > 0)
81
+ orders.set(id, Math.max(...causes) + 1);
82
+ else if (typeOf.get(id) === SO_DECISION_TYPE)
83
+ orders.set(id, 0);
84
+ }
85
+ return { orders, unreachable: nodes.map((n) => n.id).filter((id) => !orders.has(id)) };
86
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { TextRun } from './types.js';
2
+ export declare function runsToPlainText(runs: TextRun[]): string;
3
+ /** Canonical form: drop empty runs, coerce falsey marks away, merge adjacent
4
+ * runs whose marks match. */
5
+ export declare function normalizeRuns(runs: TextRun[]): TextRun[];
package/dist/text.js ADDED
@@ -0,0 +1,25 @@
1
+ export function runsToPlainText(runs) {
2
+ return runs.map((r) => r.text).join('');
3
+ }
4
+ /** Canonical form: drop empty runs, coerce falsey marks away, merge adjacent
5
+ * runs whose marks match. */
6
+ export function normalizeRuns(runs) {
7
+ const out = [];
8
+ for (const r of runs) {
9
+ if (r.text === '')
10
+ continue;
11
+ const run = { text: r.text };
12
+ if (r.bold === true)
13
+ run.bold = true;
14
+ if (r.italic === true)
15
+ run.italic = true;
16
+ const prev = out[out.length - 1];
17
+ if (prev !== undefined && Boolean(prev.bold) === Boolean(run.bold) && Boolean(prev.italic) === Boolean(run.italic)) {
18
+ prev.text += run.text;
19
+ }
20
+ else {
21
+ out.push(run);
22
+ }
23
+ }
24
+ return out;
25
+ }
@@ -0,0 +1,88 @@
1
+ import { type DiagramModel, type DiagramNode, type LayoutOverlay, type StrideCategory, type Threat, type ThreatStatus } from './types.js';
2
+ /** The notation id a plane (or the model) declares to be drawn as a STRIDE
3
+ * data-flow diagram. */
4
+ export declare const TM_NOTATION: "threat-model";
5
+ export declare const TM_ENTITY_TYPE: "tm-entity";
6
+ export declare const TM_PROCESS_TYPE: "tm-process";
7
+ export declare const TM_STORE_TYPE: "tm-store";
8
+ /** A trust boundary is a CONTAINER, never a box with arrows: "this flow crosses
9
+ * a boundary" is then derived from containment (see crossings) instead of being
10
+ * authored, and cannot disagree with the drawing. */
11
+ export declare const TM_BOUNDARY_TYPE: "tm-boundary";
12
+ export declare const TM_FLOW_KIND: "data-flow";
13
+ export declare const TM_TYPES: ReadonlySet<string>;
14
+ export declare const isThreatModelNode: (n: DiagramNode) => boolean;
15
+ export declare const STRIDE_NAMES: Record<StrideCategory, string>;
16
+ /** The title a threat gets when it is added from the canvas, before anyone has
17
+ * typed one. It is a real title rather than `''` because a threat with an empty
18
+ * title fails validation (`threat-title`) — and the studio autosaves on a timer,
19
+ * so an empty one would reach the save handler as a 400 while the user is still
20
+ * typing. Lives in core so every host seeds the same word, and so "was this ever
21
+ * titled?" is one comparison rather than a per-host convention. */
22
+ export declare const NEW_THREAT_TITLE = "New threat";
23
+ /** which element a threat command or register row refers to */
24
+ export type ThreatTarget = {
25
+ node: string;
26
+ } | {
27
+ relation: string;
28
+ };
29
+ /** The overlay key a threat target is filed under (`LayoutOverlay.notes`). Node
30
+ * and relation ids are separate namespaces — nothing stops a node and a relation
31
+ * sharing an id — so one flat map needs the prefix. */
32
+ export declare const threatTargetKey: (t: ThreatTarget) => string;
33
+ /** The element's threat list — `[]` when it carries none, undefined when there
34
+ * is no such element. The two are different answers: a note is drawn for the
35
+ * first case's element and the second is a dangling reference. */
36
+ export declare function threatsOf(m: DiagramModel, t: ThreatTarget): readonly Threat[] | undefined;
37
+ /** First free `t<n>`. Ids are scoped to their element, so a removed `t2` is
38
+ * handed out again rather than the counter climbing forever. */
39
+ export declare function nextThreatId(threats: readonly Threat[]): string;
40
+ /** the boundaries a relation's ends sit in; an absent side is "outside every boundary" */
41
+ export interface Crossing {
42
+ from?: string;
43
+ to?: string;
44
+ }
45
+ export interface ThreatRow {
46
+ target: ThreatTarget;
47
+ /** the element's display name — a flow reads `from → to (label)` */
48
+ name: string;
49
+ threat: Threat;
50
+ }
51
+ export declare const isOpen: (t: Threat) => boolean;
52
+ export declare function strideFor(typeOrKind: string | undefined): readonly StrideCategory[];
53
+ /** The nearest trust boundary above `nodeId` on the viewed plane, or undefined
54
+ * outside every boundary. Containment is a DAG; the first parent by declaration
55
+ * order is the one followed — the same pick a reader makes from the drawing. */
56
+ export declare function boundaryOf(m: DiagramModel, plane: string | undefined, nodeId: string): string | undefined;
57
+ /** Every relation whose two ends sit in different boundaries — the flows STRIDE
58
+ * cares about. Derived from containment, not authored, and not filtered by kind:
59
+ * a boundary crossing is a fact about the drawing, whatever the arrow means. */
60
+ export declare function crossings(m: DiagramModel, plane: string | undefined): ReadonlyMap<string, Crossing>;
61
+ /** One end of a crossing, as a reader sees it: *outside* where the end sits in
62
+ * no boundary at all, otherwise the boundary node's name — or, for an id no node
63
+ * carries, the id itself, so a broken model still says which one it means. */
64
+ export declare function boundaryName(m: DiagramModel, id: string | undefined): string;
65
+ /** `DMZ ⇢ Backend`: a crossing read between the two BOUNDARIES its ends sit in.
66
+ * The other arrow is the whole point — a flow's own line reads `from → to` and
67
+ * names the elements, this one names the trust zones the flow leaves and enters.
68
+ * Formatted here rather than at each call site so the studio panel and the
69
+ * published table cannot word the same fact differently. */
70
+ export declare function crossingLabel(m: DiagramModel, crossing: Crossing): string;
71
+ /** The register: every threat in the model, nodes first then relations, each in
72
+ * declaration order — the one order the studio list and the published table share. */
73
+ export declare function threatRegister(m: DiagramModel): ThreatRow[];
74
+ export declare function threatSummary(threats: readonly Threat[] | undefined): {
75
+ open: number;
76
+ total: number;
77
+ };
78
+ /** the status a bubble's chip advances to on a click: the THREAT_STATUSES
79
+ * order, wrapping — an absent status is `open`, as isOpen reads it */
80
+ export declare function nextThreatStatus(t: Threat): ThreatStatus;
81
+ /**
82
+ * Whether every threat-bearing element in the model has an open bubble on
83
+ * `planeKey` — the studio's `Notes` chip reads pressed exactly then, so a
84
+ * press always does the thing the picture is missing (opens the rest, or
85
+ * closes all). Model-wide, like set-notes-open: the chip and the command must
86
+ * agree on what "all" means. False when nothing carries a threat.
87
+ */
88
+ export declare function allNotesOpen(model: DiagramModel, layout: LayoutOverlay, planeKey: string): boolean;
@@ -0,0 +1,188 @@
1
+ import { relationLabels } from './labels.js';
2
+ import { STRIDE, THREAT_STATUSES, } from './types.js';
3
+ import { containmentPlaneOf } from './view/hierarchy.js';
4
+ /** The notation id a plane (or the model) declares to be drawn as a STRIDE
5
+ * data-flow diagram. */
6
+ export const TM_NOTATION = 'threat-model';
7
+ export const TM_ENTITY_TYPE = 'tm-entity';
8
+ export const TM_PROCESS_TYPE = 'tm-process';
9
+ export const TM_STORE_TYPE = 'tm-store';
10
+ /** A trust boundary is a CONTAINER, never a box with arrows: "this flow crosses
11
+ * a boundary" is then derived from containment (see crossings) instead of being
12
+ * authored, and cannot disagree with the drawing. */
13
+ export const TM_BOUNDARY_TYPE = 'tm-boundary';
14
+ export const TM_FLOW_KIND = 'data-flow';
15
+ export const TM_TYPES = new Set([TM_ENTITY_TYPE, TM_PROCESS_TYPE, TM_STORE_TYPE, TM_BOUNDARY_TYPE]);
16
+ export const isThreatModelNode = (n) => n.type !== undefined && TM_TYPES.has(n.type);
17
+ export const STRIDE_NAMES = {
18
+ S: 'Spoofing',
19
+ T: 'Tampering',
20
+ R: 'Repudiation',
21
+ I: 'Information disclosure',
22
+ D: 'Denial of service',
23
+ E: 'Elevation of privilege',
24
+ };
25
+ /** The title a threat gets when it is added from the canvas, before anyone has
26
+ * typed one. It is a real title rather than `''` because a threat with an empty
27
+ * title fails validation (`threat-title`) — and the studio autosaves on a timer,
28
+ * so an empty one would reach the save handler as a 400 while the user is still
29
+ * typing. Lives in core so every host seeds the same word, and so "was this ever
30
+ * titled?" is one comparison rather than a per-host convention. */
31
+ export const NEW_THREAT_TITLE = 'New threat';
32
+ /** The overlay key a threat target is filed under (`LayoutOverlay.notes`). Node
33
+ * and relation ids are separate namespaces — nothing stops a node and a relation
34
+ * sharing an id — so one flat map needs the prefix. */
35
+ export const threatTargetKey = (t) => ('node' in t ? `node:${t.node}` : `relation:${t.relation}`);
36
+ /** The element's threat list — `[]` when it carries none, undefined when there
37
+ * is no such element. The two are different answers: a note is drawn for the
38
+ * first case's element and the second is a dangling reference. */
39
+ export function threatsOf(m, t) {
40
+ const el = 'node' in t ? m.nodes.find((n) => n.id === t.node) : m.relations.find((r) => r.id === t.relation);
41
+ return el === undefined ? undefined : (el.threats ?? []);
42
+ }
43
+ /** First free `t<n>`. Ids are scoped to their element, so a removed `t2` is
44
+ * handed out again rather than the counter climbing forever. */
45
+ export function nextThreatId(threats) {
46
+ const taken = new Set(threats.map((t) => t.id));
47
+ let n = 1;
48
+ while (taken.has(`t${n}`))
49
+ n += 1;
50
+ return `t${n}`;
51
+ }
52
+ export const isOpen = (t) => (t.status ?? 'open') === 'open';
53
+ /** STRIDE-per-element (the Microsoft table): which categories apply to a DFD
54
+ * element. A type outside the DFD vocabulary — a C4 container being
55
+ * threat-modelled on its own plane — gets every category offered. */
56
+ const STRIDE_FOR = {
57
+ [TM_ENTITY_TYPE]: ['S', 'R'],
58
+ [TM_PROCESS_TYPE]: STRIDE,
59
+ [TM_STORE_TYPE]: ['T', 'R', 'I', 'D'],
60
+ [TM_FLOW_KIND]: ['T', 'I', 'D'],
61
+ };
62
+ export function strideFor(typeOrKind) {
63
+ return (typeOrKind !== undefined ? STRIDE_FOR[typeOrKind] : undefined) ?? STRIDE;
64
+ }
65
+ /** child → parents on the plane a view of `plane` uses (containmentOf resolved;
66
+ * an untagged edge belongs to the first-declared plane), declaration order kept */
67
+ function planeParents(m, plane) {
68
+ const active = containmentPlaneOf(m, plane);
69
+ const defaultPlane = m.planes?.[0]?.id;
70
+ const parents = new Map();
71
+ for (const e of m.containment) {
72
+ if ((e.plane ?? defaultPlane) !== active)
73
+ continue;
74
+ parents.set(e.child, [...(parents.get(e.child) ?? []), e.parent]);
75
+ }
76
+ return parents;
77
+ }
78
+ function nearestBoundary(parents, typeOf, id) {
79
+ const seen = new Set();
80
+ let cur = parents.get(id)?.[0];
81
+ // `seen` is belt-and-braces: validation rejects containment cycles, but a
82
+ // derivation that runs every frame must not be able to spin on a bad model.
83
+ while (cur !== undefined && !seen.has(cur)) {
84
+ if (typeOf.get(cur) === TM_BOUNDARY_TYPE)
85
+ return cur;
86
+ seen.add(cur);
87
+ cur = parents.get(cur)?.[0];
88
+ }
89
+ return undefined;
90
+ }
91
+ /** The nearest trust boundary above `nodeId` on the viewed plane, or undefined
92
+ * outside every boundary. Containment is a DAG; the first parent by declaration
93
+ * order is the one followed — the same pick a reader makes from the drawing. */
94
+ export function boundaryOf(m, plane, nodeId) {
95
+ return nearestBoundary(planeParents(m, plane), new Map(m.nodes.map((n) => [n.id, n.type])), nodeId);
96
+ }
97
+ /** Every relation whose two ends sit in different boundaries — the flows STRIDE
98
+ * cares about. Derived from containment, not authored, and not filtered by kind:
99
+ * a boundary crossing is a fact about the drawing, whatever the arrow means. */
100
+ export function crossings(m, plane) {
101
+ const parents = planeParents(m, plane);
102
+ const typeOf = new Map(m.nodes.map((n) => [n.id, n.type]));
103
+ const cache = new Map();
104
+ const bOf = (id) => {
105
+ if (!cache.has(id))
106
+ cache.set(id, nearestBoundary(parents, typeOf, id));
107
+ return cache.get(id);
108
+ };
109
+ const out = new Map();
110
+ for (const r of m.relations) {
111
+ const from = bOf(r.from);
112
+ const to = bOf(r.to);
113
+ if (from === to)
114
+ continue;
115
+ // an absent side means "outside every boundary" — omitted rather than
116
+ // `undefined` so a serialized Crossing stays minimal
117
+ out.set(r.id, { ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}) });
118
+ }
119
+ return out;
120
+ }
121
+ /** One end of a crossing, as a reader sees it: *outside* where the end sits in
122
+ * no boundary at all, otherwise the boundary node's name — or, for an id no node
123
+ * carries, the id itself, so a broken model still says which one it means. */
124
+ export function boundaryName(m, id) {
125
+ return id === undefined ? 'outside' : (m.nodes.find((n) => n.id === id)?.name ?? id);
126
+ }
127
+ /** `DMZ ⇢ Backend`: a crossing read between the two BOUNDARIES its ends sit in.
128
+ * The other arrow is the whole point — a flow's own line reads `from → to` and
129
+ * names the elements, this one names the trust zones the flow leaves and enters.
130
+ * Formatted here rather than at each call site so the studio panel and the
131
+ * published table cannot word the same fact differently. */
132
+ export function crossingLabel(m, crossing) {
133
+ return `${boundaryName(m, crossing.from)} ⇢ ${boundaryName(m, crossing.to)}`;
134
+ }
135
+ /** The register: every threat in the model, nodes first then relations, each in
136
+ * declaration order — the one order the studio list and the published table share. */
137
+ export function threatRegister(m) {
138
+ const rows = [];
139
+ for (const n of m.nodes)
140
+ for (const threat of n.threats ?? [])
141
+ rows.push({ target: { node: n.id }, name: n.name, threat });
142
+ const nameOf = (id) => m.nodes.find((n) => n.id === id)?.name ?? id;
143
+ for (const r of m.relations) {
144
+ if (r.threats === undefined || r.threats.length === 0)
145
+ continue;
146
+ const label = relationLabels(r)[0]?.text;
147
+ const name = `${nameOf(r.from)} → ${nameOf(r.to)}${label !== undefined && label !== '' ? ` (${label})` : ''}`;
148
+ for (const threat of r.threats)
149
+ rows.push({ target: { relation: r.id }, name, threat });
150
+ }
151
+ return rows;
152
+ }
153
+ export function threatSummary(threats) {
154
+ const list = threats ?? [];
155
+ return { open: list.filter(isOpen).length, total: list.length };
156
+ }
157
+ /** the status a bubble's chip advances to on a click: the THREAT_STATUSES
158
+ * order, wrapping — an absent status is `open`, as isOpen reads it */
159
+ export function nextThreatStatus(t) {
160
+ const i = THREAT_STATUSES.indexOf(t.status ?? 'open');
161
+ return THREAT_STATUSES[(i + 1) % THREAT_STATUSES.length] ?? 'open';
162
+ }
163
+ /**
164
+ * Whether every threat-bearing element in the model has an open bubble on
165
+ * `planeKey` — the studio's `Notes` chip reads pressed exactly then, so a
166
+ * press always does the thing the picture is missing (opens the rest, or
167
+ * closes all). Model-wide, like set-notes-open: the chip and the command must
168
+ * agree on what "all" means. False when nothing carries a threat.
169
+ */
170
+ export function allNotesOpen(model, layout, planeKey) {
171
+ const bucket = layout.notes?.[planeKey];
172
+ let any = false;
173
+ for (const n of model.nodes) {
174
+ if ((n.threats?.length ?? 0) === 0)
175
+ continue;
176
+ any = true;
177
+ if (bucket?.[threatTargetKey({ node: n.id })]?.open !== true)
178
+ return false;
179
+ }
180
+ for (const r of model.relations) {
181
+ if ((r.threats?.length ?? 0) === 0)
182
+ continue;
183
+ any = true;
184
+ if (bucket?.[threatTargetKey({ relation: r.id })]?.open !== true)
185
+ return false;
186
+ }
187
+ return any;
188
+ }