@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
package/package.json
CHANGED
|
@@ -15,9 +15,43 @@ const minimalValid = {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
describe('validateActivities', () => {
|
|
18
|
-
it('ACT-001 — accepts
|
|
18
|
+
it('ACT-001 — accepts notation: activities (deprecated)', () => {
|
|
19
19
|
const r = validateActivities(minimalValid);
|
|
20
20
|
expect(r.valid).toBe(true);
|
|
21
|
+
// deprecated notation emits a warning, not an error
|
|
22
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('ACT-001 — accepts notation: action (canonical)', () => {
|
|
26
|
+
const r = validateActivities({
|
|
27
|
+
notation: 'action',
|
|
28
|
+
actions: [
|
|
29
|
+
{ id: 'ACTION-001', name: 'Start', duration: 3 },
|
|
30
|
+
{ id: 'ACTION-002', name: 'End', duration: 2, predecessors: ['ACTION-001'] },
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
expect(r.valid).toBe(true);
|
|
34
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('ACT-001 — accepts notation: action with deprecated activities: key (emits field warning)', () => {
|
|
38
|
+
const r = validateActivities({
|
|
39
|
+
notation: 'action',
|
|
40
|
+
activities: [{ id: 'ACTION-001', name: 'Foo', duration: 1 }],
|
|
41
|
+
});
|
|
42
|
+
expect(r.valid).toBe(true);
|
|
43
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_FIELD')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('ACT-001 — normalises action_type to activity_type for downstream layout', () => {
|
|
47
|
+
const input: Record<string, unknown> = {
|
|
48
|
+
notation: 'action',
|
|
49
|
+
actions: [{ id: 'ACTION-001', name: 'Foo', duration: 1, action_type: 'project' }],
|
|
50
|
+
};
|
|
51
|
+
const r = validateActivities(input);
|
|
52
|
+
expect(r.valid).toBe(true);
|
|
53
|
+
const entry = (input.actions as Array<Record<string, unknown>>)[0];
|
|
54
|
+
expect(entry['activity_type']).toBe('project');
|
|
21
55
|
});
|
|
22
56
|
|
|
23
57
|
it('ACT-001 — rejects missing notation', () => {
|
|
@@ -19,20 +19,31 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
19
19
|
|
|
20
20
|
const raw = input as Record<string, unknown>;
|
|
21
21
|
|
|
22
|
-
// ACT-001: notation must
|
|
22
|
+
// ACT-001: notation must be "action" (canonical) or "activities" (deprecated)
|
|
23
23
|
if (raw.notation === undefined) {
|
|
24
24
|
errors.push({ code: 'ACT-001', message: 'notation field is required' });
|
|
25
25
|
return { valid: false, errors, warnings };
|
|
26
26
|
}
|
|
27
|
-
if (raw.notation !== 'activities') {
|
|
28
|
-
errors.push({ code: 'ACT-001', message: `notation must be "
|
|
27
|
+
if (raw.notation !== 'action' && raw.notation !== 'activities') {
|
|
28
|
+
errors.push({ code: 'ACT-001', message: `notation must be "action", got "${String(raw.notation)}"` });
|
|
29
29
|
return { valid: false, errors, warnings };
|
|
30
30
|
}
|
|
31
|
+
if (raw.notation === 'activities') {
|
|
32
|
+
warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activities" is deprecated — migrate to notation: "action"' });
|
|
33
|
+
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
// Accept "actions:" (canonical) or "activities:" (deprecated)
|
|
36
|
+
const rawArray = Array.isArray(raw.actions) ? raw.actions : (Array.isArray(raw.activities) ? raw.activities : undefined);
|
|
37
|
+
if (Array.isArray(raw.activities) && !Array.isArray(raw.actions)) {
|
|
38
|
+
warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activities:" is deprecated — rename to "actions:"' });
|
|
39
|
+
}
|
|
40
|
+
if (!rawArray) {
|
|
41
|
+
const canonKey = raw.notation === 'action' ? 'actions' : 'activities';
|
|
42
|
+
errors.push({ code: 'SCHEMA_INVALID', message: `${canonKey} must be an array`, path: canonKey });
|
|
34
43
|
return { valid: false, errors, warnings };
|
|
35
44
|
}
|
|
45
|
+
// Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
|
|
46
|
+
if (!Array.isArray(raw.activities)) raw.activities = raw.actions;
|
|
36
47
|
|
|
37
48
|
// ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
|
|
38
49
|
// surfaces clearly even when activities also have issues).
|
|
@@ -99,6 +110,11 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
99
110
|
}
|
|
100
111
|
const act = a as Record<string, unknown>;
|
|
101
112
|
|
|
113
|
+
// Normalise action_type → activity_type (canonical rename; old name kept for compat)
|
|
114
|
+
if (act['action_type'] !== undefined && act['activity_type'] === undefined) {
|
|
115
|
+
act['activity_type'] = act['action_type'];
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
// ACT-010: reject single-value forms
|
|
103
119
|
if ('goal' in act) {
|
|
104
120
|
errors.push({ code: 'ACT-010', message: `Use "goals: []" array form instead of "goal:" (activity at index ${i})`, path });
|
|
@@ -71,13 +71,11 @@ describe('activity-card worked example (eu-programme)', () => {
|
|
|
71
71
|
// Description row
|
|
72
72
|
expect(layout.descriptionRow?.label).toBe('Description');
|
|
73
73
|
// Chain sections
|
|
74
|
-
expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', '
|
|
74
|
+
expect(layout.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
|
|
75
75
|
const driversSection = layout.chainSections.find((s) => s.type === 'drivers')!;
|
|
76
76
|
const goalsSection = layout.chainSections.find((s) => s.type === 'goals')!;
|
|
77
|
-
const assessmentsSection = layout.chainSections.find((s) => s.type === 'assessments')!;
|
|
78
77
|
expect(driversSection.nodes.map((n) => n.id)).toContain('FACTOR-EU-MDR-1');
|
|
79
78
|
expect(goalsSection.nodes.map((n) => n.id)).toContain('GOAL-EU-MARKET-1');
|
|
80
|
-
expect(assessmentsSection.isEmpty).toBe(true); // no assessment in this fixture
|
|
81
79
|
// Chain edges
|
|
82
80
|
expect(layout.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'GOAL-EU-MARKET-1' });
|
|
83
81
|
expect(layout.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
|
|
@@ -94,20 +94,17 @@ describe('layoutActivityCard', () => {
|
|
|
94
94
|
expect(l.descriptionRow).toBeUndefined();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it('
|
|
97
|
+
it('three chain sections in order: drivers, goals, changes', () => {
|
|
98
98
|
const l = layoutActivityCard(RESOLVED);
|
|
99
|
-
expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', '
|
|
99
|
+
expect(l.chainSections.map((s) => s.type)).toEqual(['drivers', 'goals', 'changes']);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it('chain sections carry the resolved nodes', () => {
|
|
103
103
|
const l = layoutActivityCard(RESOLVED);
|
|
104
104
|
const drivers = l.chainSections.find((s) => s.type === 'drivers')!;
|
|
105
|
-
const assessments = l.chainSections.find((s) => s.type === 'assessments')!;
|
|
106
105
|
const goals = l.chainSections.find((s) => s.type === 'goals')!;
|
|
107
106
|
const changes = l.chainSections.find((s) => s.type === 'changes')!;
|
|
108
107
|
expect(drivers.nodes.map((n) => n.id)).toEqual(['FACTOR-EU-MDR-1']);
|
|
109
|
-
expect(assessments.nodes.map((n) => n.id)).toEqual(['ASSESSMENT-MDR-1']);
|
|
110
|
-
expect(assessments.nodes[0].meta).toBe('2026-03-01');
|
|
111
108
|
expect(goals.nodes.map((n) => n.id)).toEqual(['GOAL-EU-MARKET-1']);
|
|
112
109
|
expect(changes.nodes.map((n) => n.id)).toEqual(['CHANGE-EU-COMPLIANCE-1']);
|
|
113
110
|
});
|
|
@@ -127,10 +124,6 @@ describe('layoutActivityCard', () => {
|
|
|
127
124
|
expect(l.chainEdges).toContainEqual({ sourceId: 'GOAL-EU-MARKET-1', targetId: 'CHANGE-EU-COMPLIANCE-1' });
|
|
128
125
|
});
|
|
129
126
|
|
|
130
|
-
it('builds driver→assessment edge when assessment is linked', () => {
|
|
131
|
-
const l = layoutActivityCard(RESOLVED);
|
|
132
|
-
expect(l.chainEdges).toContainEqual({ sourceId: 'FACTOR-EU-MDR-1', targetId: 'ASSESSMENT-MDR-1' });
|
|
133
|
-
});
|
|
134
127
|
|
|
135
128
|
it('milestone ArchiMate class is set', () => {
|
|
136
129
|
const l = layoutActivityCard(RESOLVED);
|
|
@@ -217,6 +217,33 @@ describe('resolveActivityCard', () => {
|
|
|
217
217
|
expect(r.warnings.some((w) => w.code === 'PC-001')).toBe(true);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
it('resolves stakeholders from inline stakeholders: [] on the Action element', () => {
|
|
221
|
+
const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
|
|
222
|
+
const r = resolveActivityCard(CARD, {
|
|
223
|
+
elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
|
|
224
|
+
relations: [REL_GOAL],
|
|
225
|
+
});
|
|
226
|
+
expect(r.valid).toBe(true);
|
|
227
|
+
const sh = r.resolved!.stakeholders!;
|
|
228
|
+
expect(sh).toHaveLength(1);
|
|
229
|
+
expect(sh[0].id).toBe('STAKEHOLDER-OPS-1');
|
|
230
|
+
expect(sh[0].name).toBe('Operations');
|
|
231
|
+
expect(sh[0].role).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('REL stakeholder takes precedence over inline duplicate (carries role)', () => {
|
|
235
|
+
const projectWithInlineStakeholders = { ...PROJECT, stakeholders: ['STAKEHOLDER-OPS-1'] };
|
|
236
|
+
const r = resolveActivityCard(CARD, {
|
|
237
|
+
elements: [projectWithInlineStakeholders, CHILD, FACTOR, GOAL, CHANGE, STAKEHOLDER],
|
|
238
|
+
relations: [REL_GOAL, REL_STAKEHOLDER],
|
|
239
|
+
});
|
|
240
|
+
expect(r.valid).toBe(true);
|
|
241
|
+
const sh = r.resolved!.stakeholders!;
|
|
242
|
+
// deduplicated — REL entry wins, inline duplicate is dropped
|
|
243
|
+
expect(sh).toHaveLength(1);
|
|
244
|
+
expect(sh[0].role).toBe('sponsor');
|
|
245
|
+
});
|
|
246
|
+
|
|
220
247
|
it('falls back to the inline goals field when no activity_goal relation exists', () => {
|
|
221
248
|
const projectWithInline = { ...PROJECT, goals: ['GOAL-EU-MARKET-1'] };
|
|
222
249
|
const r = resolveActivityCard(CARD, {
|
|
@@ -241,7 +268,7 @@ describe('resolveActivityCard', () => {
|
|
|
241
268
|
expect(pc001!.message).toContain('canon/elements/**');
|
|
242
269
|
expect(pc001!.message).toContain('canon/views/activities/**');
|
|
243
270
|
// Must include actionable hint with expected file pattern (§7 PC-001)
|
|
244
|
-
expect(pc001!.message).toContain('notation:
|
|
271
|
+
expect(pc001!.message).toContain('notation: action');
|
|
245
272
|
expect(pc001!.message).toContain(`id: "${CARD.activity_card.project}"`);
|
|
246
273
|
});
|
|
247
274
|
|
|
@@ -20,10 +20,34 @@ const VALID = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
describe('validateActivityCard', () => {
|
|
23
|
-
it('accepts a well-formed card', () => {
|
|
23
|
+
it('accepts a well-formed card (deprecated notation: activity-card)', () => {
|
|
24
24
|
const r = validateActivityCard(VALID);
|
|
25
25
|
expect(r.valid).toBe(true);
|
|
26
26
|
expect(r.errors).toHaveLength(0);
|
|
27
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('accepts notation: action-card with action_card block (canonical)', () => {
|
|
31
|
+
const r = validateActivityCard({
|
|
32
|
+
notation: 'action-card',
|
|
33
|
+
action_card: {
|
|
34
|
+
id: 'ACTION_CARD-EU-PROGRAMME-1',
|
|
35
|
+
project: 'ACTION-EU-PROGRAMME-1',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(r.valid).toBe(true);
|
|
39
|
+
expect(r.warnings.some(w => w.code === 'DEPRECATED_NOTATION')).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts action_card block with legacy ACTIVITY_CARD id prefix', () => {
|
|
43
|
+
const r = validateActivityCard({
|
|
44
|
+
notation: 'action-card',
|
|
45
|
+
action_card: {
|
|
46
|
+
id: 'ACTIVITY_CARD-EU-PROGRAMME-1',
|
|
47
|
+
project: 'ACTION-EU-PROGRAMME-1',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(r.valid).toBe(true);
|
|
27
51
|
});
|
|
28
52
|
|
|
29
53
|
it('rejects non-object input', () => {
|
|
@@ -183,7 +183,7 @@ export function layoutActivityCard(
|
|
|
183
183
|
cursorY += height + SECTION_GAP;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// ── 5. Chain: Drivers →
|
|
186
|
+
// ── 5. Chain: Drivers → Goals → Changes ──────────────────────────────────
|
|
187
187
|
// Each section is a full-width band with a header label and stacked node rows.
|
|
188
188
|
// An empty section shows a gap indicator ("— not on file") so the author
|
|
189
189
|
// sees which parts of the narrative are missing.
|
|
@@ -222,14 +222,10 @@ export function layoutActivityCard(
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const { drivers, goals, changes } = card.motivation;
|
|
225
|
-
const assessments = card.assessments ?? [];
|
|
226
225
|
|
|
227
226
|
pushChainSection('drivers', 'Drivers', 'What prompted this initiative?',
|
|
228
227
|
drivers.map((d) => ({ id: d.id, name: d.name })));
|
|
229
228
|
|
|
230
|
-
pushChainSection('assessments', 'Assessments', 'What is the current gap?',
|
|
231
|
-
assessments.map((a) => ({ id: a.id, name: a.name, meta: a.observed_at })));
|
|
232
|
-
|
|
233
229
|
pushChainSection('goals', 'Goals', 'What outcome do we target?',
|
|
234
230
|
goals.map((g) => ({ id: g.id, name: g.name })));
|
|
235
231
|
|
|
@@ -242,9 +238,6 @@ export function layoutActivityCard(
|
|
|
242
238
|
// Node-level chain edges.
|
|
243
239
|
const driverIdSet = new Set(drivers.map((d) => d.id));
|
|
244
240
|
const goalIdSet = new Set(goals.map((g) => g.id));
|
|
245
|
-
for (const a of assessments) {
|
|
246
|
-
if (driverIdSet.has(a.driverId)) chainEdges.push({ sourceId: a.driverId, targetId: a.id });
|
|
247
|
-
}
|
|
248
241
|
for (const g of goals) {
|
|
249
242
|
for (const did of g.driverIds) {
|
|
250
243
|
if (driverIdSet.has(did)) chainEdges.push({ sourceId: did, targetId: g.id });
|
|
@@ -104,16 +104,17 @@ function collectByNotation(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* GOAL ids reached by an *active* `
|
|
108
|
-
* `fromId`. A relation with a non-null `valid_to` has
|
|
109
|
-
* re-aimed) and is excluded
|
|
107
|
+
* GOAL ids reached by an *active* `action_goal` (or legacy `activity_goal`)
|
|
108
|
+
* relation originating at `fromId`. A relation with a non-null `valid_to` has
|
|
109
|
+
* ended (the activity was re-aimed) and is excluded.
|
|
110
110
|
*/
|
|
111
111
|
function activeActivityGoals(relations: unknown[], fromId: string): string[] {
|
|
112
112
|
const out: string[] = [];
|
|
113
113
|
for (const doc of relations) {
|
|
114
114
|
if (!isObject(doc)) continue;
|
|
115
115
|
if (str(doc['notation']) !== 'relation') continue;
|
|
116
|
-
|
|
116
|
+
const type = str(doc['type']);
|
|
117
|
+
if (type !== 'action_goal' && type !== 'activity_goal') continue;
|
|
117
118
|
if (str(doc['from']) !== fromId) continue;
|
|
118
119
|
const to = str(doc['to']);
|
|
119
120
|
if (!to) continue;
|
|
@@ -125,11 +126,8 @@ function activeActivityGoals(relations: unknown[], fromId: string): string[] {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
|
-
* STAKEHOLDER ids + roles reached by an *active* `
|
|
129
|
-
* relation originating at `fromId`.
|
|
130
|
-
* ended and is excluded, so the card shows the project's currently-engaged
|
|
131
|
-
* stakeholders. The optional `role` field on the relation carries the
|
|
132
|
-
* project-role (initiator | owner | sponsor | project_manager).
|
|
129
|
+
* STAKEHOLDER ids + roles reached by an *active* `action_stakeholder` (or
|
|
130
|
+
* legacy `activity_stakeholder`) relation originating at `fromId`.
|
|
133
131
|
*/
|
|
134
132
|
function activeActivityStakeholders(
|
|
135
133
|
relations: unknown[],
|
|
@@ -139,7 +137,8 @@ function activeActivityStakeholders(
|
|
|
139
137
|
for (const doc of relations) {
|
|
140
138
|
if (!isObject(doc)) continue;
|
|
141
139
|
if (str(doc['notation']) !== 'relation') continue;
|
|
142
|
-
|
|
140
|
+
const type = str(doc['type']);
|
|
141
|
+
if (type !== 'action_stakeholder' && type !== 'activity_stakeholder') continue;
|
|
143
142
|
if (str(doc['from']) !== fromId) continue;
|
|
144
143
|
const to = str(doc['to']);
|
|
145
144
|
if (!to) continue;
|
|
@@ -165,20 +164,22 @@ export function resolveActivityCard(
|
|
|
165
164
|
return { valid: false, errors, warnings };
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
|
|
169
|
-
const
|
|
167
|
+
// Look up by canonical `notation: action` first, then legacy `notation: activity`.
|
|
168
|
+
const actionElements = collectByNotation(sources.elements, 'action');
|
|
169
|
+
const activityElements = collectByNotation(sources.elements, 'activity');
|
|
170
|
+
const allActionElements = new Map([...activityElements, ...actionElements]); // action wins on duplicate id
|
|
171
|
+
const projectRec = allActionElements.get(projectId);
|
|
170
172
|
|
|
171
|
-
// PC-001 — project must resolve to an admitted
|
|
172
|
-
// exhausting the canonical resolution scope (§6.1)
|
|
173
|
-
// recursively first, then canon/views/activities/** as secondary fallback.
|
|
173
|
+
// PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
|
|
174
|
+
// element after exhausting the canonical resolution scope (§6.1).
|
|
174
175
|
if (!projectRec) {
|
|
175
176
|
errors.push({
|
|
176
177
|
code: 'PC-001',
|
|
177
178
|
message:
|
|
178
|
-
`
|
|
179
|
+
`action_card.project "${projectId}" not found after searching ` +
|
|
179
180
|
`canon/elements/** and canon/views/activities/** — ` +
|
|
180
|
-
`add a YAML file with notation:
|
|
181
|
-
`(e.g. canon/elements/05_implementation/
|
|
181
|
+
`add a YAML file with notation: action and id: "${projectId}" ` +
|
|
182
|
+
`(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
|
|
182
183
|
});
|
|
183
184
|
return { valid: false, errors, warnings };
|
|
184
185
|
}
|
|
@@ -349,13 +350,14 @@ export function resolveActivityCard(
|
|
|
349
350
|
});
|
|
350
351
|
|
|
351
352
|
// ── Child activities — parent = project id ──────────────────────────────────
|
|
353
|
+
// Look in both `notation: action` and legacy `notation: activity` elements.
|
|
352
354
|
const childActivities: ResolvedChildActivity[] = [];
|
|
353
|
-
for (const [id, rec] of
|
|
355
|
+
for (const [id, rec] of allActionElements) {
|
|
354
356
|
if (str(rec['parent']) !== projectId) continue;
|
|
355
357
|
childActivities.push({
|
|
356
358
|
id,
|
|
357
359
|
name: str(rec['name']) ?? id,
|
|
358
|
-
activity_type: str(rec['activity_type']),
|
|
360
|
+
activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
|
|
359
361
|
start_date: str(rec['start_date']),
|
|
360
362
|
end_date: str(rec['end_date']),
|
|
361
363
|
owner: str(rec['owner']),
|
|
@@ -370,10 +372,19 @@ export function resolveActivityCard(
|
|
|
370
372
|
// element name, falling back to the id when the element is absent.
|
|
371
373
|
const goalNames = projectGoalIds.map((gid) => str(goalElems.get(gid)?.['name']) ?? gid);
|
|
372
374
|
|
|
373
|
-
// ── Stakeholders —
|
|
375
|
+
// ── Stakeholders — inline `stakeholders: []` on the Action element PLUS active
|
|
376
|
+
// `action_stakeholder` / `activity_stakeholder` REL files. Both sources are
|
|
377
|
+
// merged; REL entries take precedence (they carry the role field).
|
|
374
378
|
const stakeholderElems = collectByNotation(sources.elements, 'stakeholder');
|
|
379
|
+
const relStakeholderRefs = activeActivityStakeholders(sources.relations, projectId);
|
|
380
|
+
const relStakeholderIds = new Set(relStakeholderRefs.map((s) => s.id));
|
|
381
|
+
const inlineStakeholderIds = strArray(projectRec['stakeholders']).filter((id) => !relStakeholderIds.has(id));
|
|
382
|
+
const allStakeholderRefs: Array<{ id: string; role?: string }> = [
|
|
383
|
+
...relStakeholderRefs,
|
|
384
|
+
...inlineStakeholderIds.map((id) => ({ id })),
|
|
385
|
+
];
|
|
375
386
|
const stakeholders: ResolvedStakeholder[] = [];
|
|
376
|
-
for (const { id: sid, role } of
|
|
387
|
+
for (const { id: sid, role } of allStakeholderRefs) {
|
|
377
388
|
const rec = stakeholderElems.get(sid);
|
|
378
389
|
if (!rec) {
|
|
379
390
|
warnings.push({
|
|
@@ -401,7 +412,7 @@ export function resolveActivityCard(
|
|
|
401
412
|
valid_to: validToOk ? (validToRaw as string) : undefined,
|
|
402
413
|
start_date: str(projectRec['start_date']),
|
|
403
414
|
end_date: str(projectRec['end_date']),
|
|
404
|
-
activity_type: str(projectRec['activity_type']),
|
|
415
|
+
activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
|
|
405
416
|
status: str(projectRec['status']),
|
|
406
417
|
};
|
|
407
418
|
|
|
@@ -285,7 +285,7 @@ export interface ChainEdge {
|
|
|
285
285
|
* so the author sees the missing data.
|
|
286
286
|
*/
|
|
287
287
|
export interface ChainSectionLayout {
|
|
288
|
-
type: 'drivers' | '
|
|
288
|
+
type: 'drivers' | 'goals' | 'changes';
|
|
289
289
|
/** Section header label. */
|
|
290
290
|
label: string;
|
|
291
291
|
/** Short question answered by this section, shown in grey after the label. */
|
|
@@ -26,8 +26,8 @@ import type { ValidationError, ValidationWarning, ValidationResult } from '../va
|
|
|
26
26
|
|
|
27
27
|
export type { ValidationError, ValidationWarning, ValidationResult };
|
|
28
28
|
|
|
29
|
-
const ACTIVITY_CARD_ID_RE = /^ACTIVITY_CARD(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
30
|
-
const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
29
|
+
const ACTIVITY_CARD_ID_RE = /^(ACTIVITY_CARD|ACTION_CARD)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
30
|
+
const ACTIVITY_ID_RE = /^(ACTIVITY|ACTION)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
31
31
|
const MILESTONE_ID_RE = /^MILESTONE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
32
32
|
const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
33
33
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -49,37 +49,46 @@ export function validateActivityCard(input: unknown): ValidationResult {
|
|
|
49
49
|
// ── Header (CONTRACT.md §2) ────────────────────────────────────────────────
|
|
50
50
|
if (!('notation' in raw)) {
|
|
51
51
|
errors.push({ code: 'HDR-001', message: 'notation field is required' });
|
|
52
|
-
} else if (raw['notation'] !== 'activity-card') {
|
|
52
|
+
} else if (raw['notation'] !== 'action-card' && raw['notation'] !== 'activity-card') {
|
|
53
53
|
errors.push({
|
|
54
54
|
code: 'HDR-002',
|
|
55
|
-
message: `notation must be "
|
|
55
|
+
message: `notation must be "action-card", got "${String(raw['notation'])}"`,
|
|
56
56
|
});
|
|
57
|
+
} else if (raw['notation'] === 'activity-card') {
|
|
58
|
+
warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activity-card" is deprecated — migrate to notation: "action-card"' });
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
// ── activity_card block
|
|
60
|
-
|
|
61
|
+
// ── action_card / activity_card block ───────────────────────────────────────
|
|
62
|
+
// "action_card:" is canonical; "activity_card:" is accepted for compat.
|
|
63
|
+
const rawBlock = raw['action_card'] ?? raw['activity_card'];
|
|
64
|
+
if (raw['activity_card'] !== undefined && raw['action_card'] === undefined) {
|
|
65
|
+
warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activity_card:" is deprecated — rename to "action_card:"' });
|
|
66
|
+
}
|
|
67
|
+
const block = rawBlock;
|
|
61
68
|
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
62
|
-
errors.push({ code: 'AC-001', message: 'Missing required root key:
|
|
69
|
+
errors.push({ code: 'AC-001', message: 'Missing required root key: action_card (object)' });
|
|
63
70
|
return { valid: false, errors, warnings };
|
|
64
71
|
}
|
|
72
|
+
// Normalise onto activity_card for downstream (resolver reads doc.activity_card)
|
|
73
|
+
if (raw['activity_card'] === undefined) raw['activity_card'] = rawBlock;
|
|
65
74
|
const card = block as Record<string, unknown>;
|
|
66
75
|
|
|
67
76
|
if (!isNonEmptyString(card['id'])) {
|
|
68
|
-
errors.push({ code: 'AC-002', message: '
|
|
77
|
+
errors.push({ code: 'AC-002', message: 'action_card.id is required' });
|
|
69
78
|
} else if (!ACTIVITY_CARD_ID_RE.test(card['id'])) {
|
|
70
79
|
errors.push({
|
|
71
80
|
code: 'AC-002',
|
|
72
|
-
message: `
|
|
81
|
+
message: `action_card.id "${card['id']}" must match ACTION_CARD-[<middle>-]<INTEGER> (or legacy ACTIVITY_CARD-…)`,
|
|
73
82
|
});
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
// PC-001 (document-local half): project present + well-formed.
|
|
77
86
|
if (!isNonEmptyString(card['project'])) {
|
|
78
|
-
errors.push({ code: 'PC-001', message: '
|
|
87
|
+
errors.push({ code: 'PC-001', message: 'action_card.project is required (an ACTION-… id)' });
|
|
79
88
|
} else if (!ACTIVITY_ID_RE.test(card['project'])) {
|
|
80
89
|
errors.push({
|
|
81
90
|
code: 'PC-001',
|
|
82
|
-
message: `
|
|
91
|
+
message: `action_card.project "${card['project']}" is malformed; must match ACTION-[<middle>-]<INTEGER> (or legacy ACTIVITY-…)`,
|
|
83
92
|
});
|
|
84
93
|
}
|
|
85
94
|
|
|
@@ -6,3 +6,14 @@ export type {
|
|
|
6
6
|
} from './types.js';
|
|
7
7
|
|
|
8
8
|
export { validateCapabilityMap } from './validate.js';
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
CapabilityTreeNode,
|
|
12
|
+
CapabilityTreeEdge,
|
|
13
|
+
CapabilityTreeLevelCounts,
|
|
14
|
+
CapabilityTreeLayout,
|
|
15
|
+
} from './layout-tree.js';
|
|
16
|
+
export { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_RANK_SEP, TREE_NODE_SEP } from './layout-tree.js';
|
|
17
|
+
|
|
18
|
+
export type { RenderCapabilityTreeOptions } from './render-capability-tree.js';
|
|
19
|
+
export { renderCapabilityTreeSvg } from './render-capability-tree.js';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { CapabilityNode, CapabilityMapHeader } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const TREE_NODE_WIDTH = 250;
|
|
4
|
+
export const TREE_NODE_HEIGHT = 64;
|
|
5
|
+
/** Gap between sibling columns (node width + rank sep = 330px, matching DSM). */
|
|
6
|
+
export const TREE_RANK_SEP = 80;
|
|
7
|
+
/** Vertical gap between sibling nodes. */
|
|
8
|
+
export const TREE_NODE_SEP = 24;
|
|
9
|
+
|
|
10
|
+
export interface CapabilityTreeNode {
|
|
11
|
+
id: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
data: CapabilityNode;
|
|
17
|
+
depth: number;
|
|
18
|
+
hasChildren: boolean;
|
|
19
|
+
isCollapsed: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CapabilityTreeEdge {
|
|
23
|
+
source: string;
|
|
24
|
+
target: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CapabilityTreeLevelCounts {
|
|
28
|
+
/** Nodes at depth 0–2 (pink band). */
|
|
29
|
+
band0: number;
|
|
30
|
+
/** Nodes at depth 3–4 (yellow band). */
|
|
31
|
+
band1: number;
|
|
32
|
+
/** Nodes at depth 5+ (blue band). */
|
|
33
|
+
band2: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CapabilityTreeLayout {
|
|
37
|
+
nodes: CapabilityTreeNode[];
|
|
38
|
+
edges: CapabilityTreeEdge[];
|
|
39
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
40
|
+
levelCounts: CapabilityTreeLevelCounts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function layoutCapabilityTree(
|
|
44
|
+
map: CapabilityMapHeader,
|
|
45
|
+
collapsedIds: Set<string> = new Set(),
|
|
46
|
+
): CapabilityTreeLayout {
|
|
47
|
+
const nodes: CapabilityTreeNode[] = [];
|
|
48
|
+
const edges: CapabilityTreeEdge[] = [];
|
|
49
|
+
|
|
50
|
+
const colNextY = new Map<number, number>();
|
|
51
|
+
function getNextY(col: number): number {
|
|
52
|
+
return colNextY.get(col) ?? 0;
|
|
53
|
+
}
|
|
54
|
+
function advanceY(col: number, delta: number): void {
|
|
55
|
+
colNextY.set(col, (colNextY.get(col) ?? 0) + delta);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function placeNode(node: CapabilityNode, depth: number): { top: number; bottom: number } {
|
|
59
|
+
const isCollapsed = collapsedIds.has(node.id);
|
|
60
|
+
const hasChildren = Boolean(node.children?.length);
|
|
61
|
+
const visibleChildren = !isCollapsed && node.children?.length ? node.children : [];
|
|
62
|
+
|
|
63
|
+
const x = depth * (TREE_NODE_WIDTH + TREE_RANK_SEP);
|
|
64
|
+
|
|
65
|
+
if (visibleChildren.length === 0) {
|
|
66
|
+
const y = getNextY(depth);
|
|
67
|
+
advanceY(depth, TREE_NODE_HEIGHT + TREE_NODE_SEP);
|
|
68
|
+
nodes.push({ id: node.id, x, y, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
|
|
69
|
+
return { top: y, bottom: y + TREE_NODE_HEIGHT };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const childSpans: Array<{ top: number; bottom: number }> = [];
|
|
73
|
+
for (const child of visibleChildren) {
|
|
74
|
+
childSpans.push(placeNode(child, depth + 1));
|
|
75
|
+
edges.push({ source: node.id, target: child.id });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const spanTop = Math.min(...childSpans.map(s => s.top));
|
|
79
|
+
const spanBottom = Math.max(...childSpans.map(s => s.bottom));
|
|
80
|
+
const parentY = spanTop + (spanBottom - spanTop) / 2 - TREE_NODE_HEIGHT / 2;
|
|
81
|
+
|
|
82
|
+
const curColY = getNextY(depth);
|
|
83
|
+
const finalY = Math.max(parentY, curColY);
|
|
84
|
+
advanceY(depth, finalY - curColY + TREE_NODE_HEIGHT + TREE_NODE_SEP);
|
|
85
|
+
|
|
86
|
+
nodes.push({ id: node.id, x, y: finalY, width: TREE_NODE_WIDTH, height: TREE_NODE_HEIGHT, data: node, depth, hasChildren, isCollapsed });
|
|
87
|
+
return { top: Math.min(finalY, spanTop), bottom: Math.max(finalY + TREE_NODE_HEIGHT, spanBottom) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const root of map.capabilities) {
|
|
91
|
+
placeNode(root, 0);
|
|
92
|
+
for (const [col, y] of colNextY) {
|
|
93
|
+
colNextY.set(col, y + TREE_NODE_SEP);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (nodes.length === 0) {
|
|
98
|
+
return { nodes: [], edges: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, levelCounts: { band0: 0, band1: 0, band2: 0 } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const minX = Math.min(...nodes.map(n => n.x));
|
|
102
|
+
const minY = Math.min(...nodes.map(n => n.y));
|
|
103
|
+
const maxX = Math.max(...nodes.map(n => n.x + n.width));
|
|
104
|
+
const maxY = Math.max(...nodes.map(n => n.y + n.height));
|
|
105
|
+
|
|
106
|
+
const levelCounts: CapabilityTreeLevelCounts = { band0: 0, band1: 0, band2: 0 };
|
|
107
|
+
for (const n of nodes) {
|
|
108
|
+
if (n.depth <= 2) levelCounts.band0++;
|
|
109
|
+
else if (n.depth <= 4) levelCounts.band1++;
|
|
110
|
+
else levelCounts.band2++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { nodes, edges, bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, levelCounts };
|
|
114
|
+
}
|