@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,142 @@
1
+ import type { CapabilityMap, LayoutOptions, CapabilityMapLayout, LaidOutNode, LaidOutEdge } from './dsm-schema.js';
2
+ import { getLevel, getParentAddress } from './address.js';
3
+
4
+ const DEFAULT_NODE_WIDTH = 250;
5
+ const DEFAULT_NODE_HEIGHT = 64;
6
+ const DEFAULT_RANK_SEP = 80;
7
+ const DEFAULT_NODE_SEP = 24;
8
+ /** Width of the virtual organisation-root node — narrower than a capability
9
+ * card since it carries only a label, no maturity dot or address. */
10
+ const ROOT_WIDTH = 200;
11
+
12
+ /**
13
+ * Lays out a capability map's on-diagram (non-backlog) capabilities, plus a
14
+ * virtual organisation-root node one column to the left of the L1s (§6.1).
15
+ * Backlog filtering is a Layer B / component concern (same split as the
16
+ * goals module) — this function lays out whatever `capabilities` it's given,
17
+ * using their address to derive parent/child structure.
18
+ */
19
+ export function layoutCapabilityMap(map: CapabilityMap, options: LayoutOptions = {}): CapabilityMapLayout {
20
+ const {
21
+ nodeWidth = DEFAULT_NODE_WIDTH,
22
+ nodeHeight = DEFAULT_NODE_HEIGHT,
23
+ rankSep = DEFAULT_RANK_SEP,
24
+ nodeSep = DEFAULT_NODE_SEP,
25
+ hideCollapsed = [],
26
+ // organisationLabel isn't stored on LaidOutNode (its `data` is a
27
+ // Capability | null, with no label slot for the virtual root) — the
28
+ // component reads `layoutOptions.organisationLabel ?? map.organisation`
29
+ // directly when it renders the root card.
30
+ } = options;
31
+
32
+ const hiddenSet = new Set(hideCollapsed);
33
+ const onDiagram = map.capabilities.filter((c) => !c.backlog);
34
+ const byId = new Map(onDiagram.map((c) => [c.id, c]));
35
+ const addressToId = new Map(onDiagram.map((c) => [c.address, c.id]));
36
+
37
+ const children = new Map<number, number[]>();
38
+ const l1Ids: number[] = [];
39
+ for (const c of onDiagram) {
40
+ const level = getLevel(c.address);
41
+ if (level === 'backlog') continue;
42
+ if (level === 1) {
43
+ l1Ids.push(c.id);
44
+ continue;
45
+ }
46
+ const parentAddress = getParentAddress(c.address);
47
+ const parentId = parentAddress ? addressToId.get(parentAddress) : undefined;
48
+ if (parentId === undefined) {
49
+ // MISSING_PARENT_BY_ADDRESS is a warning, not an error — render it
50
+ // hanging directly off the root rather than dropping it silently.
51
+ l1Ids.push(c.id);
52
+ continue;
53
+ }
54
+ if (!children.has(parentId)) children.set(parentId, []);
55
+ children.get(parentId)!.push(c.id);
56
+ }
57
+
58
+ const byAddress = (a: number, b: number): number =>
59
+ byId.get(a)!.address.localeCompare(byId.get(b)!.address, undefined, { numeric: true });
60
+ l1Ids.sort(byAddress);
61
+ for (const kids of children.values()) kids.sort(byAddress);
62
+
63
+ const hiddenSubtrees = new Set<number>();
64
+ function markHidden(id: number): void {
65
+ for (const child of children.get(id) ?? []) {
66
+ hiddenSubtrees.add(child);
67
+ markHidden(child);
68
+ }
69
+ }
70
+ for (const id of hiddenSet) markHidden(id);
71
+
72
+ const nodes: LaidOutNode[] = [];
73
+ const edges: LaidOutEdge[] = [];
74
+ const columnNextY = new Map<number, number>();
75
+ const getNextY = (col: number): number => columnNextY.get(col) ?? 0;
76
+ const advanceY = (col: number, delta: number): void => { columnNextY.set(col, (columnNextY.get(col) ?? 0) + delta); };
77
+
78
+ function placeSubtree(id: number, col: number): { top: number; bottom: number } {
79
+ const cap = byId.get(id)!;
80
+ const allChildIds = children.get(id) ?? [];
81
+ const childIds = allChildIds.filter((c) => !hiddenSubtrees.has(c));
82
+ const hasHiddenChildren = childIds.length < allChildIds.length;
83
+
84
+ if (childIds.length === 0) {
85
+ const y = getNextY(col);
86
+ advanceY(col, nodeHeight + nodeSep);
87
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
88
+ return { top: y, bottom: y + nodeHeight };
89
+ }
90
+
91
+ const spans = childIds.map((cid) => placeSubtree(cid, col + 1));
92
+ const spanTop = Math.min(...spans.map((s) => s.top));
93
+ const spanBottom = Math.max(...spans.map((s) => s.bottom));
94
+ const parentY = spanTop + (spanBottom - spanTop) / 2 - nodeHeight / 2;
95
+ const curColY = getNextY(col);
96
+ const finalY = Math.max(parentY, curColY);
97
+ advanceY(col, finalY - curColY + nodeHeight + nodeSep);
98
+
99
+ nodes.push({ id, x: col * (nodeWidth + rankSep), y: finalY, width: nodeWidth, height: nodeHeight, data: cap, hasHiddenChildren });
100
+ for (const cid of childIds) edges.push({ source: id, target: cid });
101
+ return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + nodeHeight, spanBottom) };
102
+ }
103
+
104
+ for (const id of l1Ids) {
105
+ if (hiddenSubtrees.has(id)) continue;
106
+ placeSubtree(id, 1);
107
+ for (const [col, y] of columnNextY) columnNextY.set(col, y + nodeSep);
108
+ }
109
+
110
+ if (nodes.length === 0) {
111
+ const rootNode: LaidOutNode = { id: 0, x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
112
+ return { rootNode, nodes: [], edges: [], bounds: { x: 0, y: 0, width: ROOT_WIDTH, height: nodeHeight } };
113
+ }
114
+
115
+ const minX = Math.min(...nodes.map((n) => n.x));
116
+ const minY = Math.min(...nodes.map((n) => n.y));
117
+ const maxX = Math.max(...nodes.map((n) => n.x + n.width));
118
+ const maxY = Math.max(...nodes.map((n) => n.y + n.height));
119
+
120
+ const l1IdSet = new Set(l1Ids);
121
+ const l1Nodes = nodes.filter((n) => l1IdSet.has(n.id));
122
+ const rootTop = Math.min(...l1Nodes.map((n) => n.y));
123
+ const rootBottom = Math.max(...l1Nodes.map((n) => n.y + n.height));
124
+ const rootY = rootTop + (rootBottom - rootTop) / 2 - nodeHeight / 2;
125
+ const rootNode: LaidOutNode = { id: 0, x: 0, y: rootY, width: ROOT_WIDTH, height: nodeHeight, data: null, hasHiddenChildren: false };
126
+
127
+ const rootEdges: LaidOutEdge[] = l1Ids
128
+ .filter((id) => !hiddenSubtrees.has(id))
129
+ .map((id) => ({ source: 'root' as const, target: id }));
130
+
131
+ return {
132
+ rootNode,
133
+ nodes,
134
+ edges: [...rootEdges, ...edges],
135
+ bounds: {
136
+ x: Math.min(0, minX),
137
+ y: Math.min(rootY, minY),
138
+ width: Math.max(maxX, ROOT_WIDTH) - Math.min(0, minX),
139
+ height: Math.max(rootY + nodeHeight, maxY) - Math.min(rootY, minY),
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,208 @@
1
+ import type { Capability, CapabilityMap, MutationResult, ValidationError } from './dsm-schema.js';
2
+ import { parseAddress, getLevel, getParentAddress, getFirstFreeAddress, isAddressTaken } from './address.js';
3
+
4
+ function cloneMap(map: CapabilityMap): CapabilityMap {
5
+ return {
6
+ organisation: map.organisation,
7
+ set_id: map.set_id,
8
+ capabilities: map.capabilities.map((c) => ({
9
+ ...c,
10
+ maturity: c.maturity ? c.maturity.map((m) => ({ ...m })) : undefined,
11
+ })),
12
+ };
13
+ }
14
+
15
+ function nextId(map: CapabilityMap): number {
16
+ return Math.max(0, ...map.capabilities.map((c) => c.id)) + 1;
17
+ }
18
+
19
+ function refusalError(message: string): MutationResult<CapabilityMap> {
20
+ const err: ValidationError = { code: 'MUTATION_REFUSED', message };
21
+ return { ok: false, error: err };
22
+ }
23
+
24
+ /** True when `addr` sits anywhere under `ancestorAddr` in the address hierarchy. */
25
+ function isDescendantAddress(addr: string, ancestorAddr: string): boolean {
26
+ const [ax, ay, az] = parseAddress(addr);
27
+ const [px, py, pz] = parseAddress(ancestorAddr);
28
+ if (px !== ax) return false;
29
+ if (py === 0) return ay !== 0 || az !== 0; // ancestor is L1 — any L2/L3 under the same X
30
+ if (pz === 0) return ay === py && az !== 0; // ancestor is L2 — only L3s under the same X.Y
31
+ return false; // ancestor is L3 — no descendants possible (max depth)
32
+ }
33
+
34
+ /** First unused top-level (L1) address — the "parent" for a promote-to-L1 reparent. */
35
+ function firstFreeL1(capabilities: Capability[]): string {
36
+ for (let x = 1; x < 1000; x++) {
37
+ const candidate = `${x}.0.0`;
38
+ if (!isAddressTaken(candidate, capabilities)) return candidate;
39
+ }
40
+ throw new Error('firstFreeL1: no free top-level address (searched 1..999)');
41
+ }
42
+
43
+ /**
44
+ * Move `sourceId` under `targetId` (or under the virtual root when
45
+ * `targetId` is 0 — the sentinel matching the goals module's `parent_id: 0`
46
+ * convention, since the root isn't itself a Capability with an id).
47
+ *
48
+ * Recomputes addresses for the whole moved branch (§5.1). The depth check is
49
+ * expressed once, generically, as "every descendant's new level must stay in
50
+ * 1..3" — this covers both directions the spec discusses explicitly (L2→L1
51
+ * promotion, where descendants move one level shallower) and the symmetric,
52
+ * unspecified case (L1→L2 demotion, where descendants move one level
53
+ * deeper) without needing separate per-direction rules: an L1 with L3
54
+ * grandchildren refuses demotion to L2 because that would need a 4th level,
55
+ * caught by the same check.
56
+ */
57
+ export function reparent(map: CapabilityMap, sourceId: number, targetId: number): MutationResult<CapabilityMap> {
58
+ const clone = cloneMap(map);
59
+ const source = clone.capabilities.find((c) => c.id === sourceId);
60
+ if (!source || source.backlog) return refusalError(`Capability ${sourceId} not found (use restoreFromBacklog for backlog items)`);
61
+ if (sourceId === targetId) return refusalError('Cannot reparent to self');
62
+
63
+ const target = targetId === 0 ? null : clone.capabilities.find((c) => c.id === targetId);
64
+ if (targetId !== 0 && (!target || target.backlog)) return refusalError(`Target capability ${targetId} not found`);
65
+
66
+ if (target && isDescendantAddress(target.address, source.address)) {
67
+ return refusalError('Cannot reparent: target is a descendant of the source (would create a cycle)');
68
+ }
69
+
70
+ const sourceLevel = getLevel(source.address);
71
+ if (sourceLevel === 'backlog') return refusalError(`Capability ${sourceId} has no on-diagram address`);
72
+ const targetLevel = target ? getLevel(target.address) : 0;
73
+ const newLevel = (typeof targetLevel === 'number' ? targetLevel : 0) + 1;
74
+ if (newLevel > 3) return refusalError('Reparent would exceed max depth (L3)');
75
+
76
+ const levelDelta = newLevel - sourceLevel;
77
+ const originalSourceAddress = source.address;
78
+
79
+ // Snapshot descendants + their original address/level BEFORE anything is
80
+ // reassigned — both for the depth check and to resolve old-parent links
81
+ // while walking the branch top-down below.
82
+ const descendants = clone.capabilities
83
+ .filter((c) => !c.backlog && c.id !== sourceId && isDescendantAddress(c.address, originalSourceAddress))
84
+ .map((c) => ({ cap: c, originalAddress: c.address, originalLevel: getLevel(c.address) as 1 | 2 | 3 }));
85
+
86
+ for (const d of descendants) {
87
+ const newDescLevel = d.originalLevel + levelDelta;
88
+ if (newDescLevel > 3 || newDescLevel < 1) {
89
+ return refusalError(`Reparent would push descendant ${d.cap.id} to level ${newDescLevel}, outside 1..3`);
90
+ }
91
+ }
92
+
93
+ const oldAddressToId = new Map<string, number>(clone.capabilities.filter((c) => !c.backlog).map((c) => [c.address, c.id]));
94
+ const newAddressById = new Map<number, string>();
95
+
96
+ source.address = target ? getFirstFreeAddress(target.address, clone.capabilities) : firstFreeL1(clone.capabilities);
97
+ newAddressById.set(sourceId, source.address);
98
+
99
+ // Shallowest-first so each descendant's old parent has already been
100
+ // re-addressed (or is the source itself) by the time we reach it.
101
+ descendants.sort((a, b) => a.originalLevel - b.originalLevel);
102
+ for (const d of descendants) {
103
+ const oldParentAddress = getParentAddress(d.originalAddress);
104
+ const oldParentId = oldParentAddress ? oldAddressToId.get(oldParentAddress) : undefined;
105
+ const newParentAddress = oldParentId != null ? newAddressById.get(oldParentId) : undefined;
106
+ if (!newParentAddress) {
107
+ return refusalError(`Reparent could not resolve the new parent address for descendant ${d.cap.id}`);
108
+ }
109
+ const newAddress = getFirstFreeAddress(newParentAddress, clone.capabilities);
110
+ d.cap.address = newAddress;
111
+ newAddressById.set(d.cap.id, newAddress);
112
+ }
113
+
114
+ return { ok: true, result: clone };
115
+ }
116
+
117
+ export function addChild(map: CapabilityMap, parentId: number, newCap: Omit<Capability, 'id' | 'address'>): MutationResult<CapabilityMap> {
118
+ const clone = cloneMap(map);
119
+ const parent = parentId === 0 ? null : clone.capabilities.find((c) => c.id === parentId);
120
+ if (parentId !== 0 && (!parent || parent.backlog)) return refusalError(`Parent capability ${parentId} not found`);
121
+
122
+ const parentLevel = parent ? getLevel(parent.address) : 0;
123
+ const newLevel = (typeof parentLevel === 'number' ? parentLevel : 0) + 1;
124
+ if (newLevel > 3) return refusalError('New child would exceed max depth (L3)');
125
+
126
+ const address = parent ? getFirstFreeAddress(parent.address, clone.capabilities) : firstFreeL1(clone.capabilities);
127
+ const id = nextId(clone);
128
+ clone.capabilities.push({ ...newCap, id, address, backlog: false });
129
+ return { ok: true, result: clone };
130
+ }
131
+
132
+ export function deleteWithDescendants(map: CapabilityMap, id: number): MutationResult<CapabilityMap> {
133
+ const clone = cloneMap(map);
134
+ const target = clone.capabilities.find((c) => c.id === id);
135
+ if (!target) return refusalError(`Capability ${id} not found`);
136
+
137
+ const toDelete = new Set<number>([id]);
138
+ if (!target.backlog) {
139
+ for (const c of clone.capabilities) {
140
+ if (!c.backlog && isDescendantAddress(c.address, target.address)) toDelete.add(c.id);
141
+ }
142
+ }
143
+ clone.capabilities = clone.capabilities.filter((c) => !toDelete.has(c.id));
144
+ return { ok: true, result: clone };
145
+ }
146
+
147
+ /** Moves a whole branch to the backlog. Descendants move with it (a
148
+ * backlogged capability's on-diagram children would otherwise dangle). */
149
+ export function moveBranchToBacklog(map: CapabilityMap, id: number): MutationResult<CapabilityMap> {
150
+ const clone = cloneMap(map);
151
+ const target = clone.capabilities.find((c) => c.id === id);
152
+ if (!target) return refusalError(`Capability ${id} not found`);
153
+ if (target.backlog) return refusalError(`Capability ${id} is already in the backlog`);
154
+
155
+ const branch = [target, ...clone.capabilities.filter((c) => !c.backlog && isDescendantAddress(c.address, target.address))];
156
+ for (const c of branch) {
157
+ c.backlog = true;
158
+ c.address = '0.0.0';
159
+ }
160
+ return { ok: true, result: clone };
161
+ }
162
+
163
+ export function restoreFromBacklog(map: CapabilityMap, id: number, parentId: number): MutationResult<CapabilityMap> {
164
+ const clone = cloneMap(map);
165
+ const target = clone.capabilities.find((c) => c.id === id);
166
+ if (!target || !target.backlog) return refusalError(`Capability ${id} is not in the backlog`);
167
+
168
+ const parent = parentId === 0 ? null : clone.capabilities.find((c) => c.id === parentId);
169
+ if (parentId !== 0 && (!parent || parent.backlog)) return refusalError(`Parent capability ${parentId} not found`);
170
+ const parentLevel = parent ? getLevel(parent.address) : 0;
171
+ const newLevel = (typeof parentLevel === 'number' ? parentLevel : 0) + 1;
172
+ if (newLevel > 3) return refusalError('Restore would exceed max depth (L3)');
173
+
174
+ target.address = parent ? getFirstFreeAddress(parent.address, clone.capabilities) : firstFreeL1(clone.capabilities);
175
+ target.backlog = false;
176
+ return { ok: true, result: clone };
177
+ }
178
+
179
+ /**
180
+ * Repairs addresses that don't resolve to an existing parent (MISSING_PARENT_BY_ADDRESS):
181
+ * reassigns each such capability to the first free slot under its would-be
182
+ * parent's level, or moves it to the backlog when no such parent exists at all.
183
+ */
184
+ export function normaliseAddresses(map: CapabilityMap): CapabilityMap {
185
+ const clone = cloneMap(map);
186
+ const addressToId = new Map<string, number>(clone.capabilities.filter((c) => !c.backlog).map((c) => [c.address, c.id]));
187
+
188
+ // Re-derive level-by-level (L1 first) so an L1 fixed up in this pass can
189
+ // already serve as a valid parent for an L2 processed right after it.
190
+ const byLevel = clone.capabilities
191
+ .filter((c) => !c.backlog)
192
+ .map((c) => ({ cap: c, level: getLevel(c.address) }))
193
+ .filter((e): e is { cap: Capability; level: 1 | 2 | 3 } => typeof e.level === 'number')
194
+ .sort((a, b) => a.level - b.level);
195
+
196
+ for (const { cap, level } of byLevel) {
197
+ if (level === 1) continue; // L1 has no parent to validate against
198
+ const parentAddress = getParentAddress(cap.address);
199
+ if (parentAddress && addressToId.has(parentAddress)) continue; // already fine
200
+
201
+ // No resolvable parent: reassign to the backlog rather than guessing one.
202
+ addressToId.delete(cap.address);
203
+ cap.backlog = true;
204
+ cap.address = '0.0.0';
205
+ }
206
+
207
+ return clone;
208
+ }
@@ -0,0 +1,80 @@
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
+
15
+ export interface MaturitySnapshot {
16
+ date: string;
17
+ level: number;
18
+ }
19
+
20
+ export interface Capability {
21
+ id: number;
22
+ name: string;
23
+ /** 'X.Y.Z' triple. X=0 or backlog=true means "not on the diagram". */
24
+ address: string;
25
+ backlog?: boolean;
26
+ description?: string;
27
+ maturity?: MaturitySnapshot[];
28
+ }
29
+
30
+ export interface CapabilityMap {
31
+ organisation: string;
32
+ set_id: string;
33
+ capabilities: Capability[];
34
+ }
35
+
36
+ export type {
37
+ ValidationError,
38
+ ValidationWarning,
39
+ ValidationResult,
40
+ } from '../validation-types.js';
41
+
42
+ import type { ValidationError } from '../validation-types.js';
43
+
44
+ export interface MutationResult<T> {
45
+ ok: boolean;
46
+ result?: T;
47
+ error?: ValidationError;
48
+ }
49
+
50
+ export interface LayoutOptions {
51
+ rankdir?: 'LR' | 'TB';
52
+ nodeWidth?: number;
53
+ nodeHeight?: number;
54
+ rankSep?: number;
55
+ nodeSep?: number;
56
+ hideCollapsed?: number[];
57
+ organisationLabel?: string;
58
+ }
59
+
60
+ export interface LaidOutNode {
61
+ id: number;
62
+ x: number;
63
+ y: number;
64
+ width: number;
65
+ height: number;
66
+ data: Capability | null;
67
+ hasHiddenChildren: boolean;
68
+ }
69
+
70
+ export interface LaidOutEdge {
71
+ source: number | 'root';
72
+ target: number;
73
+ }
74
+
75
+ export interface CapabilityMapLayout {
76
+ rootNode: LaidOutNode;
77
+ nodes: LaidOutNode[];
78
+ edges: LaidOutEdge[];
79
+ bounds: { x: number; y: number; width: number; height: number };
80
+ }
@@ -0,0 +1,53 @@
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
+
16
+ /** Default 1..5 maturity-dot colour scale — overridable via the
17
+ * `maturityColours` prop (spec §6.2), which is separate from ThemeTokens
18
+ * since it's keyed by maturity level, not a fixed named token. */
19
+ export const DEFAULT_MATURITY_COLOURS: Record<number, string> = {
20
+ 1: '#ef4444',
21
+ 2: '#f97316',
22
+ 3: '#eab308',
23
+ 4: '#84cc16',
24
+ 5: '#22c55e',
25
+ };
26
+
27
+ export const DEFAULT_THEME: Required<ThemeTokens> = {
28
+ cardFill: '#eff6ff',
29
+ cardBorderColor: '#94a3b8',
30
+ cardBorderWidth: 1,
31
+ cardTextColor: '#0f172a',
32
+ cardMetaColor: '#64748b',
33
+ edgeColor: '#94a3b8',
34
+ edgeWidth: 1.5,
35
+ dropTargetBorderColor: '#2563eb',
36
+ rootFill: '#e2e8f0',
37
+ rootTextColor: '#0f172a',
38
+ };
39
+
40
+ export function resolveTheme(theme?: ThemeTokens): Required<ThemeTokens> {
41
+ return {
42
+ cardFill: theme?.cardFill ?? DEFAULT_THEME.cardFill,
43
+ cardBorderColor: theme?.cardBorderColor ?? DEFAULT_THEME.cardBorderColor,
44
+ cardBorderWidth: theme?.cardBorderWidth ?? DEFAULT_THEME.cardBorderWidth,
45
+ cardTextColor: theme?.cardTextColor ?? DEFAULT_THEME.cardTextColor,
46
+ cardMetaColor: theme?.cardMetaColor ?? DEFAULT_THEME.cardMetaColor,
47
+ edgeColor: theme?.edgeColor ?? DEFAULT_THEME.edgeColor,
48
+ edgeWidth: theme?.edgeWidth ?? DEFAULT_THEME.edgeWidth,
49
+ dropTargetBorderColor: theme?.dropTargetBorderColor ?? DEFAULT_THEME.dropTargetBorderColor,
50
+ rootFill: theme?.rootFill ?? DEFAULT_THEME.rootFill,
51
+ rootTextColor: theme?.rootTextColor ?? DEFAULT_THEME.rootTextColor,
52
+ };
53
+ }
@@ -0,0 +1,103 @@
1
+ import type { CapabilityMap, ValidationResult, ValidationError, ValidationWarning } from './dsm-schema.js';
2
+
3
+ /** Parses an address string loosely enough to tell "wrong shape"
4
+ * (INVALID_ADDRESS_FORMAT) apart from "right shape, too many levels"
5
+ * (MAX_DEPTH_EXCEEDED, e.g. '1.2.3.4') — a strict 3-segment regex would
6
+ * reject both identically and lose that distinction. */
7
+ function parseLoose(address: string): { triple: [number, number, number] } | { tooDeep: true } | null {
8
+ const parts = address.trim().split('.');
9
+ if (parts.length < 3 || !parts.every((p) => /^\d+$/.test(p))) return null;
10
+ if (parts.length > 3) return { tooDeep: true };
11
+ return { triple: [Number(parts[0]), Number(parts[1]), Number(parts[2])] };
12
+ }
13
+
14
+ export function validateCapabilityMapData(input: unknown): ValidationResult {
15
+ const errors: ValidationError[] = [];
16
+ const warnings: ValidationWarning[] = [];
17
+
18
+ if (!input || typeof input !== 'object') {
19
+ return { valid: false, errors: [{ code: 'SCHEMA_INVALID', message: 'Input must be an object' }], warnings: [] };
20
+ }
21
+
22
+ const raw = input as Record<string, unknown>;
23
+ if (!Array.isArray(raw.capabilities)) {
24
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capabilities must be an array', path: 'capabilities' });
25
+ return { valid: false, errors, warnings };
26
+ }
27
+
28
+ const map = raw as unknown as CapabilityMap;
29
+ const idSet = new Set<number>();
30
+ const addressToId = new Map<string, number>();
31
+ const onDiagram = new Map<number, [number, number, number]>();
32
+
33
+ for (let i = 0; i < map.capabilities.length; i++) {
34
+ const c = map.capabilities[i] as unknown;
35
+ const path = `capabilities[${i}]`;
36
+
37
+ if (!c || typeof c !== 'object') {
38
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capability entry must be an object', path });
39
+ continue;
40
+ }
41
+ const cap = c as { id?: unknown; name?: unknown; address?: unknown; backlog?: unknown; maturity?: unknown };
42
+
43
+ if (typeof cap.id !== 'number') {
44
+ errors.push({ code: 'SCHEMA_INVALID', message: 'capability id must be a number', path });
45
+ continue;
46
+ }
47
+ if (idSet.has(cap.id)) {
48
+ errors.push({ code: 'DUPLICATE_ID', message: `Duplicate capability id: ${cap.id}`, path });
49
+ } else {
50
+ idSet.add(cap.id);
51
+ }
52
+
53
+ if (!cap.name || typeof cap.name !== 'string' || cap.name.trim() === '') {
54
+ errors.push({ code: 'EMPTY_NAME', message: `Capability ${cap.id} has empty name`, path });
55
+ }
56
+
57
+ if (typeof cap.address !== 'string') {
58
+ errors.push({ code: 'INVALID_ADDRESS_FORMAT', message: `Capability ${cap.id} address must be a string`, path });
59
+ continue;
60
+ }
61
+ const parsed = parseLoose(cap.address);
62
+ if (parsed === null) {
63
+ errors.push({ code: 'INVALID_ADDRESS_FORMAT', message: `Capability ${cap.id} address "${cap.address}" is not in 'X.Y.Z' form`, path });
64
+ continue;
65
+ }
66
+ if ('tooDeep' in parsed) {
67
+ errors.push({ code: 'MAX_DEPTH_EXCEEDED', message: `Capability ${cap.id} address "${cap.address}" has more than 3 levels`, path });
68
+ continue;
69
+ }
70
+ const triple = parsed.triple;
71
+ const isBacklog = cap.backlog === true || triple[0] === 0;
72
+
73
+ if (!isBacklog) {
74
+ if (addressToId.has(cap.address)) {
75
+ errors.push({ code: 'DUPLICATE_ADDRESS', message: `Duplicate address "${cap.address}" (capability ${cap.id})`, path });
76
+ } else {
77
+ addressToId.set(cap.address, cap.id);
78
+ }
79
+ onDiagram.set(cap.id, triple);
80
+ }
81
+
82
+ if (Array.isArray(cap.maturity)) {
83
+ for (const snap of cap.maturity as unknown[]) {
84
+ const level = (snap as { level?: unknown } | null)?.level;
85
+ if (typeof level === 'number' && (level < 1 || level > 5)) {
86
+ warnings.push({ code: 'INVALID_MATURITY_LEVEL', message: `Capability ${cap.id} has maturity level ${level} outside 1..5`, path });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ if (errors.length > 0) return { valid: false, errors, warnings };
93
+
94
+ for (const [id, [x, y, z]] of onDiagram) {
95
+ if (z !== 0 && !addressToId.has(`${x}.${y}.0`)) {
96
+ warnings.push({ code: 'MISSING_PARENT_BY_ADDRESS', message: `Capability ${id} (${x}.${y}.${z}) has no L2 parent at ${x}.${y}.0` });
97
+ } else if (y !== 0 && z === 0 && !addressToId.has(`${x}.0.0`)) {
98
+ warnings.push({ code: 'MISSING_PARENT_BY_ADDRESS', message: `Capability ${id} (${x}.${y}.0) has no L1 parent at ${x}.0.0` });
99
+ }
100
+ }
101
+
102
+ return { valid: errors.length === 0, errors, warnings };
103
+ }
@@ -17,3 +17,38 @@ export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP,
17
17
 
18
18
  export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
19
19
  export { renderCapabilityTreeSvg } from './render-capability-tree.js';
20
+
21
+ // ── DSM-migration capability map (docs/extraction/capability-map.md) ───────
22
+ // A separate, flat, address-triple data model from CapabilityNode above —
23
+ // see dsm-schema.ts for why the two coexist in this folder. Named
24
+ // `validateCapabilityMapData` rather than `validateCapabilityMap` because
25
+ // that name is already taken by the native-notation validator on line 8.
26
+
27
+ export type {
28
+ Capability,
29
+ CapabilityMap,
30
+ MaturitySnapshot,
31
+ MutationResult,
32
+ LayoutOptions,
33
+ LaidOutNode,
34
+ LaidOutEdge,
35
+ CapabilityMapLayout,
36
+ } from './dsm-schema.js';
37
+
38
+ export {
39
+ parseAddress,
40
+ formatAddress,
41
+ getLevel,
42
+ getParentAddress,
43
+ getFirstFreeAddress,
44
+ isAddressTaken,
45
+ } from './address.js';
46
+
47
+ export { validateCapabilityMapData } from './dsm-validate.js';
48
+ export { layoutCapabilityMap } from './dsm-layout.js';
49
+ export { reparent, addChild, deleteWithDescendants, moveBranchToBacklog, restoreFromBacklog, normaliseAddresses } from './dsm-mutations.js';
50
+
51
+ export { CapabilityMapView } from './CapabilityMapView.js';
52
+ export type { CapabilityMapViewProps, CapabilityMapChange } from './CapabilityMapView.js';
53
+ export type { ThemeTokens } from './dsm-theme.js';
54
+ export { DEFAULT_MATURITY_COLOURS } from './dsm-theme.js';