@transitrix/diagrams 1.1.0 → 1.3.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/validate.js +21 -5
- package/dist/activity-card/__tests__/example.test.js +1 -3
- package/dist/activity-card/__tests__/layout.test.js +2 -9
- package/dist/activity-card/__tests__/resolver.test.js +26 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/layout.js +1 -7
- package/dist/activity-card/resolver.js +34 -23
- package/dist/activity-card/types.d.ts +1 -1
- 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/fgca/__tests__/parse-canonical.test.js +14 -1
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/parse-canonical.js +20 -24
- package/dist/fgca/preview-layout.js +50 -1
- package/dist/fgca/types.d.ts +2 -0
- package/dist/fgca/validate.js +19 -11
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +26 -3
- package/dist/theme/tokens.d.ts +40 -0
- package/dist/theme/tokens.js +28 -0
- package/dist/webview/render-activity-card.js +2 -3
- package/dist/webview/render-fgca.js +2 -2
- package/dist/webview/render-goals.js +28 -5
- package/package.json +1 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/validate.ts +21 -5
- package/src/activity-card/__tests__/example.test.ts +1 -3
- package/src/activity-card/__tests__/layout.test.ts +2 -9
- package/src/activity-card/__tests__/resolver.test.ts +28 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/layout.ts +1 -8
- package/src/activity-card/resolver.ts +34 -23
- package/src/activity-card/types.ts +1 -1
- 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/fgca/__tests__/parse-canonical.test.ts +15 -1
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/parse-canonical.ts +20 -30
- package/src/fgca/preview-layout.ts +53 -1
- package/src/fgca/types.ts +2 -0
- package/src/fgca/validate.ts +16 -8
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +27 -2
- package/src/theme/tokens.ts +30 -0
- package/src/webview/render-activity-card.ts +2 -3
- package/src/webview/render-fgca.ts +2 -2
- package/src/webview/render-goals.ts +26 -5
|
@@ -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: [] });
|
|
@@ -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 });
|
|
@@ -61,13 +61,11 @@ describe('activity-card worked example (eu-programme)', () => {
|
|
|
61
61
|
// Description row
|
|
62
62
|
expect(layout.descriptionRow?.label).toBe('Description');
|
|
63
63
|
// Chain sections
|
|
64
|
-
expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', '
|
|
64
|
+
expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
|
|
65
65
|
const driversSection = layout.chainSections.find((s) => s.type === 'drivers');
|
|
66
66
|
const goalsSection = layout.chainSections.find((s) => s.type === 'goals');
|
|
67
|
-
const assessmentsSection = layout.chainSections.find((s) => s.type === 'assessments');
|
|
68
67
|
expect(driversSection.nodes.map((n) => n.id)).toContain('FACTOR-EU-MDR-1');
|
|
69
68
|
expect(goalsSection.nodes.map((n) => n.id)).toContain('GOAL-EU-MARKET-1');
|
|
70
|
-
expect(assessmentsSection.isEmpty).toBe(true); // no assessment in this fixture
|
|
71
69
|
// Chain edges
|
|
72
70
|
expect(layout.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
|
|
73
71
|
expect(layout.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
|
|
@@ -82,19 +82,16 @@ describe('layoutActivityCard', () => {
|
|
|
82
82
|
const l = layoutActivityCard({ ...RESOLVED, cardDescription: undefined });
|
|
83
83
|
expect(l.descriptionRow).toBeUndefined();
|
|
84
84
|
});
|
|
85
|
-
it('
|
|
85
|
+
it('three chain sections in order: drivers, goals, changes', () => {
|
|
86
86
|
const l = layoutActivityCard(RESOLVED);
|
|
87
|
-
expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', '
|
|
87
|
+
expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
|
|
88
88
|
});
|
|
89
89
|
it('chain sections carry the resolved nodes', () => {
|
|
90
90
|
const l = layoutActivityCard(RESOLVED);
|
|
91
91
|
const drivers = l.chainSections.find((s) => s.type === 'drivers');
|
|
92
|
-
const assessments = l.chainSections.find((s) => s.type === 'assessments');
|
|
93
92
|
const goals = l.chainSections.find((s) => s.type === 'goals');
|
|
94
93
|
const changes = l.chainSections.find((s) => s.type === 'changes');
|
|
95
94
|
expect(drivers.nodes.map((n) => n.id)).toEqual(['FACTOR-EU-MDR-1']);
|
|
96
|
-
expect(assessments.nodes.map((n) => n.id)).toEqual(['ASSESSMENT-MDR-1']);
|
|
97
|
-
expect(assessments.nodes[0].meta).toBe('2026-03-01');
|
|
98
95
|
expect(goals.nodes.map((n) => n.id)).toEqual(['GOAL-EU-MARKET-1']);
|
|
99
96
|
expect(changes.nodes.map((n) => n.id)).toEqual(['CHANGE-EU-COMPLIANCE-1']);
|
|
100
97
|
});
|
|
@@ -111,10 +108,6 @@ describe('layoutActivityCard', () => {
|
|
|
111
108
|
expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
|
|
112
109
|
expect(l.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
|
|
113
110
|
});
|
|
114
|
-
it('builds driver→assessment edge when assessment is linked', () => {
|
|
115
|
-
const l = layoutActivityCard(RESOLVED);
|
|
116
|
-
expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'ASSESSMENT-MDR-1' });
|
|
117
|
-
});
|
|
118
111
|
it('milestone ArchiMate class is set', () => {
|
|
119
112
|
const l = layoutActivityCard(RESOLVED);
|
|
120
113
|
expect(l.milestones).toHaveLength(1);
|
|
@@ -198,6 +198,31 @@ describe('resolveActivityCard', () => {
|
|
|
198
198
|
expect(r.resolved.stakeholders).toEqual([{ id: 'STAKEHOLDER-OPS-1', name: 'STAKEHOLDER-OPS-1', role: 'sponsor' }]);
|
|
199
199
|
expect(r.warnings.some((w) => w.code === 'PC-001')).toBe(true);
|
|
200
200
|
});
|
|
201
|
+
it('resolves stakeholders from inline stakeholders: [] on the Action element', () => {
|
|
202
|
+
const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
|
|
203
|
+
const r = resolveActivityCard(CARD, {
|
|
204
|
+
elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
|
|
205
|
+
relations: [REL_GOAL],
|
|
206
|
+
});
|
|
207
|
+
expect(r.valid).toBe(true);
|
|
208
|
+
const sh = r.resolved.stakeholders;
|
|
209
|
+
expect(sh).toHaveLength(1);
|
|
210
|
+
expect(sh[0].id).toBe('STAKEHOLDER-OPS-1');
|
|
211
|
+
expect(sh[0].name).toBe('Operations');
|
|
212
|
+
expect(sh[0].role).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
it('REL stakeholder takes precedence over inline duplicate (carries role)', () => {
|
|
215
|
+
const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
|
|
216
|
+
const r = resolveActivityCard(CARD, {
|
|
217
|
+
elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
|
|
218
|
+
relations: [REL_GOAL, REL_STAKEHOLDER],
|
|
219
|
+
});
|
|
220
|
+
expect(r.valid).toBe(true);
|
|
221
|
+
const sh = r.resolved.stakeholders;
|
|
222
|
+
// deduplicated — REL entry wins, inline duplicate is dropped
|
|
223
|
+
expect(sh).toHaveLength(1);
|
|
224
|
+
expect(sh[0].role).toBe('sponsor');
|
|
225
|
+
});
|
|
201
226
|
it('falls back to the inline goals field when no activity_goal relation exists', () => {
|
|
202
227
|
const projectWithInline = { ...PROJECT, goals: ['GOAL-EU-MARKET-1'] };
|
|
203
228
|
const r = resolveActivityCard(CARD, {
|
|
@@ -220,7 +245,7 @@ describe('resolveActivityCard', () => {
|
|
|
220
245
|
expect(pc001.message).toContain('canon/elements/**');
|
|
221
246
|
expect(pc001.message).toContain('canon/views/activities/**');
|
|
222
247
|
// Must include actionable hint with expected file pattern (§7 PC-001)
|
|
223
|
-
expect(pc001.message).toContain('notation:
|
|
248
|
+
expect(pc001.message).toContain('notation: action');
|
|
224
249
|
expect(pc001.message).toContain(`id: "${CARD.activity_card.project}"`);
|
|
225
250
|
});
|
|
226
251
|
it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
|
|
@@ -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);
|
|
@@ -164,7 +164,7 @@ export function layoutActivityCard(card, options) {
|
|
|
164
164
|
descriptionRow = { label: 'Description', valueLines: lines, x: contentX, y: cursorY, width: contentW, height };
|
|
165
165
|
cursorY += height + SECTION_GAP;
|
|
166
166
|
}
|
|
167
|
-
// ── 5. Chain: Drivers →
|
|
167
|
+
// ── 5. Chain: Drivers → Goals → Changes ──────────────────────────────────
|
|
168
168
|
// Each section is a full-width band with a header label and stacked node rows.
|
|
169
169
|
// An empty section shows a gap indicator ("— not on file") so the author
|
|
170
170
|
// sees which parts of the narrative are missing.
|
|
@@ -192,9 +192,7 @@ export function layoutActivityCard(card, options) {
|
|
|
192
192
|
cursorY += sectionH + CONNECTOR_H;
|
|
193
193
|
}
|
|
194
194
|
const { drivers, goals, changes } = card.motivation;
|
|
195
|
-
const assessments = card.assessments ?? [];
|
|
196
195
|
pushChainSection('drivers', 'Drivers', 'What prompted this initiative?', drivers.map((d) => ({ id: d.id, name: d.name })));
|
|
197
|
-
pushChainSection('assessments', 'Assessments', 'What is the current gap?', assessments.map((a) => ({ id: a.id, name: a.name, meta: a.observed_at })));
|
|
198
196
|
pushChainSection('goals', 'Goals', 'What outcome do we target?', goals.map((g) => ({ id: g.id, name: g.name })));
|
|
199
197
|
pushChainSection('changes', 'Changes', 'What will change?', changes.map((c) => ({ id: c.id, name: c.name })));
|
|
200
198
|
// Remove trailing CONNECTOR_H — last section connects to milestones with SECTION_GAP.
|
|
@@ -202,10 +200,6 @@ export function layoutActivityCard(card, options) {
|
|
|
202
200
|
// Node-level chain edges.
|
|
203
201
|
const driverIdSet = new Set(drivers.map((d) => d.id));
|
|
204
202
|
const goalIdSet = new Set(goals.map((g) => g.id));
|
|
205
|
-
for (const a of assessments) {
|
|
206
|
-
if (driverIdSet.has(a.driverId))
|
|
207
|
-
chainEdges.push({ sourceId: a.driverId, targetId: a.id });
|
|
208
|
-
}
|
|
209
203
|
for (const g of goals) {
|
|
210
204
|
for (const did of g.driverIds) {
|
|
211
205
|
if (driverIdSet.has(did))
|
|
@@ -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,18 +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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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).
|
|
154
155
|
if (!projectRec) {
|
|
155
156
|
errors.push({
|
|
156
157
|
code: 'PC-001',
|
|
157
|
-
message: `
|
|
158
|
+
message: `action_card.project "${projectId}" not found after searching ` +
|
|
158
159
|
`canon/elements/** and canon/views/activities/** — ` +
|
|
159
|
-
`add a YAML file with notation:
|
|
160
|
-
`(e.g. canon/elements/05_implementation/
|
|
160
|
+
`add a YAML file with notation: action and id: "${projectId}" ` +
|
|
161
|
+
`(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
|
|
161
162
|
});
|
|
162
163
|
return { valid: false, errors, warnings };
|
|
163
164
|
}
|
|
@@ -328,14 +329,15 @@ export function resolveActivityCard(doc, sources) {
|
|
|
328
329
|
return a.observed_at < b.observed_at ? -1 : a.observed_at > b.observed_at ? 1 : 0;
|
|
329
330
|
});
|
|
330
331
|
// ── Child activities — parent = project id ──────────────────────────────────
|
|
332
|
+
// Look in both `notation: action` and legacy `notation: activity` elements.
|
|
331
333
|
const childActivities = [];
|
|
332
|
-
for (const [id, rec] of
|
|
334
|
+
for (const [id, rec] of allActionElements) {
|
|
333
335
|
if (str(rec['parent']) !== projectId)
|
|
334
336
|
continue;
|
|
335
337
|
childActivities.push({
|
|
336
338
|
id,
|
|
337
339
|
name: str(rec['name']) ?? id,
|
|
338
|
-
activity_type: str(rec['activity_type']),
|
|
340
|
+
activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
|
|
339
341
|
start_date: str(rec['start_date']),
|
|
340
342
|
end_date: str(rec['end_date']),
|
|
341
343
|
owner: str(rec['owner']),
|
|
@@ -348,10 +350,19 @@ export function resolveActivityCard(doc, sources) {
|
|
|
348
350
|
// goals referenced only by in-scope changes). Resolve each id to its GOAL
|
|
349
351
|
// element name, falling back to the id when the element is absent.
|
|
350
352
|
const goalNames = projectGoalIds.map((gid) => str(goalElems.get(gid)?.['name']) ?? gid);
|
|
351
|
-
// ── Stakeholders —
|
|
353
|
+
// ── Stakeholders — inline `stakeholders: []` on the Action element PLUS active
|
|
354
|
+
// `action_stakeholder` / `activity_stakeholder` REL files. Both sources are
|
|
355
|
+
// merged; REL entries take precedence (they carry the role field).
|
|
352
356
|
const stakeholderElems = collectByNotation(sources.elements, 'stakeholder');
|
|
357
|
+
const relStakeholderRefs = activeActivityStakeholders(sources.relations, projectId);
|
|
358
|
+
const relStakeholderIds = new Set(relStakeholderRefs.map((s) => s.id));
|
|
359
|
+
const inlineStakeholderIds = strArray(projectRec['stakeholders']).filter((id) => !relStakeholderIds.has(id));
|
|
360
|
+
const allStakeholderRefs = [
|
|
361
|
+
...relStakeholderRefs,
|
|
362
|
+
...inlineStakeholderIds.map((id) => ({ id })),
|
|
363
|
+
];
|
|
353
364
|
const stakeholders = [];
|
|
354
|
-
for (const { id: sid, role } of
|
|
365
|
+
for (const { id: sid, role } of allStakeholderRefs) {
|
|
355
366
|
const rec = stakeholderElems.get(sid);
|
|
356
367
|
if (!rec) {
|
|
357
368
|
warnings.push({
|
|
@@ -378,7 +389,7 @@ export function resolveActivityCard(doc, sources) {
|
|
|
378
389
|
valid_to: validToOk ? validToRaw : undefined,
|
|
379
390
|
start_date: str(projectRec['start_date']),
|
|
380
391
|
end_date: str(projectRec['end_date']),
|
|
381
|
-
activity_type: str(projectRec['activity_type']),
|
|
392
|
+
activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
|
|
382
393
|
status: str(projectRec['status']),
|
|
383
394
|
};
|
|
384
395
|
const resolved = {
|
|
@@ -214,7 +214,7 @@ export interface ChainEdge {
|
|
|
214
214
|
* so the author sees the missing data.
|
|
215
215
|
*/
|
|
216
216
|
export interface ChainSectionLayout {
|
|
217
|
-
type: 'drivers' | '
|
|
217
|
+
type: 'drivers' | 'goals' | 'changes';
|
|
218
218
|
/** Section header label. */
|
|
219
219
|
label: string;
|
|
220
220
|
/** Short question answered by this section, shown in grey after the label. */
|
|
@@ -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;
|