@transitrix/diagrams 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activities/__tests__/validate.test.js +32 -1
- package/dist/activities/cpm.js +11 -2
- package/dist/activities/gantt.js +6 -3
- package/dist/activities/types.d.ts +1 -0
- package/dist/activities/validate.js +24 -8
- package/dist/activity-card/__tests__/resolver.test.js +22 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/resolver.js +28 -20
- package/dist/activity-card/validate.js +22 -11
- package/dist/capability-map/index.d.ts +4 -0
- package/dist/capability-map/index.js +2 -0
- package/dist/capability-map/layout-tree.d.ts +42 -0
- package/dist/capability-map/layout-tree.js +65 -0
- package/dist/capability-map/render-capability-tree.d.ts +15 -0
- package/dist/capability-map/render-capability-tree.js +108 -0
- package/dist/capability-map/types.d.ts +2 -0
- package/dist/compliance/__tests__/impact.test.js +198 -0
- package/dist/compliance/html.js +1 -7
- package/dist/compliance/impact.d.ts +24 -1
- package/dist/compliance/impact.js +97 -21
- package/dist/factor/__tests__/validate.test.js +3 -3
- package/dist/factor/types.d.ts +1 -1
- package/dist/factor/validate.js +2 -2
- package/dist/fgca/__tests__/chain-table.test.js +2 -2
- package/dist/fgca/__tests__/layout.test.js +11 -11
- package/dist/fgca/__tests__/preview-layout.test.js +7 -7
- package/dist/fgca/__tests__/resolver.test.js +2 -2
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/chain-table.d.ts +1 -1
- package/dist/fgca/chain-table.js +4 -4
- package/dist/fgca/index.d.ts +2 -2
- package/dist/fgca/index.js +1 -1
- package/dist/fgca/layout.d.ts +2 -2
- package/dist/fgca/layout.js +9 -9
- package/dist/fgca/nodeTypes.d.ts +1 -1
- package/dist/fgca/nodeTypes.js +2 -2
- package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
- package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
- package/dist/fgca/parse-canonical.js +24 -13
- package/dist/fgca/preview-layout.d.ts +1 -1
- package/dist/fgca/preview-layout.js +4 -4
- package/dist/fgca/resolver.js +3 -1
- package/dist/fgca/types.d.ts +13 -11
- package/dist/fgca/types.js +2 -2
- package/dist/fgca/validate.d.ts +2 -2
- package/dist/fgca/validate.js +34 -23
- package/dist/process-blueprint/index.d.ts +1 -1
- package/dist/process-blueprint/layout.js +64 -37
- package/dist/process-blueprint/types.d.ts +14 -0
- package/dist/scenarios/index.d.ts +1 -1
- package/dist/scenarios/types.d.ts +4 -4
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +28 -3
- package/dist/theme/tokens.d.ts +47 -1
- package/dist/theme/tokens.js +31 -1
- package/dist/webview/__tests__/entry-notations.test.js +2 -0
- package/dist/webview/__tests__/render-process.test.d.ts +1 -0
- package/dist/webview/__tests__/render-process.test.js +463 -0
- package/dist/webview/__tests__/render-util.test.d.ts +1 -0
- package/dist/webview/__tests__/render-util.test.js +54 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +9 -5
- package/dist/webview/render-activities.d.ts +21 -1
- package/dist/webview/render-activities.js +91 -56
- package/dist/webview/render-activity-card.d.ts +11 -1
- package/dist/webview/render-activity-card.js +11 -2
- package/dist/webview/render-blocks.d.ts +24 -1
- package/dist/webview/render-blocks.js +58 -24
- package/dist/webview/render-fgca.d.ts +27 -0
- package/dist/webview/render-fgca.js +30 -16
- package/dist/webview/render-goals.d.ts +26 -1
- package/dist/webview/render-goals.js +30 -20
- package/dist/webview/render-process-blueprint.d.ts +11 -1
- package/dist/webview/render-process-blueprint.js +118 -53
- package/dist/webview/render-process.d.ts +95 -0
- package/dist/webview/render-process.js +392 -0
- package/dist/webview/render-scenarios.js +2 -2
- package/dist/webview/render-util.d.ts +19 -6
- package/dist/webview/render-util.js +23 -15
- package/package.json +15 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/cpm.ts +14 -2
- package/src/activities/gantt.ts +6 -5
- package/src/activities/types.ts +1 -0
- package/src/activities/validate.ts +24 -8
- package/src/activity-card/__tests__/resolver.test.ts +24 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/resolver.ts +29 -20
- package/src/activity-card/validate.ts +20 -11
- package/src/capability-map/index.ts +11 -0
- package/src/capability-map/layout-tree.ts +114 -0
- package/src/capability-map/render-capability-tree.ts +131 -0
- package/src/capability-map/types.ts +2 -0
- package/src/compliance/__tests__/impact.test.ts +219 -0
- package/src/compliance/html.ts +1 -8
- package/src/compliance/impact.ts +141 -23
- package/src/factor/__tests__/validate.test.ts +3 -3
- package/src/factor/types.ts +1 -1
- package/src/factor/validate.ts +2 -2
- package/src/fgca/__tests__/chain-table.test.ts +2 -2
- package/src/fgca/__tests__/layout.test.ts +13 -13
- package/src/fgca/__tests__/preview-layout.test.ts +7 -7
- package/src/fgca/__tests__/resolver.test.ts +2 -2
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/chain-table.ts +5 -5
- package/src/fgca/index.ts +3 -3
- package/src/fgca/layout.ts +11 -11
- package/src/fgca/nodeTypes.ts +3 -3
- package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
- package/src/fgca/parse-canonical.ts +26 -15
- package/src/fgca/preview-layout.ts +5 -5
- package/src/fgca/resolver.ts +3 -1
- package/src/fgca/types.ts +15 -13
- package/src/fgca/validate.ts +32 -21
- package/src/process-blueprint/index.ts +1 -0
- package/src/process-blueprint/layout.ts +71 -42
- package/src/process-blueprint/types.ts +15 -0
- package/src/scenarios/index.ts +2 -2
- package/src/scenarios/types.ts +4 -4
- package/src/scenarios/validate.ts +3 -3
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +29 -2
- package/src/theme/tokens.ts +33 -1
- package/src/webview/__tests__/entry-notations.test.ts +2 -0
- package/src/webview/__tests__/render-process.test.ts +538 -0
- package/src/webview/__tests__/render-util.test.ts +66 -0
- package/src/webview/entry.ts +11 -5
- package/src/webview/render-activities.ts +107 -67
- package/src/webview/render-activity-card.ts +11 -2
- package/src/webview/render-blocks.ts +78 -28
- package/src/webview/render-fgca.ts +53 -29
- package/src/webview/render-goals.ts +45 -23
- package/src/webview/render-process-blueprint.ts +156 -82
- package/src/webview/render-process.ts +507 -0
- package/src/webview/render-scenarios.ts +3 -3
- package/src/webview/render-util.ts +27 -14
|
@@ -12,9 +12,40 @@ const minimalValid = {
|
|
|
12
12
|
],
|
|
13
13
|
};
|
|
14
14
|
describe('validateActivities', () => {
|
|
15
|
-
it('ACT-001 — accepts
|
|
15
|
+
it('ACT-001 — accepts notation: activities (deprecated)', () => {
|
|
16
16
|
const r = validateActivities(minimalValid);
|
|
17
17
|
expect(r.valid).toBe(true);
|
|
18
|
+
// deprecated notation emits a warning, not an error
|
|
19
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('ACT-001 — accepts notation: action (canonical)', () => {
|
|
22
|
+
const r = validateActivities({
|
|
23
|
+
notation: 'action',
|
|
24
|
+
actions: [
|
|
25
|
+
{ id: 'ACTION-001', name: 'Start', duration: 3 },
|
|
26
|
+
{ id: 'ACTION-002', name: 'End', duration: 2, predecessors: ['ACTION-001'] },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
expect(r.valid).toBe(true);
|
|
30
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it('ACT-001 — accepts notation: action with deprecated activities: key (emits field warning)', () => {
|
|
33
|
+
const r = validateActivities({
|
|
34
|
+
notation: 'action',
|
|
35
|
+
activities: [{ id: 'ACTION-001', name: 'Foo', duration: 1 }],
|
|
36
|
+
});
|
|
37
|
+
expect(r.valid).toBe(true);
|
|
38
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_FIELD')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('ACT-001 — normalises action_type to activity_type for downstream layout', () => {
|
|
41
|
+
const input = {
|
|
42
|
+
notation: 'action',
|
|
43
|
+
actions: [{ id: 'ACTION-001', name: 'Foo', duration: 1, action_type: 'project' }],
|
|
44
|
+
};
|
|
45
|
+
const r = validateActivities(input);
|
|
46
|
+
expect(r.valid).toBe(true);
|
|
47
|
+
const entry = input.actions[0];
|
|
48
|
+
expect(entry['activity_type']).toBe('project');
|
|
18
49
|
});
|
|
19
50
|
it('ACT-001 — rejects missing notation', () => {
|
|
20
51
|
const r = validateActivities({ activities: [] });
|
package/dist/activities/cpm.js
CHANGED
|
@@ -2,6 +2,15 @@ export function computeCpm(activities) {
|
|
|
2
2
|
const result = new Map();
|
|
3
3
|
if (activities.length === 0)
|
|
4
4
|
return result;
|
|
5
|
+
// When no activity carries any duration data, critical path is indeterminate —
|
|
6
|
+
// return all non-critical so the diagram renders in default grey.
|
|
7
|
+
const hasDuration = activities.some((a) => (a.duration !== undefined && a.duration !== null) || (a.duration_days !== undefined && a.duration_days !== null));
|
|
8
|
+
if (!hasDuration) {
|
|
9
|
+
for (const a of activities) {
|
|
10
|
+
result.set(a.id, { es: 0, ef: 0, ls: 0, lf: 0, slack: 0, isCritical: false });
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
5
14
|
// Build successor map and in-degree for topological sort
|
|
6
15
|
const successors = new Map();
|
|
7
16
|
const predecessors = new Map();
|
|
@@ -41,7 +50,7 @@ export function computeCpm(activities) {
|
|
|
41
50
|
const ef = new Map();
|
|
42
51
|
for (const id of topoOrder) {
|
|
43
52
|
const a = actById.get(id);
|
|
44
|
-
const dur = a.duration ?? 0;
|
|
53
|
+
const dur = a.duration ?? a.duration_days ?? 0;
|
|
45
54
|
const maxPredEf = Math.max(0, ...(predecessors.get(id) ?? []).map(p => ef.get(p) ?? 0));
|
|
46
55
|
es.set(id, maxPredEf);
|
|
47
56
|
ef.set(id, maxPredEf + dur);
|
|
@@ -53,7 +62,7 @@ export function computeCpm(activities) {
|
|
|
53
62
|
const lf = new Map();
|
|
54
63
|
for (const id of [...topoOrder].reverse()) {
|
|
55
64
|
const a = actById.get(id);
|
|
56
|
-
const dur = a.duration ?? 0;
|
|
65
|
+
const dur = a.duration ?? a.duration_days ?? 0;
|
|
57
66
|
const succs = successors.get(id) ?? [];
|
|
58
67
|
const minSuccLs = succs.length === 0 ? projectFinish : Math.min(...succs.map(s => ls.get(s) ?? projectFinish));
|
|
59
68
|
lf.set(id, minSuccLs);
|
package/dist/activities/gantt.js
CHANGED
|
@@ -86,7 +86,10 @@ function detectMode(doc, leaves) {
|
|
|
86
86
|
if (allPinned)
|
|
87
87
|
return 'pinned';
|
|
88
88
|
const hasProjectStart = typeof doc.project?.start_date === 'string' && doc.project.start_date.length > 0;
|
|
89
|
-
const allHaveDuration = leaves.every((a) =>
|
|
89
|
+
const allHaveDuration = leaves.every((a) => {
|
|
90
|
+
const d = a.duration ?? a.duration_days;
|
|
91
|
+
return typeof d === 'number' && Number.isFinite(d) && d >= 0;
|
|
92
|
+
});
|
|
90
93
|
if (hasProjectStart && allHaveDuration)
|
|
91
94
|
return 'computed';
|
|
92
95
|
return null;
|
|
@@ -111,7 +114,7 @@ export function computeGanttLayout(doc) {
|
|
|
111
114
|
for (const a of leaves) {
|
|
112
115
|
const startDate = a.start_date;
|
|
113
116
|
const endDate = a.end_date;
|
|
114
|
-
const isMilestone = a.duration === 0 || startDate === endDate;
|
|
117
|
+
const isMilestone = (a.duration ?? a.duration_days) === 0 || startDate === endDate;
|
|
115
118
|
const bar = {
|
|
116
119
|
id: a.id,
|
|
117
120
|
name: a.name,
|
|
@@ -134,7 +137,7 @@ export function computeGanttLayout(doc) {
|
|
|
134
137
|
for (const a of leaves) {
|
|
135
138
|
const c = cpm.get(a.id);
|
|
136
139
|
const es = c?.es ?? 0;
|
|
137
|
-
const dur = a.duration ?? 0;
|
|
140
|
+
const dur = a.duration ?? a.duration_days ?? 0;
|
|
138
141
|
const startDate = nthWorkingDay(projectStart, es, calendar);
|
|
139
142
|
const endDate = dur === 0
|
|
140
143
|
? startDate
|
|
@@ -7,19 +7,31 @@ export function validateActivities(input) {
|
|
|
7
7
|
return { valid: false, errors: [{ code: 'ACT-001', message: 'Input must be an object' }], warnings };
|
|
8
8
|
}
|
|
9
9
|
const raw = input;
|
|
10
|
-
// ACT-001: notation must
|
|
10
|
+
// ACT-001: notation must be "action" (canonical) or "activities" (deprecated)
|
|
11
11
|
if (raw.notation === undefined) {
|
|
12
12
|
errors.push({ code: 'ACT-001', message: 'notation field is required' });
|
|
13
13
|
return { valid: false, errors, warnings };
|
|
14
14
|
}
|
|
15
|
-
if (raw.notation !== 'activities') {
|
|
16
|
-
errors.push({ code: 'ACT-001', message: `notation must be "
|
|
15
|
+
if (raw.notation !== 'action' && raw.notation !== 'activities') {
|
|
16
|
+
errors.push({ code: 'ACT-001', message: `notation must be "action", got "${String(raw.notation)}"` });
|
|
17
17
|
return { valid: false, errors, warnings };
|
|
18
18
|
}
|
|
19
|
-
if (
|
|
20
|
-
|
|
19
|
+
if (raw.notation === 'activities') {
|
|
20
|
+
warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activities" is deprecated — migrate to notation: "action"' });
|
|
21
|
+
}
|
|
22
|
+
// Accept "actions:" (canonical) or "activities:" (deprecated)
|
|
23
|
+
const rawArray = Array.isArray(raw.actions) ? raw.actions : (Array.isArray(raw.activities) ? raw.activities : undefined);
|
|
24
|
+
if (Array.isArray(raw.activities) && !Array.isArray(raw.actions)) {
|
|
25
|
+
warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activities:" is deprecated — rename to "actions:"' });
|
|
26
|
+
}
|
|
27
|
+
if (!rawArray) {
|
|
28
|
+
const canonKey = raw.notation === 'action' ? 'actions' : 'activities';
|
|
29
|
+
errors.push({ code: 'SCHEMA_INVALID', message: `${canonKey} must be an array`, path: canonKey });
|
|
21
30
|
return { valid: false, errors, warnings };
|
|
22
31
|
}
|
|
32
|
+
// Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
|
|
33
|
+
if (!Array.isArray(raw.activities))
|
|
34
|
+
raw.activities = raw.actions;
|
|
23
35
|
// ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
|
|
24
36
|
// surfaces clearly even when activities also have issues).
|
|
25
37
|
if (raw.project && typeof raw.project === 'object') {
|
|
@@ -85,6 +97,10 @@ export function validateActivities(input) {
|
|
|
85
97
|
continue;
|
|
86
98
|
}
|
|
87
99
|
const act = a;
|
|
100
|
+
// Normalise action_type → activity_type (canonical rename; old name kept for compat)
|
|
101
|
+
if (act['action_type'] !== undefined && act['activity_type'] === undefined) {
|
|
102
|
+
act['activity_type'] = act['action_type'];
|
|
103
|
+
}
|
|
88
104
|
// ACT-010: reject single-value forms
|
|
89
105
|
if ('goal' in act) {
|
|
90
106
|
errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
|
|
@@ -113,7 +129,7 @@ export function validateActivities(input) {
|
|
|
113
129
|
idSet.add(id);
|
|
114
130
|
}
|
|
115
131
|
// ACT-009: non-negative numeric fields
|
|
116
|
-
const numericFields = ['duration', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'];
|
|
132
|
+
const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'];
|
|
117
133
|
for (const field of numericFields) {
|
|
118
134
|
const val = act[field];
|
|
119
135
|
if (val !== undefined && val !== null) {
|
|
@@ -122,8 +138,8 @@ export function validateActivities(input) {
|
|
|
122
138
|
}
|
|
123
139
|
}
|
|
124
140
|
}
|
|
125
|
-
// ACT-011: warn if no duration
|
|
126
|
-
if (act.duration === undefined || act.duration === null) {
|
|
141
|
+
// ACT-011: warn if no duration (duration_days is accepted as an alias)
|
|
142
|
+
if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
|
|
127
143
|
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
|
|
128
144
|
}
|
|
129
145
|
}
|
|
@@ -38,7 +38,7 @@ const UNRELATED = {
|
|
|
38
38
|
name: 'Other',
|
|
39
39
|
parent: 'ACTIVITY-SOMETHING-ELSE-1',
|
|
40
40
|
};
|
|
41
|
-
const FACTOR = { notation: '
|
|
41
|
+
const FACTOR = { notation: 'driver', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
|
|
42
42
|
const GOAL = { notation: 'goal', id: 'GOAL-EU-MARKET-1', name: 'Access EU market', factors: ['FACTOR-EU-MDR-1'] };
|
|
43
43
|
const CHANGE = {
|
|
44
44
|
notation: 'change',
|
|
@@ -212,6 +212,27 @@ describe('resolveActivityCard', () => {
|
|
|
212
212
|
expect(r.valid).toBe(false);
|
|
213
213
|
expect(r.errors.some((e) => e.code === 'PC-001')).toBe(true);
|
|
214
214
|
});
|
|
215
|
+
it('PC-001 diagnostic discloses paths searched and actionable hint (#344)', () => {
|
|
216
|
+
const r = resolveActivityCard(CARD, { elements: [FACTOR, GOAL, CHANGE], relations });
|
|
217
|
+
const pc001 = r.errors.find((e) => e.code === 'PC-001');
|
|
218
|
+
expect(pc001).toBeDefined();
|
|
219
|
+
// Must disclose both searched paths (§7 PC-001)
|
|
220
|
+
expect(pc001.message).toContain('canon/elements/**');
|
|
221
|
+
expect(pc001.message).toContain('canon/views/activities/**');
|
|
222
|
+
// Must include actionable hint with expected file pattern (§7 PC-001)
|
|
223
|
+
expect(pc001.message).toContain('notation: action');
|
|
224
|
+
expect(pc001.message).toContain(`id: "${CARD.activity_card.project}"`);
|
|
225
|
+
});
|
|
226
|
+
it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
|
|
227
|
+
// Regression: the resolver must accept elements regardless of which subfolder
|
|
228
|
+
// they were loaded from (e.g. canon/elements/05_implementation/activities/).
|
|
229
|
+
// The resolver is filesystem-free, so we simulate the deep-path scenario by
|
|
230
|
+
// including the PROJECT element in the elements pool — just as loadCanon does
|
|
231
|
+
// after walking canon/elements/** recursively.
|
|
232
|
+
const r = resolveActivityCard(CARD, sources);
|
|
233
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
234
|
+
expect(r.errors.some((e) => e.code === 'PC-001')).toBe(false);
|
|
235
|
+
});
|
|
215
236
|
it('resolves activity_type and status from the Activity element', () => {
|
|
216
237
|
const r = resolveActivityCard(CARD, {
|
|
217
238
|
elements: [{ ...PROJECT, activity_type: 'programme', status: 'on_track' }, CHILD, FACTOR, GOAL, CHANGE],
|
|
@@ -18,10 +18,32 @@ const VALID = {
|
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
describe('validateActivityCard', () => {
|
|
21
|
-
it('accepts a well-formed card', () => {
|
|
21
|
+
it('accepts a well-formed card (deprecated notation: activity-card)', () => {
|
|
22
22
|
const r = validateActivityCard(VALID);
|
|
23
23
|
expect(r.valid).toBe(true);
|
|
24
24
|
expect(r.errors).toHaveLength(0);
|
|
25
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it('accepts notation: action-card with action_card block (canonical)', () => {
|
|
28
|
+
const r = validateActivityCard({
|
|
29
|
+
notation: 'action-card',
|
|
30
|
+
action_card: {
|
|
31
|
+
id: 'ACTION_CARD-EU-PROGRAMME-1',
|
|
32
|
+
project: 'ACTION-EU-PROGRAMME-1',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(r.valid).toBe(true);
|
|
36
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('accepts action_card block with legacy ACTIVITY_CARD id prefix', () => {
|
|
39
|
+
const r = validateActivityCard({
|
|
40
|
+
notation: 'action-card',
|
|
41
|
+
action_card: {
|
|
42
|
+
id: 'ACTIVITY_CARD-EU-PROGRAMME-1',
|
|
43
|
+
project: 'ACTION-EU-PROGRAMME-1',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
expect(r.valid).toBe(true);
|
|
25
47
|
});
|
|
26
48
|
it('rejects non-object input', () => {
|
|
27
49
|
expect(validateActivityCard('x').valid).toBe(false);
|
|
@@ -81,9 +81,9 @@ function collectByNotation(docs, notation) {
|
|
|
81
81
|
return out;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
* GOAL ids reached by an *active* `
|
|
85
|
-
* `fromId`. A relation with a non-null `valid_to` has
|
|
86
|
-
* re-aimed) and is excluded
|
|
84
|
+
* GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
|
|
85
|
+
* relation originating at `fromId`. A relation with a non-null `valid_to` has
|
|
86
|
+
* ended (the activity was re-aimed) and is excluded.
|
|
87
87
|
*/
|
|
88
88
|
function activeActivityGoals(relations, fromId) {
|
|
89
89
|
const out = [];
|
|
@@ -92,7 +92,8 @@ function activeActivityGoals(relations, fromId) {
|
|
|
92
92
|
continue;
|
|
93
93
|
if (str(doc['notation']) !== 'relation')
|
|
94
94
|
continue;
|
|
95
|
-
|
|
95
|
+
const type = str(doc['type']);
|
|
96
|
+
if (type !== 'action_goal' && type !== 'activity_goal')
|
|
96
97
|
continue;
|
|
97
98
|
if (str(doc['from']) !== fromId)
|
|
98
99
|
continue;
|
|
@@ -108,11 +109,8 @@ function activeActivityGoals(relations, fromId) {
|
|
|
108
109
|
return out;
|
|
109
110
|
}
|
|
110
111
|
/**
|
|
111
|
-
* STAKEHOLDER ids + roles reached by an *active* `
|
|
112
|
-
* relation originating at `fromId`.
|
|
113
|
-
* ended and is excluded, so the card shows the project's currently-engaged
|
|
114
|
-
* stakeholders. The optional `role` field on the relation carries the
|
|
115
|
-
* project-role (initiator | owner | sponsor | project_manager).
|
|
112
|
+
* STAKEHOLDER ids + roles reached by an *active* `action_stakeholder` (or
|
|
113
|
+
* legacy `activity_stakeholder`) relation originating at `fromId`.
|
|
116
114
|
*/
|
|
117
115
|
function activeActivityStakeholders(relations, fromId) {
|
|
118
116
|
const out = [];
|
|
@@ -121,7 +119,8 @@ function activeActivityStakeholders(relations, fromId) {
|
|
|
121
119
|
continue;
|
|
122
120
|
if (str(doc['notation']) !== 'relation')
|
|
123
121
|
continue;
|
|
124
|
-
|
|
122
|
+
const type = str(doc['type']);
|
|
123
|
+
if (type !== 'action_stakeholder' && type !== 'activity_stakeholder')
|
|
125
124
|
continue;
|
|
126
125
|
if (str(doc['from']) !== fromId)
|
|
127
126
|
continue;
|
|
@@ -146,13 +145,20 @@ export function resolveActivityCard(doc, sources) {
|
|
|
146
145
|
errors.push({ code: 'PC-001', message: 'activity_card.project is missing' });
|
|
147
146
|
return { valid: false, errors, warnings };
|
|
148
147
|
}
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
148
|
+
// Look up by canonical `notation: action` first, then legacy `notation: activity`.
|
|
149
|
+
const actionElements = collectByNotation(sources.elements, 'action');
|
|
150
|
+
const activityElements = collectByNotation(sources.elements, 'activity');
|
|
151
|
+
const allActionElements = new Map([...activityElements, ...actionElements]); // action wins on duplicate id
|
|
152
|
+
const projectRec = allActionElements.get(projectId);
|
|
153
|
+
// PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
|
|
154
|
+
// element after exhausting the canonical resolution scope (§6.1).
|
|
152
155
|
if (!projectRec) {
|
|
153
156
|
errors.push({
|
|
154
157
|
code: 'PC-001',
|
|
155
|
-
message: `
|
|
158
|
+
message: `action_card.project "${projectId}" not found after searching ` +
|
|
159
|
+
`canon/elements/** and canon/views/activities/** — ` +
|
|
160
|
+
`add a YAML file with notation: action and id: "${projectId}" ` +
|
|
161
|
+
`(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
|
|
156
162
|
});
|
|
157
163
|
return { valid: false, errors, warnings };
|
|
158
164
|
}
|
|
@@ -247,9 +253,10 @@ export function resolveActivityCard(doc, sources) {
|
|
|
247
253
|
return { valid: false, errors, warnings };
|
|
248
254
|
}
|
|
249
255
|
// ── Motivation chain — expand against canon DRIVER / GOAL / CHANGE elements ──
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
const
|
|
256
|
+
// Canon elements use `notation: driver`; `notation: factor` accepted for backward compat.
|
|
257
|
+
const driverElemsLegacy = collectByNotation(sources.elements, 'factor');
|
|
258
|
+
const driverElemsNew = collectByNotation(sources.elements, 'driver');
|
|
259
|
+
const driverElems = new Map([...driverElemsLegacy, ...driverElemsNew]);
|
|
253
260
|
const goalElems = collectByNotation(sources.elements, 'goal');
|
|
254
261
|
const changeElems = collectByNotation(sources.elements, 'change');
|
|
255
262
|
const changes = [];
|
|
@@ -322,14 +329,15 @@ export function resolveActivityCard(doc, sources) {
|
|
|
322
329
|
return a.observed_at < b.observed_at ? -1 : a.observed_at > b.observed_at ? 1 : 0;
|
|
323
330
|
});
|
|
324
331
|
// ── Child activities — parent = project id ──────────────────────────────────
|
|
332
|
+
// Look in both `notation: action` and legacy `notation: activity` elements.
|
|
325
333
|
const childActivities = [];
|
|
326
|
-
for (const [id, rec] of
|
|
334
|
+
for (const [id, rec] of allActionElements) {
|
|
327
335
|
if (str(rec['parent']) !== projectId)
|
|
328
336
|
continue;
|
|
329
337
|
childActivities.push({
|
|
330
338
|
id,
|
|
331
339
|
name: str(rec['name']) ?? id,
|
|
332
|
-
activity_type: str(rec['activity_type']),
|
|
340
|
+
activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
|
|
333
341
|
start_date: str(rec['start_date']),
|
|
334
342
|
end_date: str(rec['end_date']),
|
|
335
343
|
owner: str(rec['owner']),
|
|
@@ -372,7 +380,7 @@ export function resolveActivityCard(doc, sources) {
|
|
|
372
380
|
valid_to: validToOk ? validToRaw : undefined,
|
|
373
381
|
start_date: str(projectRec['start_date']),
|
|
374
382
|
end_date: str(projectRec['end_date']),
|
|
375
|
-
activity_type: str(projectRec['activity_type']),
|
|
383
|
+
activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
|
|
376
384
|
status: str(projectRec['status']),
|
|
377
385
|
};
|
|
378
386
|
const resolved = {
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
// HDR-002 wrong `notation` (CONTRACT.md §2)
|
|
22
22
|
// PC-001 `project` missing or malformed (the document-local half; the
|
|
23
23
|
// "does not resolve" half is emitted by the resolver)
|
|
24
|
-
const ACTIVITY_CARD_ID_RE = /^ACTIVITY_CARD(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
25
|
-
const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
24
|
+
const ACTIVITY_CARD_ID_RE = /^(ACTIVITY_CARD|ACTION_CARD)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
25
|
+
const ACTIVITY_ID_RE = /^(ACTIVITY|ACTION)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
26
26
|
const MILESTONE_ID_RE = /^MILESTONE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
27
27
|
const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
28
28
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -40,36 +40,47 @@ export function validateActivityCard(input) {
|
|
|
40
40
|
if (!('notation' in raw)) {
|
|
41
41
|
errors.push({ code: 'HDR-001', message: 'notation field is required' });
|
|
42
42
|
}
|
|
43
|
-
else if (raw['notation'] !== 'activity-card') {
|
|
43
|
+
else if (raw['notation'] !== 'action-card' && raw['notation'] !== 'activity-card') {
|
|
44
44
|
errors.push({
|
|
45
45
|
code: 'HDR-002',
|
|
46
|
-
message: `notation must be "
|
|
46
|
+
message: `notation must be "action-card", got "${String(raw['notation'])}"`,
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
else if (raw['notation'] === 'activity-card') {
|
|
50
|
+
warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activity-card" is deprecated — migrate to notation: "action-card"' });
|
|
51
|
+
}
|
|
52
|
+
// ── action_card / activity_card block ───────────────────────────────────────
|
|
53
|
+
// "action_card:" is canonical; "activity_card:" is accepted for compat.
|
|
54
|
+
const rawBlock = raw['action_card'] ?? raw['activity_card'];
|
|
55
|
+
if (raw['activity_card'] !== undefined && raw['action_card'] === undefined) {
|
|
56
|
+
warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activity_card:" is deprecated — rename to "action_card:"' });
|
|
57
|
+
}
|
|
58
|
+
const block = rawBlock;
|
|
51
59
|
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
52
|
-
errors.push({ code: 'AC-001', message: 'Missing required root key:
|
|
60
|
+
errors.push({ code: 'AC-001', message: 'Missing required root key: action_card (object)' });
|
|
53
61
|
return { valid: false, errors, warnings };
|
|
54
62
|
}
|
|
63
|
+
// Normalise onto activity_card for downstream (resolver reads doc.activity_card)
|
|
64
|
+
if (raw['activity_card'] === undefined)
|
|
65
|
+
raw['activity_card'] = rawBlock;
|
|
55
66
|
const card = block;
|
|
56
67
|
if (!isNonEmptyString(card['id'])) {
|
|
57
|
-
errors.push({ code: 'AC-002', message: '
|
|
68
|
+
errors.push({ code: 'AC-002', message: 'action_card.id is required' });
|
|
58
69
|
}
|
|
59
70
|
else if (!ACTIVITY_CARD_ID_RE.test(card['id'])) {
|
|
60
71
|
errors.push({
|
|
61
72
|
code: 'AC-002',
|
|
62
|
-
message: `
|
|
73
|
+
message: `action_card.id "${card['id']}" must match ACTION_CARD-[<middle>-]<INTEGER> (or legacy ACTIVITY_CARD-…)`,
|
|
63
74
|
});
|
|
64
75
|
}
|
|
65
76
|
// PC-001 (document-local half): project present + well-formed.
|
|
66
77
|
if (!isNonEmptyString(card['project'])) {
|
|
67
|
-
errors.push({ code: 'PC-001', message: '
|
|
78
|
+
errors.push({ code: 'PC-001', message: 'action_card.project is required (an ACTION-… id)' });
|
|
68
79
|
}
|
|
69
80
|
else if (!ACTIVITY_ID_RE.test(card['project'])) {
|
|
70
81
|
errors.push({
|
|
71
82
|
code: 'PC-001',
|
|
72
|
-
message: `
|
|
83
|
+
message: `action_card.project "${card['project']}" is malformed; must match ACTION-[<middle>-]<INTEGER> (or legacy ACTIVITY-…)`,
|
|
73
84
|
});
|
|
74
85
|
}
|
|
75
86
|
if (card['description'] !== undefined && typeof card['description'] !== 'string') {
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export type { CapabilityType, CapabilityNode, CapabilityMapHeader, CapabilityMapFile, } from './types.js';
|
|
2
2
|
export { validateCapabilityMap } from './validate.js';
|
|
3
|
+
export type { CapabilityTreeNode, CapabilityTreeEdge, CapabilityTreeLevelCounts, CapabilityTreeLayout, } from './layout-tree.js';
|
|
4
|
+
export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
|
|
5
|
+
export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
|
|
6
|
+
export { renderCapabilityTreeSvg } from './render-capability-tree.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CapabilityNode, CapabilityMapHeader } from './types.js';
|
|
2
|
+
export declare const TREE_NODE_WIDTH = 250;
|
|
3
|
+
export declare const TREE_NODE_HEIGHT = 64;
|
|
4
|
+
/** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
|
|
5
|
+
export declare const TREE_RANK_SEP = 80;
|
|
6
|
+
/** Vertical gap between sibling nodes. */
|
|
7
|
+
export declare const TREE_NODE_SEP = 24;
|
|
8
|
+
export interface CapabilityTreeNode {
|
|
9
|
+
id: string;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
data: CapabilityNode;
|
|
15
|
+
depth: number;
|
|
16
|
+
hasChildren: boolean;
|
|
17
|
+
isCollapsed: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface CapabilityTreeEdge {
|
|
20
|
+
source: string;
|
|
21
|
+
target: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CapabilityTreeLevelCounts {
|
|
24
|
+
/** Nodes at depth 0–2 (pink band). */
|
|
25
|
+
band0: number;
|
|
26
|
+
/** Nodes at depth 3–4 (yellow band). */
|
|
27
|
+
band1: number;
|
|
28
|
+
/** Nodes at depth 5+ (blue band). */
|
|
29
|
+
band2: number;
|
|
30
|
+
}
|
|
31
|
+
export interface CapabilityTreeLayout {
|
|
32
|
+
nodes: CapabilityTreeNode[];
|
|
33
|
+
edges: CapabilityTreeEdge[];
|
|
34
|
+
bounds: {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
};
|
|
40
|
+
levelCounts: CapabilityTreeLevelCounts;
|
|
41
|
+
}
|
|
42
|
+
export declare function layoutCapabilityTree(map: CapabilityMapHeader, collapsedIds?: Set<string>): CapabilityTreeLayout;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const TREE_NODE_WIDTH = 250;
|
|
2
|
+
export const TREE_NODE_HEIGHT = 64;
|
|
3
|
+
/** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
|
|
4
|
+
export const TREE_RANK_SEP = 80;
|
|
5
|
+
/** Vertical gap between sibling nodes. */
|
|
6
|
+
export const TREE_NODE_SEP = 24;
|
|
7
|
+
export function layoutCapabilityTree(map, collapsedIds = new Set()) {
|
|
8
|
+
const nodes = [];
|
|
9
|
+
const edges = [];
|
|
10
|
+
const colNextY = new Map();
|
|
11
|
+
function getNextY(col) {
|
|
12
|
+
return colNextY.get(col) ?? 0;
|
|
13
|
+
}
|
|
14
|
+
function advanceY(col, delta) {
|
|
15
|
+
colNextY.set(col, (colNextY.get(col) ?? 0) + delta);
|
|
16
|
+
}
|
|
17
|
+
function placeNode(node, depth) {
|
|
18
|
+
const isCollapsed = collapsedIds.has(node.id);
|
|
19
|
+
const hasChildren = Boolean(node.children?.length);
|
|
20
|
+
const visibleChildren = !isCollapsed && node.children?.length ? node.children : [];
|
|
21
|
+
const x = depth * (TREE_NODE_WIDTH + TREE_RANK_SEP);
|
|
22
|
+
if (visibleChildren.length === 0) {
|
|
23
|
+
const y = getNextY(depth);
|
|
24
|
+
advanceY(depth, TREE_NODE_HEIGHT + TREE_NODE_SEP);
|
|
25
|
+
nodes.push({ id: node.id, x, y, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
|
|
26
|
+
return { top: y, bottom: y + TREE_NODE_HEIGHT };
|
|
27
|
+
}
|
|
28
|
+
const childSpans = [];
|
|
29
|
+
for (const child of visibleChildren) {
|
|
30
|
+
childSpans.push(placeNode(child, depth + 1));
|
|
31
|
+
edges.push({ source: node.id, target: child.id });
|
|
32
|
+
}
|
|
33
|
+
const spanTop = Math.min(...childSpans.map(s => s.top));
|
|
34
|
+
const spanBottom = Math.max(...childSpans.map(s => s.bottom));
|
|
35
|
+
const parentY = spanTop + (spanBottom - spanTop) / 2 - TREE_NODE_HEIGHT / 2;
|
|
36
|
+
const curColY = getNextY(depth);
|
|
37
|
+
const finalY = Math.max(parentY, curColY);
|
|
38
|
+
advanceY(depth, finalY - curColY + TREE_NODE_HEIGHT + TREE_NODE_SEP);
|
|
39
|
+
nodes.push({ id: node.id, x, y: finalY, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
|
|
40
|
+
return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + TREE_NODE_HEIGHT, spanBottom) };
|
|
41
|
+
}
|
|
42
|
+
for (const root of map.capabilities) {
|
|
43
|
+
placeNode(root, 0);
|
|
44
|
+
for (const [col, y] of colNextY) {
|
|
45
|
+
colNextY.set(col, y + TREE_NODE_SEP);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (nodes.length === 0) {
|
|
49
|
+
return { nodes: [], edges: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, levelCounts: { band0: 0, band1: 0, band2: 0 } };
|
|
50
|
+
}
|
|
51
|
+
const minX = Math.min(...nodes.map(n => n.x));
|
|
52
|
+
const minY = Math.min(...nodes.map(n => n.y));
|
|
53
|
+
const maxX = Math.max(...nodes.map(n => n.x + n.width));
|
|
54
|
+
const maxY = Math.max(...nodes.map(n => n.y + n.height));
|
|
55
|
+
const levelCounts = { band0: 0, band1: 0, band2: 0 };
|
|
56
|
+
for (const n of nodes) {
|
|
57
|
+
if (n.depth <= 2)
|
|
58
|
+
levelCounts.band0++;
|
|
59
|
+
else if (n.depth <= 4)
|
|
60
|
+
levelCounts.band1++;
|
|
61
|
+
else
|
|
62
|
+
levelCounts.band2++;
|
|
63
|
+
}
|
|
64
|
+
return { nodes, edges, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, levelCounts };
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CapabilityMapHeader } from './types.js';
|
|
2
|
+
export interface RenderCapabilityTreeOptions {
|
|
3
|
+
collapsedIds?: Set<string>;
|
|
4
|
+
curvature?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Renders a capability-map as a left-to-right SVG node tree with DSM-matching
|
|
8
|
+
* visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
|
|
9
|
+
*
|
|
10
|
+
* The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
|
|
11
|
+
* that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
|
|
12
|
+
* in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
|
|
13
|
+
* for the webview click handler.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderCapabilityTreeSvg(map: CapabilityMapHeader, opts?: RenderCapabilityTreeOptions): string;
|