@transitrix/diagrams 1.5.0 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capability-map/CapabilityCardNode.d.ts +19 -0
- package/dist/capability-map/CapabilityCardNode.js +66 -0
- package/dist/capability-map/CapabilityMapView.d.ts +59 -0
- package/dist/capability-map/CapabilityMapView.js +204 -0
- package/dist/capability-map/CapabilityRootNode.d.ts +12 -0
- package/dist/capability-map/CapabilityRootNode.js +23 -0
- package/dist/capability-map/__tests__/CapabilityMapView.test.d.ts +1 -0
- package/dist/capability-map/__tests__/CapabilityMapView.test.js +217 -0
- package/dist/capability-map/__tests__/address.test.d.ts +1 -0
- package/dist/capability-map/__tests__/address.test.js +49 -0
- package/dist/capability-map/__tests__/dsm-layout.test.d.ts +1 -0
- package/dist/capability-map/__tests__/dsm-layout.test.js +46 -0
- package/dist/capability-map/__tests__/dsm-mutations.test.d.ts +1 -0
- package/dist/capability-map/__tests__/dsm-mutations.test.js +125 -0
- package/dist/capability-map/__tests__/dsm-validate.test.d.ts +1 -0
- package/dist/capability-map/__tests__/dsm-validate.test.js +56 -0
- package/dist/capability-map/address.d.ts +12 -0
- package/dist/capability-map/address.js +48 -0
- package/dist/capability-map/dsm-layout.d.ts +9 -0
- package/dist/capability-map/dsm-layout.js +125 -0
- package/dist/capability-map/dsm-mutations.d.ts +28 -0
- package/dist/capability-map/dsm-mutations.js +196 -0
- package/dist/capability-map/dsm-schema.d.ts +71 -0
- package/dist/capability-map/dsm-schema.js +14 -0
- package/dist/capability-map/dsm-theme.d.ts +20 -0
- package/dist/capability-map/dsm-theme.js +36 -0
- package/dist/capability-map/dsm-validate.d.ts +2 -0
- package/dist/capability-map/dsm-validate.js +93 -0
- package/dist/capability-map/index.d.ts +9 -0
- package/dist/capability-map/index.js +6 -0
- package/dist/codex/__tests__/example.test.d.ts +1 -0
- package/dist/codex/__tests__/example.test.js +33 -0
- package/dist/codex/__tests__/validate.test.d.ts +1 -0
- package/dist/codex/__tests__/validate.test.js +68 -0
- package/dist/codex/index.d.ts +4 -0
- package/dist/codex/index.js +2 -0
- package/dist/codex/types.d.ts +7 -0
- package/dist/codex/types.js +8 -0
- package/dist/codex/validate.d.ts +11 -0
- package/dist/codex/validate.js +103 -0
- package/dist/compliance/__tests__/canon-catalog.test.d.ts +1 -0
- package/dist/compliance/__tests__/canon-catalog.test.js +60 -0
- package/dist/compliance/__tests__/trace.test.d.ts +1 -0
- package/dist/compliance/__tests__/trace.test.js +144 -0
- package/dist/compliance/__tests__/view-resolution.test.d.ts +1 -0
- package/dist/compliance/__tests__/view-resolution.test.js +59 -0
- package/dist/compliance/canon-catalog.d.ts +19 -0
- package/dist/compliance/canon-catalog.js +59 -0
- package/dist/compliance/classify.js +7 -1
- package/dist/compliance/coverage-metric.d.ts +12 -0
- package/dist/compliance/coverage-metric.js +35 -0
- package/dist/compliance/impact.d.ts +7 -1
- package/dist/compliance/impact.js +53 -0
- package/dist/compliance/index.d.ts +8 -5
- package/dist/compliance/index.js +4 -2
- package/dist/compliance/reverse-index.js +4 -1
- package/dist/compliance/trace.d.ts +26 -0
- package/dist/compliance/trace.js +89 -0
- package/dist/compliance/types.d.ts +94 -1
- package/dist/constraint/__tests__/example.test.d.ts +1 -0
- package/dist/constraint/__tests__/example.test.js +14 -0
- package/dist/constraint/__tests__/validate.test.d.ts +1 -0
- package/dist/constraint/__tests__/validate.test.js +58 -0
- package/dist/constraint/index.d.ts +4 -0
- package/dist/constraint/index.js +2 -0
- package/dist/constraint/types.d.ts +4 -0
- package/dist/constraint/types.js +2 -0
- package/dist/constraint/validate.d.ts +7 -0
- package/dist/constraint/validate.js +115 -0
- package/dist/fgca/__tests__/parse-canonical.test.js +31 -0
- package/dist/fgca/parse-canonical.d.ts +1 -1
- package/dist/fgca/parse-canonical.js +18 -4
- package/dist/goals/GoalNode.d.ts +19 -0
- package/dist/goals/GoalNode.js +96 -0
- package/dist/goals/GoalTreeView.d.ts +59 -0
- package/dist/goals/GoalTreeView.js +245 -0
- package/dist/goals/__tests__/GoalTreeView.test.d.ts +1 -0
- package/dist/goals/__tests__/GoalTreeView.test.js +337 -0
- package/dist/goals/__tests__/mutations.test.js +39 -0
- package/dist/goals/__tests__/parse-canonical.test.js +2 -0
- package/dist/goals/index.d.ts +3 -0
- package/dist/goals/index.js +1 -0
- package/dist/goals/layout.js +2 -1
- package/dist/goals/mutations.js +6 -6
- package/dist/goals/parse-canonical.d.ts +5 -0
- package/dist/goals/parse-canonical.js +5 -0
- package/dist/goals/theme.d.ts +22 -0
- package/dist/goals/theme.js +32 -0
- package/dist/goals/types.d.ts +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/repo-validate/types.js +2 -3
- package/dist/webview/__tests__/render-goals.test.js +17 -3
- package/dist/webview/render-goals.js +2 -1
- package/package.json +4 -1
- package/src/capability-map/CapabilityCardNode.tsx +146 -0
- package/src/capability-map/CapabilityMapView.tsx +331 -0
- package/src/capability-map/CapabilityRootNode.tsx +38 -0
- package/src/capability-map/__tests__/CapabilityMapView.test.tsx +232 -0
- package/src/capability-map/__tests__/address.test.ts +59 -0
- package/src/capability-map/__tests__/dsm-layout.test.ts +52 -0
- package/src/capability-map/__tests__/dsm-mutations.test.ts +141 -0
- package/src/capability-map/__tests__/dsm-validate.test.ts +65 -0
- package/src/capability-map/address.ts +48 -0
- package/src/capability-map/dsm-layout.ts +142 -0
- package/src/capability-map/dsm-mutations.ts +208 -0
- package/src/capability-map/dsm-schema.ts +80 -0
- package/src/capability-map/dsm-theme.ts +53 -0
- package/src/capability-map/dsm-validate.ts +103 -0
- package/src/capability-map/index.ts +35 -0
- package/src/codex/__tests__/example.test.ts +40 -0
- package/src/codex/__tests__/validate.test.ts +84 -0
- package/src/codex/index.ts +4 -0
- package/src/codex/types.ts +13 -0
- package/src/codex/validate.ts +125 -0
- package/src/compliance/__tests__/canon-catalog.test.ts +68 -0
- package/src/compliance/__tests__/trace.test.ts +157 -0
- package/src/compliance/__tests__/view-resolution.test.ts +80 -0
- package/src/compliance/canon-catalog.ts +76 -0
- package/src/compliance/classify.ts +7 -1
- package/src/compliance/coverage-metric.ts +50 -0
- package/src/compliance/impact.ts +67 -1
- package/src/compliance/index.ts +16 -1
- package/src/compliance/reverse-index.ts +3 -1
- package/src/compliance/trace.ts +118 -0
- package/src/compliance/types.ts +101 -1
- package/src/constraint/__tests__/example.test.ts +19 -0
- package/src/constraint/__tests__/validate.test.ts +68 -0
- package/src/constraint/index.ts +4 -0
- package/src/constraint/types.ts +5 -0
- package/src/constraint/validate.ts +131 -0
- package/src/fgca/__tests__/parse-canonical.test.ts +34 -0
- package/src/fgca/parse-canonical.ts +17 -4
- package/src/goals/GoalNode.tsx +219 -0
- package/src/goals/GoalTreeView.tsx +384 -0
- package/src/goals/__tests__/GoalTreeView.test.tsx +361 -0
- package/src/goals/__tests__/mutations.test.ts +42 -0
- package/src/goals/__tests__/parse-canonical.test.ts +2 -0
- package/src/goals/index.ts +4 -0
- package/src/goals/layout.ts +2 -1
- package/src/goals/mutations.ts +6 -6
- package/src/goals/parse-canonical.ts +6 -0
- package/src/goals/theme.ts +54 -0
- package/src/goals/types.ts +2 -0
- package/src/index.ts +29 -1
- package/src/repo-validate/types.ts +2 -3
- package/src/webview/__tests__/render-goals.test.ts +17 -3
- package/src/webview/render-goals.ts +2 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { layoutCapabilityMap } from '../dsm-layout.js';
|
|
3
|
+
import type { CapabilityMap } from '../dsm-schema.js';
|
|
4
|
+
|
|
5
|
+
function map(): CapabilityMap {
|
|
6
|
+
return {
|
|
7
|
+
organisation: 'Acme Corp',
|
|
8
|
+
set_id: 'v1.0',
|
|
9
|
+
capabilities: [
|
|
10
|
+
{ id: 1, name: 'Customer Acquisition', address: '1.0.0' },
|
|
11
|
+
{ id: 2, name: 'Lead Qualification', address: '1.1.0' },
|
|
12
|
+
{ id: 3, name: 'Inbound Lead Scoring', address: '1.1.1' },
|
|
13
|
+
{ id: 4, name: 'Order Management', address: '2.0.0' },
|
|
14
|
+
{ id: 5, name: 'Parked idea', address: '0.0.0', backlog: true },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('layoutCapabilityMap', () => {
|
|
20
|
+
it('lays out a virtual root plus every on-diagram capability, one column per level', () => {
|
|
21
|
+
const layout = layoutCapabilityMap(map());
|
|
22
|
+
expect(layout.rootNode.data).toBeNull();
|
|
23
|
+
expect(layout.nodes).toHaveLength(4); // backlog entry excluded
|
|
24
|
+
const byId = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
25
|
+
expect(byId.get(1)!.x).toBe(byId.get(4)!.x); // both L1s share a column
|
|
26
|
+
expect(byId.get(2)!.x).toBeGreaterThan(byId.get(1)!.x); // L2 one column further right
|
|
27
|
+
expect(byId.get(3)!.x).toBeGreaterThan(byId.get(2)!.x); // L3 further still
|
|
28
|
+
expect(byId.get(1)!.x).toBeGreaterThan(layout.rootNode.x); // root sits left of the L1s
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('connects the root to every L1 and each parent to its children', () => {
|
|
32
|
+
const layout = layoutCapabilityMap(map());
|
|
33
|
+
const edgeSet = new Set(layout.edges.map((e) => `${e.source}->${e.target}`));
|
|
34
|
+
expect(edgeSet.has('root->1')).toBe(true);
|
|
35
|
+
expect(edgeSet.has('root->4')).toBe(true);
|
|
36
|
+
expect(edgeSet.has('1->2')).toBe(true);
|
|
37
|
+
expect(edgeSet.has('2->3')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks a node as having hidden children when its subtree is collapsed', () => {
|
|
41
|
+
const layout = layoutCapabilityMap(map(), { hideCollapsed: [2] });
|
|
42
|
+
const cap2 = layout.nodes.find((n) => n.id === 2)!;
|
|
43
|
+
expect(cap2.hasHiddenChildren).toBe(true);
|
|
44
|
+
expect(layout.nodes.some((n) => n.id === 3)).toBe(false); // hidden subtree omitted
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns just the root when there are no on-diagram capabilities', () => {
|
|
48
|
+
const layout = layoutCapabilityMap({ organisation: 'Empty Co', set_id: 'v1', capabilities: [] });
|
|
49
|
+
expect(layout.nodes).toHaveLength(0);
|
|
50
|
+
expect(layout.rootNode.data).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { reparent, addChild, deleteWithDescendants, moveBranchToBacklog, restoreFromBacklog, normaliseAddresses } from '../dsm-mutations.js';
|
|
3
|
+
import type { CapabilityMap } from '../dsm-schema.js';
|
|
4
|
+
|
|
5
|
+
function baseMap(): CapabilityMap {
|
|
6
|
+
return {
|
|
7
|
+
organisation: 'Acme Corp',
|
|
8
|
+
set_id: 'v1.0',
|
|
9
|
+
capabilities: [
|
|
10
|
+
{ id: 1, name: 'Customer Acquisition', address: '1.0.0' },
|
|
11
|
+
{ id: 2, name: 'Lead Qualification', address: '1.1.0' },
|
|
12
|
+
{ id: 3, name: 'Inbound Lead Scoring', address: '1.1.1' },
|
|
13
|
+
{ id: 4, name: 'Order Management', address: '2.0.0' },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('reparent', () => {
|
|
19
|
+
it('moves an L3 under a different L2 (no cascade)', () => {
|
|
20
|
+
const map = baseMap();
|
|
21
|
+
const r = reparent(map, 3, 4); // Inbound Lead Scoring (L3) -> Order Management (L1)... newLevel would be 2
|
|
22
|
+
expect(r.ok).toBe(true);
|
|
23
|
+
const moved = r.result!.capabilities.find((c) => c.id === 3)!;
|
|
24
|
+
expect(moved.address).toBe('2.1.0'); // now an L2 under capability 4
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('promotes an L2 to L1 and cascades its L3 children to L2 (spec §9)', () => {
|
|
28
|
+
const map = baseMap();
|
|
29
|
+
const r = reparent(map, 2, 0); // Lead Qualification (L2, id 2) -> virtual root
|
|
30
|
+
expect(r.ok).toBe(true);
|
|
31
|
+
const promoted = r.result!.capabilities.find((c) => c.id === 2)!;
|
|
32
|
+
const child = r.result!.capabilities.find((c) => c.id === 3)!;
|
|
33
|
+
expect(promoted.address).toBe('3.0.0'); // first free L1 slot (1 and 2 taken)
|
|
34
|
+
expect(child.address).toBe('3.1.0'); // cascaded from L3 to L2 under the new L1
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('refuses a demotion that would push a grandchild past L3', () => {
|
|
38
|
+
const map = baseMap();
|
|
39
|
+
// Capability 1 (L1) has descendant 2 (L2) and 3 (L3, a grandchild).
|
|
40
|
+
// Demoting 1 under 4 (making 1 an L2) would need 3 to become L4.
|
|
41
|
+
const r = reparent(map, 1, 4);
|
|
42
|
+
expect(r.ok).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('refuses reparenting onto its own descendant (cycle)', () => {
|
|
46
|
+
const map = baseMap();
|
|
47
|
+
const r = reparent(map, 2, 3); // Lead Qualification onto its own child
|
|
48
|
+
expect(r.ok).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('refuses reparenting onto itself', () => {
|
|
52
|
+
const map = baseMap();
|
|
53
|
+
expect(reparent(map, 1, 1).ok).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('leaves the original map untouched', () => {
|
|
57
|
+
const map = baseMap();
|
|
58
|
+
reparent(map, 3, 4);
|
|
59
|
+
expect(map.capabilities.find((c) => c.id === 3)!.address).toBe('1.1.1');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('addChild', () => {
|
|
64
|
+
it('assigns the first free address under the parent', () => {
|
|
65
|
+
const map = baseMap();
|
|
66
|
+
const r = addChild(map, 1, { name: 'New L2' });
|
|
67
|
+
expect(r.ok).toBe(true);
|
|
68
|
+
const added = r.result!.capabilities.find((c) => c.name === 'New L2')!;
|
|
69
|
+
expect(added.address).toBe('1.2.0');
|
|
70
|
+
expect(added.backlog).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('assigns a new L1 address when parentId is 0 (root)', () => {
|
|
74
|
+
const map = baseMap();
|
|
75
|
+
const r = addChild(map, 0, { name: 'New L1' });
|
|
76
|
+
expect(r.ok).toBe(true);
|
|
77
|
+
expect(r.result!.capabilities.find((c) => c.name === 'New L1')!.address).toBe('3.0.0');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('refuses adding a child under an L3 (would exceed max depth)', () => {
|
|
81
|
+
const map = baseMap();
|
|
82
|
+
const r = addChild(map, 3, { name: 'Too deep' });
|
|
83
|
+
expect(r.ok).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('deleteWithDescendants', () => {
|
|
88
|
+
it('removes the capability and every descendant', () => {
|
|
89
|
+
const map = baseMap();
|
|
90
|
+
const r = deleteWithDescendants(map, 2); // Lead Qualification + its L3 child
|
|
91
|
+
expect(r.ok).toBe(true);
|
|
92
|
+
const ids = r.result!.capabilities.map((c) => c.id);
|
|
93
|
+
expect(ids).not.toContain(2);
|
|
94
|
+
expect(ids).not.toContain(3);
|
|
95
|
+
expect(ids).toContain(1);
|
|
96
|
+
expect(ids).toContain(4);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('moveBranchToBacklog / restoreFromBacklog', () => {
|
|
101
|
+
it('moves a branch to the backlog and back', () => {
|
|
102
|
+
const map = baseMap();
|
|
103
|
+
const toBacklog = moveBranchToBacklog(map, 2);
|
|
104
|
+
expect(toBacklog.ok).toBe(true);
|
|
105
|
+
const backlogged = toBacklog.result!.capabilities.filter((c) => c.id === 2 || c.id === 3);
|
|
106
|
+
expect(backlogged.every((c) => c.backlog && c.address === '0.0.0')).toBe(true);
|
|
107
|
+
|
|
108
|
+
const restored = restoreFromBacklog(toBacklog.result!, 2, 4);
|
|
109
|
+
expect(restored.ok).toBe(true);
|
|
110
|
+
const cap2 = restored.result!.capabilities.find((c) => c.id === 2)!;
|
|
111
|
+
expect(cap2.backlog).toBe(false);
|
|
112
|
+
expect(cap2.address).toBe('2.1.0');
|
|
113
|
+
// Restoring the parent doesn't automatically restore its backlogged
|
|
114
|
+
// children — matches deleteWithDescendants/addChild's scope (this
|
|
115
|
+
// mutation acts on the single named id only).
|
|
116
|
+
expect(restored.result!.capabilities.find((c) => c.id === 3)!.backlog).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('normaliseAddresses', () => {
|
|
121
|
+
it('moves a capability with an unresolvable parent address to the backlog', () => {
|
|
122
|
+
const map: CapabilityMap = {
|
|
123
|
+
organisation: 'Acme Corp',
|
|
124
|
+
set_id: 'v1.0',
|
|
125
|
+
capabilities: [
|
|
126
|
+
{ id: 1, name: 'Customer Acquisition', address: '1.0.0' },
|
|
127
|
+
{ id: 2, name: 'Dangling L2', address: '9.1.0' }, // no capability at 9.0.0
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
const result = normaliseAddresses(map);
|
|
131
|
+
const dangling = result.capabilities.find((c) => c.id === 2)!;
|
|
132
|
+
expect(dangling.backlog).toBe(true);
|
|
133
|
+
expect(dangling.address).toBe('0.0.0');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('leaves a well-formed hierarchy unchanged', () => {
|
|
137
|
+
const map = baseMap();
|
|
138
|
+
const result = normaliseAddresses(map);
|
|
139
|
+
expect(result.capabilities).toEqual(map.capabilities);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateCapabilityMapData } from '../dsm-validate.js';
|
|
3
|
+
import type { CapabilityMap } from '../dsm-schema.js';
|
|
4
|
+
|
|
5
|
+
function validMap(): CapabilityMap {
|
|
6
|
+
return {
|
|
7
|
+
organisation: 'Acme Corp',
|
|
8
|
+
set_id: 'v1.0',
|
|
9
|
+
capabilities: [
|
|
10
|
+
{ id: 1, name: 'Customer Acquisition', address: '1.0.0' },
|
|
11
|
+
{ id: 2, name: 'Lead Qualification', address: '1.1.0' },
|
|
12
|
+
{ id: 3, name: 'Inbound Lead Scoring', address: '1.1.1' },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('validateCapabilityMapData', () => {
|
|
18
|
+
it('accepts a well-formed map', () => {
|
|
19
|
+
expect(validateCapabilityMapData(validMap()).valid).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('flags a duplicate id', () => {
|
|
23
|
+
const map = validMap();
|
|
24
|
+
map.capabilities[1].id = 1;
|
|
25
|
+
const result = validateCapabilityMapData(map);
|
|
26
|
+
expect(result.valid).toBe(false);
|
|
27
|
+
expect(result.errors.some((e) => e.code === 'DUPLICATE_ID')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('flags a malformed address', () => {
|
|
31
|
+
const map = validMap();
|
|
32
|
+
map.capabilities[0].address = 'not-an-address';
|
|
33
|
+
const result = validateCapabilityMapData(map);
|
|
34
|
+
expect(result.errors.some((e) => e.code === 'INVALID_ADDRESS_FORMAT')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('flags a too-deep address as MAX_DEPTH_EXCEEDED, not INVALID_ADDRESS_FORMAT', () => {
|
|
38
|
+
const map = validMap();
|
|
39
|
+
map.capabilities[0].address = '1.2.3.4';
|
|
40
|
+
const result = validateCapabilityMapData(map);
|
|
41
|
+
expect(result.errors.some((e) => e.code === 'MAX_DEPTH_EXCEEDED')).toBe(true);
|
|
42
|
+
expect(result.errors.some((e) => e.code === 'INVALID_ADDRESS_FORMAT')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('flags a duplicate on-diagram address', () => {
|
|
46
|
+
const map = validMap();
|
|
47
|
+
map.capabilities[1].address = '1.0.0';
|
|
48
|
+
const result = validateCapabilityMapData(map);
|
|
49
|
+
expect(result.errors.some((e) => e.code === 'DUPLICATE_ADDRESS')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('warns on a missing parent by address', () => {
|
|
53
|
+
const map = validMap();
|
|
54
|
+
map.capabilities[1].address = '9.1.0'; // no capability at 9.0.0
|
|
55
|
+
const result = validateCapabilityMapData(map);
|
|
56
|
+
expect(result.warnings.some((w) => w.code === 'MISSING_PARENT_BY_ADDRESS')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not flag a backlog entry\'s address as a duplicate or requiring a parent', () => {
|
|
60
|
+
const map = validMap();
|
|
61
|
+
map.capabilities.push({ id: 4, name: 'Parked idea', address: '0.0.0', backlog: true });
|
|
62
|
+
const result = validateCapabilityMapData(map);
|
|
63
|
+
expect(result.valid).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Capability } from './dsm-schema.js';
|
|
2
|
+
|
|
3
|
+
/** 'X.Y.Z' -> [X, Y, Z]. Throws on malformed input — callers on untrusted
|
|
4
|
+
* data should validate with validateCapabilityMapData first. */
|
|
5
|
+
export function parseAddress(address: string): [number, number, number] {
|
|
6
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(address.trim());
|
|
7
|
+
if (!m) throw new Error(`parseAddress: expected 'X.Y.Z', got "${address}"`);
|
|
8
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatAddress(triple: [number, number, number]): string {
|
|
12
|
+
return triple.join('.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** X.0.0 -> L1, X.Y.0 -> L2, X.Y.Z -> L3, 0.0.0 -> backlog. */
|
|
16
|
+
export function getLevel(address: string): 1 | 2 | 3 | 'backlog' {
|
|
17
|
+
const [x, y, z] = parseAddress(address);
|
|
18
|
+
if (x === 0) return 'backlog';
|
|
19
|
+
if (y === 0) return 1;
|
|
20
|
+
if (z === 0) return 2;
|
|
21
|
+
return 3;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** '1.2.3' -> '1.2.0'; '1.2.0' -> '1.0.0'; '1.0.0' -> null (L1 has no parent). */
|
|
25
|
+
export function getParentAddress(address: string): string | null {
|
|
26
|
+
const [x, y, z] = parseAddress(address);
|
|
27
|
+
if (z !== 0) return formatAddress([x, y, 0]);
|
|
28
|
+
if (y !== 0) return formatAddress([x, 0, 0]);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** First address under `parent` (an L1 or L2 address) not already taken. */
|
|
33
|
+
export function getFirstFreeAddress(parent: string, capabilities: Capability[]): string {
|
|
34
|
+
const [x, y] = parseAddress(parent);
|
|
35
|
+
const level = getLevel(parent);
|
|
36
|
+
if (level !== 1 && level !== 2) {
|
|
37
|
+
throw new Error(`getFirstFreeAddress: parent must be an L1 or L2 address, got "${parent}" (${level})`);
|
|
38
|
+
}
|
|
39
|
+
for (let n = 1; n < 1000; n++) {
|
|
40
|
+
const candidate = level === 1 ? formatAddress([x, n, 0]) : formatAddress([x, y, n]);
|
|
41
|
+
if (!isAddressTaken(candidate, capabilities)) return candidate;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`getFirstFreeAddress: no free address under "${parent}" (searched 1..999)`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isAddressTaken(address: string, capabilities: Capability[]): boolean {
|
|
47
|
+
return capabilities.some((c) => !c.backlog && c.address === address);
|
|
48
|
+
}
|
|
@@ -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
|
+
}
|