@transitrix/diagrams 1.4.0 → 1.6.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 (90) hide show
  1. package/dist/activities/__tests__/gantt.test.js +39 -0
  2. package/dist/capability-map/CapabilityCardNode.d.ts +19 -0
  3. package/dist/capability-map/CapabilityCardNode.js +66 -0
  4. package/dist/capability-map/CapabilityMapView.d.ts +59 -0
  5. package/dist/capability-map/CapabilityMapView.js +204 -0
  6. package/dist/capability-map/CapabilityRootNode.d.ts +12 -0
  7. package/dist/capability-map/CapabilityRootNode.js +23 -0
  8. package/dist/capability-map/__tests__/CapabilityMapView.test.d.ts +1 -0
  9. package/dist/capability-map/__tests__/CapabilityMapView.test.js +217 -0
  10. package/dist/capability-map/__tests__/address.test.d.ts +1 -0
  11. package/dist/capability-map/__tests__/address.test.js +49 -0
  12. package/dist/capability-map/__tests__/dsm-layout.test.d.ts +1 -0
  13. package/dist/capability-map/__tests__/dsm-layout.test.js +46 -0
  14. package/dist/capability-map/__tests__/dsm-mutations.test.d.ts +1 -0
  15. package/dist/capability-map/__tests__/dsm-mutations.test.js +125 -0
  16. package/dist/capability-map/__tests__/dsm-validate.test.d.ts +1 -0
  17. package/dist/capability-map/__tests__/dsm-validate.test.js +56 -0
  18. package/dist/capability-map/address.d.ts +12 -0
  19. package/dist/capability-map/address.js +48 -0
  20. package/dist/capability-map/dsm-layout.d.ts +9 -0
  21. package/dist/capability-map/dsm-layout.js +125 -0
  22. package/dist/capability-map/dsm-mutations.d.ts +28 -0
  23. package/dist/capability-map/dsm-mutations.js +196 -0
  24. package/dist/capability-map/dsm-schema.d.ts +71 -0
  25. package/dist/capability-map/dsm-schema.js +14 -0
  26. package/dist/capability-map/dsm-theme.d.ts +20 -0
  27. package/dist/capability-map/dsm-theme.js +36 -0
  28. package/dist/capability-map/dsm-validate.d.ts +2 -0
  29. package/dist/capability-map/dsm-validate.js +93 -0
  30. package/dist/capability-map/index.d.ts +9 -0
  31. package/dist/capability-map/index.js +6 -0
  32. package/dist/compliance/__tests__/trace.test.d.ts +1 -0
  33. package/dist/compliance/__tests__/trace.test.js +144 -0
  34. package/dist/compliance/classify.js +7 -1
  35. package/dist/compliance/index.d.ts +2 -1
  36. package/dist/compliance/index.js +1 -0
  37. package/dist/compliance/reverse-index.js +4 -1
  38. package/dist/compliance/trace.d.ts +26 -0
  39. package/dist/compliance/trace.js +89 -0
  40. package/dist/compliance/types.d.ts +94 -1
  41. package/dist/goals/GoalNode.d.ts +18 -0
  42. package/dist/goals/GoalNode.js +91 -0
  43. package/dist/goals/GoalTreeView.d.ts +57 -0
  44. package/dist/goals/GoalTreeView.js +228 -0
  45. package/dist/goals/__tests__/GoalTreeView.test.d.ts +1 -0
  46. package/dist/goals/__tests__/GoalTreeView.test.js +296 -0
  47. package/dist/goals/index.d.ts +3 -0
  48. package/dist/goals/index.js +1 -0
  49. package/dist/goals/theme.d.ts +22 -0
  50. package/dist/goals/theme.js +32 -0
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.js +1 -1
  53. package/dist/webview/__tests__/render-blocks.test.js +48 -0
  54. package/dist/webview/notation-style.d.ts +11 -0
  55. package/dist/webview/notation-style.js +11 -0
  56. package/dist/webview/render-blocks.js +8 -4
  57. package/dist/webview/render-goals.js +2 -1
  58. package/package.json +4 -1
  59. package/src/activities/__tests__/gantt.test.ts +45 -0
  60. package/src/capability-map/CapabilityCardNode.tsx +146 -0
  61. package/src/capability-map/CapabilityMapView.tsx +331 -0
  62. package/src/capability-map/CapabilityRootNode.tsx +38 -0
  63. package/src/capability-map/__tests__/CapabilityMapView.test.tsx +232 -0
  64. package/src/capability-map/__tests__/address.test.ts +59 -0
  65. package/src/capability-map/__tests__/dsm-layout.test.ts +52 -0
  66. package/src/capability-map/__tests__/dsm-mutations.test.ts +141 -0
  67. package/src/capability-map/__tests__/dsm-validate.test.ts +65 -0
  68. package/src/capability-map/address.ts +48 -0
  69. package/src/capability-map/dsm-layout.ts +142 -0
  70. package/src/capability-map/dsm-mutations.ts +208 -0
  71. package/src/capability-map/dsm-schema.ts +80 -0
  72. package/src/capability-map/dsm-theme.ts +53 -0
  73. package/src/capability-map/dsm-validate.ts +103 -0
  74. package/src/capability-map/index.ts +35 -0
  75. package/src/compliance/__tests__/trace.test.ts +157 -0
  76. package/src/compliance/classify.ts +7 -1
  77. package/src/compliance/index.ts +6 -0
  78. package/src/compliance/reverse-index.ts +3 -1
  79. package/src/compliance/trace.ts +118 -0
  80. package/src/compliance/types.ts +101 -1
  81. package/src/goals/GoalNode.tsx +212 -0
  82. package/src/goals/GoalTreeView.tsx +350 -0
  83. package/src/goals/__tests__/GoalTreeView.test.tsx +313 -0
  84. package/src/goals/index.ts +4 -0
  85. package/src/goals/theme.ts +54 -0
  86. package/src/index.ts +29 -1
  87. package/src/webview/__tests__/render-blocks.test.ts +54 -0
  88. package/src/webview/notation-style.ts +12 -0
  89. package/src/webview/render-blocks.ts +8 -4
  90. package/src/webview/render-goals.ts +2 -1
@@ -0,0 +1,196 @@
1
+ import { parseAddress, getLevel, getParentAddress, getFirstFreeAddress, isAddressTaken } from './address.js';
2
+ function cloneMap(map) {
3
+ return {
4
+ organisation: map.organisation,
5
+ set_id: map.set_id,
6
+ capabilities: map.capabilities.map((c) => ({
7
+ ...c,
8
+ maturity: c.maturity ? c.maturity.map((m) => ({ ...m })) : undefined,
9
+ })),
10
+ };
11
+ }
12
+ function nextId(map) {
13
+ return Math.max(0, ...map.capabilities.map((c) => c.id)) + 1;
14
+ }
15
+ function refusalError(message) {
16
+ const err = { code: 'MUTATION_REFUSED', message };
17
+ return { ok: false, error: err };
18
+ }
19
+ /** True when `addr` sits anywhere under `ancestorAddr` in the address hierarchy. */
20
+ function isDescendantAddress(addr, ancestorAddr) {
21
+ const [ax, ay, az] = parseAddress(addr);
22
+ const [px, py, pz] = parseAddress(ancestorAddr);
23
+ if (px !== ax)
24
+ return false;
25
+ if (py === 0)
26
+ return ay !== 0 || az !== 0; // ancestor is L1 — any L2/L3 under the same X
27
+ if (pz === 0)
28
+ return ay === py && az !== 0; // ancestor is L2 — only L3s under the same X.Y
29
+ return false; // ancestor is L3 — no descendants possible (max depth)
30
+ }
31
+ /** First unused top-level (L1) address — the "parent" for a promote-to-L1 reparent. */
32
+ function firstFreeL1(capabilities) {
33
+ for (let x = 1; x < 1000; x++) {
34
+ const candidate = `${x}.0.0`;
35
+ if (!isAddressTaken(candidate, capabilities))
36
+ return candidate;
37
+ }
38
+ throw new Error('firstFreeL1: no free top-level address (searched 1..999)');
39
+ }
40
+ /**
41
+ * Move `sourceId` under `targetId` (or under the virtual root when
42
+ * `targetId` is 0 — the sentinel matching the goals module's `parent_id: 0`
43
+ * convention, since the root isn't itself a Capability with an id).
44
+ *
45
+ * Recomputes addresses for the whole moved branch (§5.1). The depth check is
46
+ * expressed once, generically, as "every descendant's new level must stay in
47
+ * 1..3" — this covers both directions the spec discusses explicitly (L2→L1
48
+ * promotion, where descendants move one level shallower) and the symmetric,
49
+ * unspecified case (L1→L2 demotion, where descendants move one level
50
+ * deeper) without needing separate per-direction rules: an L1 with L3
51
+ * grandchildren refuses demotion to L2 because that would need a 4th level,
52
+ * caught by the same check.
53
+ */
54
+ export function reparent(map, sourceId, targetId) {
55
+ const clone = cloneMap(map);
56
+ const source = clone.capabilities.find((c) => c.id === sourceId);
57
+ if (!source || source.backlog)
58
+ return refusalError(`Capability ${sourceId} not found (use restoreFromBacklog for backlog items)`);
59
+ if (sourceId === targetId)
60
+ return refusalError('Cannot reparent to self');
61
+ const target = targetId === 0 ? null : clone.capabilities.find((c) => c.id === targetId);
62
+ if (targetId !== 0 && (!target || target.backlog))
63
+ return refusalError(`Target capability ${targetId} not found`);
64
+ if (target && isDescendantAddress(target.address, source.address)) {
65
+ return refusalError('Cannot reparent: target is a descendant of the source (would create a cycle)');
66
+ }
67
+ const sourceLevel = getLevel(source.address);
68
+ if (sourceLevel === 'backlog')
69
+ return refusalError(`Capability ${sourceId} has no on-diagram address`);
70
+ const targetLevel = target ? getLevel(target.address) : 0;
71
+ const newLevel = (typeof targetLevel === 'number' ? targetLevel : 0) + 1;
72
+ if (newLevel > 3)
73
+ return refusalError('Reparent would exceed max depth (L3)');
74
+ const levelDelta = newLevel - sourceLevel;
75
+ const originalSourceAddress = source.address;
76
+ // Snapshot descendants + their original address/level BEFORE anything is
77
+ // reassigned — both for the depth check and to resolve old-parent links
78
+ // while walking the branch top-down below.
79
+ const descendants = clone.capabilities
80
+ .filter((c) => !c.backlog && c.id !== sourceId && isDescendantAddress(c.address, originalSourceAddress))
81
+ .map((c) => ({ cap: c, originalAddress: c.address, originalLevel: getLevel(c.address) }));
82
+ for (const d of descendants) {
83
+ const newDescLevel = d.originalLevel + levelDelta;
84
+ if (newDescLevel > 3 || newDescLevel < 1) {
85
+ return refusalError(`Reparent would push descendant ${d.cap.id} to level ${newDescLevel}, outside 1..3`);
86
+ }
87
+ }
88
+ const oldAddressToId = new Map(clone.capabilities.filter((c) => !c.backlog).map((c) => [c.address, c.id]));
89
+ const newAddressById = new Map();
90
+ source.address = target ? getFirstFreeAddress(target.address, clone.capabilities) : firstFreeL1(clone.capabilities);
91
+ newAddressById.set(sourceId, source.address);
92
+ // Shallowest-first so each descendant's old parent has already been
93
+ // re-addressed (or is the source itself) by the time we reach it.
94
+ descendants.sort((a, b) => a.originalLevel - b.originalLevel);
95
+ for (const d of descendants) {
96
+ const oldParentAddress = getParentAddress(d.originalAddress);
97
+ const oldParentId = oldParentAddress ? oldAddressToId.get(oldParentAddress) : undefined;
98
+ const newParentAddress = oldParentId != null ? newAddressById.get(oldParentId) : undefined;
99
+ if (!newParentAddress) {
100
+ return refusalError(`Reparent could not resolve the new parent address for descendant ${d.cap.id}`);
101
+ }
102
+ const newAddress = getFirstFreeAddress(newParentAddress, clone.capabilities);
103
+ d.cap.address = newAddress;
104
+ newAddressById.set(d.cap.id, newAddress);
105
+ }
106
+ return { ok: true, result: clone };
107
+ }
108
+ export function addChild(map, parentId, newCap) {
109
+ const clone = cloneMap(map);
110
+ const parent = parentId === 0 ? null : clone.capabilities.find((c) => c.id === parentId);
111
+ if (parentId !== 0 && (!parent || parent.backlog))
112
+ return refusalError(`Parent capability ${parentId} not found`);
113
+ const parentLevel = parent ? getLevel(parent.address) : 0;
114
+ const newLevel = (typeof parentLevel === 'number' ? parentLevel : 0) + 1;
115
+ if (newLevel > 3)
116
+ return refusalError('New child would exceed max depth (L3)');
117
+ const address = parent ? getFirstFreeAddress(parent.address, clone.capabilities) : firstFreeL1(clone.capabilities);
118
+ const id = nextId(clone);
119
+ clone.capabilities.push({ ...newCap, id, address, backlog: false });
120
+ return { ok: true, result: clone };
121
+ }
122
+ export function deleteWithDescendants(map, id) {
123
+ const clone = cloneMap(map);
124
+ const target = clone.capabilities.find((c) => c.id === id);
125
+ if (!target)
126
+ return refusalError(`Capability ${id} not found`);
127
+ const toDelete = new Set([id]);
128
+ if (!target.backlog) {
129
+ for (const c of clone.capabilities) {
130
+ if (!c.backlog && isDescendantAddress(c.address, target.address))
131
+ toDelete.add(c.id);
132
+ }
133
+ }
134
+ clone.capabilities = clone.capabilities.filter((c) => !toDelete.has(c.id));
135
+ return { ok: true, result: clone };
136
+ }
137
+ /** Moves a whole branch to the backlog. Descendants move with it (a
138
+ * backlogged capability's on-diagram children would otherwise dangle). */
139
+ export function moveBranchToBacklog(map, id) {
140
+ const clone = cloneMap(map);
141
+ const target = clone.capabilities.find((c) => c.id === id);
142
+ if (!target)
143
+ return refusalError(`Capability ${id} not found`);
144
+ if (target.backlog)
145
+ return refusalError(`Capability ${id} is already in the backlog`);
146
+ const branch = [target, ...clone.capabilities.filter((c) => !c.backlog && isDescendantAddress(c.address, target.address))];
147
+ for (const c of branch) {
148
+ c.backlog = true;
149
+ c.address = '0.0.0';
150
+ }
151
+ return { ok: true, result: clone };
152
+ }
153
+ export function restoreFromBacklog(map, id, parentId) {
154
+ const clone = cloneMap(map);
155
+ const target = clone.capabilities.find((c) => c.id === id);
156
+ if (!target || !target.backlog)
157
+ return refusalError(`Capability ${id} is not in the backlog`);
158
+ const parent = parentId === 0 ? null : clone.capabilities.find((c) => c.id === parentId);
159
+ if (parentId !== 0 && (!parent || parent.backlog))
160
+ return refusalError(`Parent capability ${parentId} not found`);
161
+ const parentLevel = parent ? getLevel(parent.address) : 0;
162
+ const newLevel = (typeof parentLevel === 'number' ? parentLevel : 0) + 1;
163
+ if (newLevel > 3)
164
+ return refusalError('Restore would exceed max depth (L3)');
165
+ target.address = parent ? getFirstFreeAddress(parent.address, clone.capabilities) : firstFreeL1(clone.capabilities);
166
+ target.backlog = false;
167
+ return { ok: true, result: clone };
168
+ }
169
+ /**
170
+ * Repairs addresses that don't resolve to an existing parent (MISSING_PARENT_BY_ADDRESS):
171
+ * reassigns each such capability to the first free slot under its would-be
172
+ * parent's level, or moves it to the backlog when no such parent exists at all.
173
+ */
174
+ export function normaliseAddresses(map) {
175
+ const clone = cloneMap(map);
176
+ const addressToId = new Map(clone.capabilities.filter((c) => !c.backlog).map((c) => [c.address, c.id]));
177
+ // Re-derive level-by-level (L1 first) so an L1 fixed up in this pass can
178
+ // already serve as a valid parent for an L2 processed right after it.
179
+ const byLevel = clone.capabilities
180
+ .filter((c) => !c.backlog)
181
+ .map((c) => ({ cap: c, level: getLevel(c.address) }))
182
+ .filter((e) => typeof e.level === 'number')
183
+ .sort((a, b) => a.level - b.level);
184
+ for (const { cap, level } of byLevel) {
185
+ if (level === 1)
186
+ continue; // L1 has no parent to validate against
187
+ const parentAddress = getParentAddress(cap.address);
188
+ if (parentAddress && addressToId.has(parentAddress))
189
+ continue; // already fine
190
+ // No resolvable parent: reassign to the backlog rather than guessing one.
191
+ addressToId.delete(cap.address);
192
+ cap.backlog = true;
193
+ cap.address = '0.0.0';
194
+ }
195
+ return clone;
196
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Types for the DSM-migration capability map (docs/extraction/capability-map.md).
3
+ *
4
+ * Distinct from `./types.ts` (`CapabilityNode`/`CapabilityMapFile`), which is
5
+ * Transitrix's own native `*.capability-map.transitrix.yaml` notation — a
6
+ * nested-children tree, statically rendered to SVG. This module is a
7
+ * separate, flat, address-triple data model matching DSM's internal
8
+ * capability editor, for the interactive `CapabilityMapView` component. The
9
+ * two coexist in this folder by design (see the address-helper/component
10
+ * deliverables in the extraction spec); only the validator function name
11
+ * collided, so it's `validateCapabilityMapData` here, not
12
+ * `validateCapabilityMap` (already taken by `./validate.ts`).
13
+ */
14
+ export interface MaturitySnapshot {
15
+ date: string;
16
+ level: number;
17
+ }
18
+ export interface Capability {
19
+ id: number;
20
+ name: string;
21
+ /** 'X.Y.Z' triple. X=0 or backlog=true means "not on the diagram". */
22
+ address: string;
23
+ backlog?: boolean;
24
+ description?: string;
25
+ maturity?: MaturitySnapshot[];
26
+ }
27
+ export interface CapabilityMap {
28
+ organisation: string;
29
+ set_id: string;
30
+ capabilities: Capability[];
31
+ }
32
+ export type { ValidationError, ValidationWarning, ValidationResult, } from '../validation-types.js';
33
+ import type { ValidationError } from '../validation-types.js';
34
+ export interface MutationResult<T> {
35
+ ok: boolean;
36
+ result?: T;
37
+ error?: ValidationError;
38
+ }
39
+ export interface LayoutOptions {
40
+ rankdir?: 'LR' | 'TB';
41
+ nodeWidth?: number;
42
+ nodeHeight?: number;
43
+ rankSep?: number;
44
+ nodeSep?: number;
45
+ hideCollapsed?: number[];
46
+ organisationLabel?: string;
47
+ }
48
+ export interface LaidOutNode {
49
+ id: number;
50
+ x: number;
51
+ y: number;
52
+ width: number;
53
+ height: number;
54
+ data: Capability | null;
55
+ hasHiddenChildren: boolean;
56
+ }
57
+ export interface LaidOutEdge {
58
+ source: number | 'root';
59
+ target: number;
60
+ }
61
+ export interface CapabilityMapLayout {
62
+ rootNode: LaidOutNode;
63
+ nodes: LaidOutNode[];
64
+ edges: LaidOutEdge[];
65
+ bounds: {
66
+ x: number;
67
+ y: number;
68
+ width: number;
69
+ height: number;
70
+ };
71
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Types for the DSM-migration capability map (docs/extraction/capability-map.md).
3
+ *
4
+ * Distinct from `./types.ts` (`CapabilityNode`/`CapabilityMapFile`), which is
5
+ * Transitrix's own native `*.capability-map.transitrix.yaml` notation — a
6
+ * nested-children tree, statically rendered to SVG. This module is a
7
+ * separate, flat, address-triple data model matching DSM's internal
8
+ * capability editor, for the interactive `CapabilityMapView` component. The
9
+ * two coexist in this folder by design (see the address-helper/component
10
+ * deliverables in the extraction spec); only the validator function name
11
+ * collided, so it's `validateCapabilityMapData` here, not
12
+ * `validateCapabilityMap` (already taken by `./validate.ts`).
13
+ */
14
+ export {};
@@ -0,0 +1,20 @@
1
+ /** Theme tokens for CapabilityMapView — same host-agnostic-prop pattern as
2
+ * the goals module's ThemeTokens (see ../goals/theme.ts). */
3
+ export interface ThemeTokens {
4
+ cardFill?: string;
5
+ cardBorderColor?: string;
6
+ cardBorderWidth?: number;
7
+ cardTextColor?: string;
8
+ cardMetaColor?: string;
9
+ edgeColor?: string;
10
+ edgeWidth?: number;
11
+ dropTargetBorderColor?: string;
12
+ rootFill?: string;
13
+ rootTextColor?: string;
14
+ }
15
+ /** Default 1..5 maturity-dot colour scale — overridable via the
16
+ * `maturityColours` prop (spec §6.2), which is separate from ThemeTokens
17
+ * since it's keyed by maturity level, not a fixed named token. */
18
+ export declare const DEFAULT_MATURITY_COLOURS: Record<number, string>;
19
+ export declare const DEFAULT_THEME: Required<ThemeTokens>;
20
+ export declare function resolveTheme(theme?: ThemeTokens): Required<ThemeTokens>;
@@ -0,0 +1,36 @@
1
+ /** Default 1..5 maturity-dot colour scale — overridable via the
2
+ * `maturityColours` prop (spec §6.2), which is separate from ThemeTokens
3
+ * since it's keyed by maturity level, not a fixed named token. */
4
+ export const DEFAULT_MATURITY_COLOURS = {
5
+ 1: '#ef4444',
6
+ 2: '#f97316',
7
+ 3: '#eab308',
8
+ 4: '#84cc16',
9
+ 5: '#22c55e',
10
+ };
11
+ export const DEFAULT_THEME = {
12
+ cardFill: '#eff6ff',
13
+ cardBorderColor: '#94a3b8',
14
+ cardBorderWidth: 1,
15
+ cardTextColor: '#0f172a',
16
+ cardMetaColor: '#64748b',
17
+ edgeColor: '#94a3b8',
18
+ edgeWidth: 1.5,
19
+ dropTargetBorderColor: '#2563eb',
20
+ rootFill: '#e2e8f0',
21
+ rootTextColor: '#0f172a',
22
+ };
23
+ export function resolveTheme(theme) {
24
+ return {
25
+ cardFill: theme?.cardFill ?? DEFAULT_THEME.cardFill,
26
+ cardBorderColor: theme?.cardBorderColor ?? DEFAULT_THEME.cardBorderColor,
27
+ cardBorderWidth: theme?.cardBorderWidth ?? DEFAULT_THEME.cardBorderWidth,
28
+ cardTextColor: theme?.cardTextColor ?? DEFAULT_THEME.cardTextColor,
29
+ cardMetaColor: theme?.cardMetaColor ?? DEFAULT_THEME.cardMetaColor,
30
+ edgeColor: theme?.edgeColor ?? DEFAULT_THEME.edgeColor,
31
+ edgeWidth: theme?.edgeWidth ?? DEFAULT_THEME.edgeWidth,
32
+ dropTargetBorderColor: theme?.dropTargetBorderColor ?? DEFAULT_THEME.dropTargetBorderColor,
33
+ rootFill: theme?.rootFill ?? DEFAULT_THEME.rootFill,
34
+ rootTextColor: theme?.rootTextColor ?? DEFAULT_THEME.rootTextColor,
35
+ };
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { ValidationResult } from './dsm-schema.js';
2
+ export declare function validateCapabilityMapData(input: unknown): ValidationResult;
@@ -0,0 +1,93 @@
1
+ /** Parses an address string loosely enough to tell "wrong shape"
2
+ * (INVALID_ADDRESS_FORMAT) apart from "right shape, too many levels"
3
+ * (MAX_DEPTH_EXCEEDED, e.g. '1.2.3.4') — a strict 3-segment regex would
4
+ * reject both identically and lose that distinction. */
5
+ function parseLoose(address) {
6
+ const parts = address.trim().split('.');
7
+ if (parts.length < 3 || !parts.every((p) => /^\d+$/.test(p)))
8
+ return null;
9
+ if (parts.length > 3)
10
+ return { tooDeep: true };
11
+ return { triple: [Number(parts[0]), Number(parts[1]), Number(parts[2])] };
12
+ }
13
+ export function validateCapabilityMapData(input) {
14
+ const errors = [];
15
+ const warnings = [];
16
+ if (!input || typeof input !== 'object') {
17
+ return { valid: false, errors: [{ code: 'SCHEMA_INVALID', message: 'Input must be an object' }], warnings: [] };
18
+ }
19
+ const raw = input;
20
+ if (!Array.isArray(raw.capabilities)) {
21
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capabilities must be an array', path: 'capabilities' });
22
+ return { valid: false, errors, warnings };
23
+ }
24
+ const map = raw;
25
+ const idSet = new Set();
26
+ const addressToId = new Map();
27
+ const onDiagram = new Map();
28
+ for (let i = 0; i < map.capabilities.length; i++) {
29
+ const c = map.capabilities[i];
30
+ const path = `capabilities[${i}]`;
31
+ if (!c || typeof c !== 'object') {
32
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capability entry must be an object', path });
33
+ continue;
34
+ }
35
+ const cap = c;
36
+ if (typeof cap.id !== 'number') {
37
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capability id must be a number', path });
38
+ continue;
39
+ }
40
+ if (idSet.has(cap.id)) {
41
+ errors.push({ code: 'DUPLICATE_ID', message: `Duplicate capability id: ${cap.id}`, path });
42
+ }
43
+ else {
44
+ idSet.add(cap.id);
45
+ }
46
+ if (!cap.name || typeof cap.name !== 'string' || cap.name.trim() === '') {
47
+ errors.push({ code: 'EMPTY_NAME', message: `Capability ${cap.id} has empty name`, path });
48
+ }
49
+ if (typeof cap.address !== 'string') {
50
+ errors.push({ code: 'INVALID_ADDRESS_FORMAT', message: `Capability ${cap.id} address must be a string`, path });
51
+ continue;
52
+ }
53
+ const parsed = parseLoose(cap.address);
54
+ if (parsed === null) {
55
+ errors.push({ code: 'INVALID_ADDRESS_FORMAT', message: `Capability ${cap.id} address "${cap.address}" is not in 'X.Y.Z' form`, path });
56
+ continue;
57
+ }
58
+ if ('tooDeep' in parsed) {
59
+ errors.push({ code: 'MAX_DEPTH_EXCEEDED', message: `Capability ${cap.id} address "${cap.address}" has more than 3 levels`, path });
60
+ continue;
61
+ }
62
+ const triple = parsed.triple;
63
+ const isBacklog = cap.backlog === true || triple[0] === 0;
64
+ if (!isBacklog) {
65
+ if (addressToId.has(cap.address)) {
66
+ errors.push({ code: 'DUPLICATE_ADDRESS', message: `Duplicate address "${cap.address}" (capability ${cap.id})`, path });
67
+ }
68
+ else {
69
+ addressToId.set(cap.address, cap.id);
70
+ }
71
+ onDiagram.set(cap.id, triple);
72
+ }
73
+ if (Array.isArray(cap.maturity)) {
74
+ for (const snap of cap.maturity) {
75
+ const level = snap?.level;
76
+ if (typeof level === 'number' && (level < 1 || level > 5)) {
77
+ warnings.push({ code: 'INVALID_MATURITY_LEVEL', message: `Capability ${cap.id} has maturity level ${level} outside 1..5`, path });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (errors.length > 0)
83
+ return { valid: false, errors, warnings };
84
+ for (const [id, [x, y, z]] of onDiagram) {
85
+ if (z !== 0 && !addressToId.has(`${x}.${y}.0`)) {
86
+ warnings.push({ code: 'MISSING_PARENT_BY_ADDRESS', message: `Capability ${id} (${x}.${y}.${z}) has no L2 parent at ${x}.${y}.0` });
87
+ }
88
+ else if (y !== 0 && z === 0 && !addressToId.has(`${x}.0.0`)) {
89
+ warnings.push({ code: 'MISSING_PARENT_BY_ADDRESS', message: `Capability ${id} (${x}.${y}.0) has no L1 parent at ${x}.0.0` });
90
+ }
91
+ }
92
+ return { valid: errors.length === 0, errors, warnings };
93
+ }
@@ -4,3 +4,12 @@ export type { CapabilityTreeNode, CapabilityTreeEdge, CapabilityTreeLevelCounts,
4
4
  export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
5
5
  export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
6
6
  export { renderCapabilityTreeSvg } from './render-capability-tree.js';
7
+ export type { Capability, CapabilityMap, MaturitySnapshot, MutationResult, LayoutOptions, LaidOutNode, LaidOutEdge, CapabilityMapLayout, } from './dsm-schema.js';
8
+ export { parseAddress, formatAddress, getLevel, getParentAddress, getFirstFreeAddress, isAddressTaken, } from './address.js';
9
+ export { validateCapabilityMapData } from './dsm-validate.js';
10
+ export { layoutCapabilityMap } from './dsm-layout.js';
11
+ export { reparent, addChild, deleteWithDescendants, moveBranchToBacklog, restoreFromBacklog, normaliseAddresses } from './dsm-mutations.js';
12
+ export { CapabilityMapView } from './CapabilityMapView.js';
13
+ export type { CapabilityMapViewProps, CapabilityMapChange } from './CapabilityMapView.js';
14
+ export type { ThemeTokens } from './dsm-theme.js';
15
+ export { DEFAULT_MATURITY_COLOURS } from './dsm-theme.js';
@@ -1,3 +1,9 @@
1
1
  export { validateCapabilityMap } from './validate.js';
2
2
  export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
3
3
  export { renderCapabilityTreeSvg } from './render-capability-tree.js';
4
+ export { parseAddress, formatAddress, getLevel, getParentAddress, getFirstFreeAddress, isAddressTaken, } from './address.js';
5
+ export { validateCapabilityMapData } from './dsm-validate.js';
6
+ export { layoutCapabilityMap } from './dsm-layout.js';
7
+ export { reparent, addChild, deleteWithDescendants, moveBranchToBacklog, restoreFromBacklog, normaliseAddresses } from './dsm-mutations.js';
8
+ export { CapabilityMapView } from './CapabilityMapView.js';
9
+ export { DEFAULT_MATURITY_COLOURS } from './dsm-theme.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildComplianceIndex } from '../reverse-index.js';
3
+ import { buildRequirementTrace, buildTraceElementCatalog } from '../trace.js';
4
+ // Requirement traceability + hierarchy view.
5
+ //
6
+ // The two halves the view exposes:
7
+ // 1. Trace chain — derived_from → REQUIREMENT → ASSERTION → subject +
8
+ // realised_via.
9
+ // 2. Hierarchy — parent chain + children via the `parent` field
10
+ // (15-requirement.md §2.4 / ELEMENT_PRIMITIVES.md §7.13).
11
+ const input = {
12
+ requirements: [
13
+ // A broad legislative parent decomposed into two child requirements.
14
+ { id: 'REQUIREMENT-DATA-PROTECTION-1', name: 'Personal-data protection', origin: 'legislative', derived_from: ['REGULATION-GDPR-2016-1'], element_kind: 'requirement' },
15
+ { id: 'REQUIREMENT-DATA-ERASURE-1', name: 'Erase on request within 30 days', origin: 'legislative', parent: 'REQUIREMENT-DATA-PROTECTION-1', derived_from: ['REGULATION-GDPR-2016-1'], element_kind: 'requirement' },
16
+ { id: 'REQUIREMENT-DATA-CONSENT-1', name: 'Obtain consent before processing', origin: 'legislative', parent: 'REQUIREMENT-DATA-PROTECTION-1', element_kind: 'requirement' },
17
+ // A process-product sub-requirement below the erasure duty (cross-origin).
18
+ { id: 'REQUIREMENT-ERASURE-SLA-1', name: 'Erasure SOP — 30d target', origin: 'process-product', parent: 'REQUIREMENT-DATA-ERASURE-1', element_kind: 'requirement' },
19
+ // Constraint hierarchy (mirror pattern).
20
+ { id: 'CONSTRAINT-EEA-TRANSFER-1', name: 'No transfer outside EEA without safeguards', element_kind: 'constraint' },
21
+ { id: 'CONSTRAINT-EEA-ANALYTICS-1', name: 'No PII in analytics logs', parent: 'CONSTRAINT-EEA-TRANSFER-1', element_kind: 'constraint' },
22
+ // Internal-only requirement — no source, no parent — for the empty-case sanity check.
23
+ { id: 'REQUIREMENT-INTERNAL-1', name: 'Internal-only', element_kind: 'requirement' },
24
+ ],
25
+ assertions: [
26
+ { id: 'ASSERTION-1', about: 'REQUIREMENT-DATA-ERASURE-1', subject: 'PRODUCT-MOBILE-1', realised_via: ['CAPABILITY-V1', 'PROCESS-PURGE-1'], status: 'compliant' },
27
+ { id: 'ASSERTION-2', about: 'REQUIREMENT-DATA-ERASURE-1', subject: 'PRODUCT-WEB-1', status: 'partial' },
28
+ { id: 'ASSERTION-3', about: 'REQUIREMENT-DATA-CONSENT-1', subject: 'PRODUCT-WEB-1', status: 'compliant' },
29
+ ],
30
+ };
31
+ const codex = [
32
+ { id: 'REGULATION-GDPR-2016-1', name: 'GDPR', type: 'REGULATION', jurisdiction: 'EU' },
33
+ ];
34
+ const catalog = buildTraceElementCatalog([{ id: 'PRODUCT-MOBILE-1', name: 'Mobile app' }, { id: 'PRODUCT-WEB-1', name: 'Web app' }], [{ id: 'CAPABILITY-V1', name: 'Erasure capability v1' }, { id: 'PROCESS-PURGE-1', name: 'Purge process' }]);
35
+ describe('buildComplianceIndex — requirementsByParent', () => {
36
+ const idx = buildComplianceIndex(input);
37
+ it('groups children of the same parent', () => {
38
+ const children = idx.requirementsByParent.get('REQUIREMENT-DATA-PROTECTION-1');
39
+ expect(children?.map(r => r.id).sort()).toEqual(['REQUIREMENT-DATA-CONSENT-1', 'REQUIREMENT-DATA-ERASURE-1']);
40
+ });
41
+ it('groups CONSTRAINT children the same way (element_kind-agnostic)', () => {
42
+ const children = idx.requirementsByParent.get('CONSTRAINT-EEA-TRANSFER-1');
43
+ expect(children?.map(r => r.id)).toEqual(['CONSTRAINT-EEA-ANALYTICS-1']);
44
+ });
45
+ it('returns undefined for a leaf with no children', () => {
46
+ expect(idx.requirementsByParent.has('REQUIREMENT-INTERNAL-1')).toBe(false);
47
+ });
48
+ });
49
+ describe('buildRequirementTrace — trace chain', () => {
50
+ const idx = buildComplianceIndex(input);
51
+ it('resolves derived_from codex artefacts on the sources block', () => {
52
+ const trace = buildRequirementTrace('REQUIREMENT-DATA-ERASURE-1', idx, catalog, codex);
53
+ expect(trace.sources).toHaveLength(1);
54
+ expect(trace.sources[0].id).toBe('REGULATION-GDPR-2016-1');
55
+ expect(trace.sources[0].codex?.jurisdiction).toBe('EU');
56
+ });
57
+ it('keeps a dangling derived_from ref with no codex when the artefact is missing', () => {
58
+ const local = {
59
+ requirements: [{ id: 'REQUIREMENT-X-1', name: 'X', derived_from: ['LAW-MISSING-1'] }],
60
+ assertions: [],
61
+ };
62
+ const trace = buildRequirementTrace('REQUIREMENT-X-1', buildComplianceIndex(local), catalog);
63
+ expect(trace.sources).toEqual([{ id: 'LAW-MISSING-1' }]);
64
+ });
65
+ it('lists ASSERTIONs id-sorted, each with resolved subject + realised_via names', () => {
66
+ const trace = buildRequirementTrace('REQUIREMENT-DATA-ERASURE-1', idx, catalog, codex);
67
+ expect(trace.assertions.map(a => a.assertion.id)).toEqual(['ASSERTION-1', 'ASSERTION-2']);
68
+ expect(trace.assertions[0].subject).toEqual({ id: 'PRODUCT-MOBILE-1', name: 'Mobile app' });
69
+ expect(trace.assertions[0].realisedVia.map(r => r.name)).toEqual(['Erasure capability v1', 'Purge process']);
70
+ });
71
+ it('returns an empty assertions list for a REQUIREMENT with no filed claim', () => {
72
+ const trace = buildRequirementTrace('REQUIREMENT-DATA-PROTECTION-1', idx, catalog);
73
+ expect(trace.assertions).toEqual([]);
74
+ });
75
+ it('returns an empty assertions list for a CONSTRAINT (16-assertion.md §1 — CONSTRAINT-side out of v1)', () => {
76
+ const trace = buildRequirementTrace('CONSTRAINT-EEA-TRANSFER-1', idx, catalog);
77
+ expect(trace.assertions).toEqual([]);
78
+ });
79
+ it('falls back to id when the subject / realised_via element is not in the catalog (dangling ref)', () => {
80
+ const local = {
81
+ requirements: [{ id: 'REQUIREMENT-Y-1', name: 'Y' }],
82
+ assertions: [{ id: 'ASSERTION-9', about: 'REQUIREMENT-Y-1', subject: 'PRODUCT-GONE-1', realised_via: ['CAPABILITY-GONE-1'], status: 'n_a' }],
83
+ };
84
+ const trace = buildRequirementTrace('REQUIREMENT-Y-1', buildComplianceIndex(local), catalog);
85
+ expect(trace.assertions[0].subject).toEqual({ id: 'PRODUCT-GONE-1' });
86
+ expect(trace.assertions[0].realisedVia).toEqual([{ id: 'CAPABILITY-GONE-1' }]);
87
+ });
88
+ });
89
+ describe('buildRequirementTrace — hierarchy', () => {
90
+ const idx = buildComplianceIndex(input);
91
+ it('walks ancestors: immediate parent first, root last', () => {
92
+ const trace = buildRequirementTrace('REQUIREMENT-ERASURE-SLA-1', idx, catalog);
93
+ expect(trace.ancestors.map(r => r.id)).toEqual([
94
+ 'REQUIREMENT-DATA-ERASURE-1',
95
+ 'REQUIREMENT-DATA-PROTECTION-1',
96
+ ]);
97
+ });
98
+ it('lists direct children id-sorted', () => {
99
+ const trace = buildRequirementTrace('REQUIREMENT-DATA-PROTECTION-1', idx, catalog);
100
+ expect(trace.children.map(r => r.id)).toEqual([
101
+ 'REQUIREMENT-DATA-CONSENT-1',
102
+ 'REQUIREMENT-DATA-ERASURE-1',
103
+ ]);
104
+ });
105
+ it('handles a top-level requirement (no ancestors) with no children', () => {
106
+ const trace = buildRequirementTrace('REQUIREMENT-INTERNAL-1', idx, catalog);
107
+ expect(trace.ancestors).toEqual([]);
108
+ expect(trace.children).toEqual([]);
109
+ });
110
+ it('supports CONSTRAINT hierarchy the same way', () => {
111
+ const trace = buildRequirementTrace('CONSTRAINT-EEA-ANALYTICS-1', idx, catalog);
112
+ expect(trace.ancestors.map(r => r.id)).toEqual(['CONSTRAINT-EEA-TRANSFER-1']);
113
+ });
114
+ it('terminates at a missing parent without throwing (broken model)', () => {
115
+ const local = {
116
+ requirements: [{ id: 'REQUIREMENT-ORPHAN-1', name: 'Orphan', parent: 'REQUIREMENT-GONE-1' }],
117
+ assertions: [],
118
+ };
119
+ const trace = buildRequirementTrace('REQUIREMENT-ORPHAN-1', buildComplianceIndex(local), catalog);
120
+ expect(trace.ancestors).toEqual([]);
121
+ });
122
+ it('breaks a parent cycle rather than looping', () => {
123
+ // A → B → A (misauthored).
124
+ const local = {
125
+ requirements: [
126
+ { id: 'REQUIREMENT-A-1', name: 'A', parent: 'REQUIREMENT-B-1' },
127
+ { id: 'REQUIREMENT-B-1', name: 'B', parent: 'REQUIREMENT-A-1' },
128
+ ],
129
+ assertions: [],
130
+ };
131
+ const trace = buildRequirementTrace('REQUIREMENT-A-1', buildComplianceIndex(local), catalog);
132
+ expect(trace.ancestors.map(r => r.id)).toEqual(['REQUIREMENT-B-1']);
133
+ });
134
+ });
135
+ describe('buildRequirementTrace — dangling target', () => {
136
+ it('returns a stub carrying the id as name when the requirement is not scanned', () => {
137
+ const trace = buildRequirementTrace('REQUIREMENT-UNKNOWN-1', buildComplianceIndex({ requirements: [], assertions: [] }), catalog);
138
+ expect(trace.requirement).toEqual({ id: 'REQUIREMENT-UNKNOWN-1', name: 'REQUIREMENT-UNKNOWN-1' });
139
+ expect(trace.sources).toEqual([]);
140
+ expect(trace.assertions).toEqual([]);
141
+ expect(trace.ancestors).toEqual([]);
142
+ expect(trace.children).toEqual([]);
143
+ });
144
+ });
@@ -32,7 +32,8 @@ export function ingestComplianceDoc(canon, doc) {
32
32
  canon.subjects.push({ id, name: str(d.name) ?? id });
33
33
  return id;
34
34
  }
35
- if (d.notation === 'requirement') {
35
+ if (d.notation === 'requirement' || d.notation === 'constraint') {
36
+ const origin = str(d.origin);
36
37
  canon.requirements.push({
37
38
  id,
38
39
  name: str(d.name) ?? id,
@@ -40,6 +41,11 @@ export function ingestComplianceDoc(canon, doc) {
40
41
  derived_from: strArray(d.derived_from),
41
42
  admitted_at: str(d.admitted_at),
42
43
  deadline: str(d.deadline),
44
+ origin: origin === 'legislative' || origin === 'process-product' || origin === 'project-product' ? origin : undefined,
45
+ parent: str(d.parent),
46
+ element_kind: d.notation,
47
+ description: str(d.description),
48
+ next_review_at: str(d.next_review_at),
43
49
  });
44
50
  return id;
45
51
  }
@@ -1,5 +1,6 @@
1
1
  export { buildComplianceIndex } from './reverse-index.js';
2
2
  export { buildLawTree, buildProductView } from './views.js';
3
+ export { buildRequirementTrace, buildTraceElementCatalog } from './trace.js';
3
4
  export { buildGapReport } from './gap-report.js';
4
5
  export type { GapReport, GapReportOptions } from './gap-report.js';
5
6
  export { emptyCanon, ingestComplianceDoc } from './classify.js';
@@ -13,4 +14,4 @@ export type { ImpactCell, ImpactColumn, ImpactEmptyCellLabels, ImpactGrouping, I
13
14
  export { assertionToScoringElement, complianceConfidenceHeader, requirementToScoringElement, scoreComplianceView, } from './confidence.js';
14
15
  export { parseCoverageMetricConfig, buildCoverageMatrix, } from './coverage-metric.js';
15
16
  export type { CoverageMetricConfig, CoverageMetricRegimes, CoverageMetricRegimesFilter, CoverageMetricSubjects, CoverageMetricThresholds, CoverageMatrix, CoverageRow, ParseCoverageMetricResult, RagStatus, } from './coverage-metric.js';
16
- export type { ComplianceIndex, ComplianceIndexInput, DeadlineStatus, IndexRequirement, IndexAssertion, LawTree, LawTreeRequirement, ProductView, ProductRequirementStatus, ObjectDetailDef, ObjectDetailInput, } from './types.js';
17
+ export type { ComplianceIndex, ComplianceIndexInput, DeadlineStatus, IndexRequirement, IndexAssertion, LawTree, LawTreeRequirement, ProductView, ProductRequirementStatus, ObjectDetailDef, ObjectDetailInput, RequirementTrace, TraceAssertionRow, TraceElementCatalog, TraceElementRef, TraceSourceRef, } from './types.js';
@@ -1,5 +1,6 @@
1
1
  export { buildComplianceIndex } from './reverse-index.js';
2
2
  export { buildLawTree, buildProductView } from './views.js';
3
+ export { buildRequirementTrace, buildTraceElementCatalog } from './trace.js';
3
4
  export { buildGapReport } from './gap-report.js';
4
5
  export { emptyCanon, ingestComplianceDoc } from './classify.js';
5
6
  export { renderComplianceMarkdown } from './markdown.js';