@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.
Files changed (58) hide show
  1. package/dist/activities/__tests__/validate.test.js +32 -1
  2. package/dist/activities/validate.js +21 -5
  3. package/dist/activity-card/__tests__/example.test.js +1 -3
  4. package/dist/activity-card/__tests__/layout.test.js +2 -9
  5. package/dist/activity-card/__tests__/resolver.test.js +26 -1
  6. package/dist/activity-card/__tests__/validate.test.js +23 -1
  7. package/dist/activity-card/layout.js +1 -7
  8. package/dist/activity-card/resolver.js +34 -23
  9. package/dist/activity-card/types.d.ts +1 -1
  10. package/dist/activity-card/validate.js +22 -11
  11. package/dist/capability-map/index.d.ts +4 -0
  12. package/dist/capability-map/index.js +2 -0
  13. package/dist/capability-map/layout-tree.d.ts +42 -0
  14. package/dist/capability-map/layout-tree.js +65 -0
  15. package/dist/capability-map/render-capability-tree.d.ts +15 -0
  16. package/dist/capability-map/render-capability-tree.js +108 -0
  17. package/dist/capability-map/types.d.ts +2 -0
  18. package/dist/fgca/__tests__/parse-canonical.test.js +14 -1
  19. package/dist/fgca/__tests__/validate.test.js +2 -2
  20. package/dist/fgca/parse-canonical.js +20 -24
  21. package/dist/fgca/preview-layout.js +50 -1
  22. package/dist/fgca/types.d.ts +2 -0
  23. package/dist/fgca/validate.js +19 -11
  24. package/dist/theme/index.d.ts +1 -1
  25. package/dist/theme/index.js +1 -1
  26. package/dist/theme/themes.js +26 -3
  27. package/dist/theme/tokens.d.ts +40 -0
  28. package/dist/theme/tokens.js +28 -0
  29. package/dist/webview/render-activity-card.js +2 -3
  30. package/dist/webview/render-fgca.js +2 -2
  31. package/dist/webview/render-goals.js +28 -5
  32. package/package.json +1 -1
  33. package/src/activities/__tests__/validate.test.ts +35 -1
  34. package/src/activities/validate.ts +21 -5
  35. package/src/activity-card/__tests__/example.test.ts +1 -3
  36. package/src/activity-card/__tests__/layout.test.ts +2 -9
  37. package/src/activity-card/__tests__/resolver.test.ts +28 -1
  38. package/src/activity-card/__tests__/validate.test.ts +25 -1
  39. package/src/activity-card/layout.ts +1 -8
  40. package/src/activity-card/resolver.ts +34 -23
  41. package/src/activity-card/types.ts +1 -1
  42. package/src/activity-card/validate.ts +20 -11
  43. package/src/capability-map/index.ts +11 -0
  44. package/src/capability-map/layout-tree.ts +114 -0
  45. package/src/capability-map/render-capability-tree.ts +131 -0
  46. package/src/capability-map/types.ts +2 -0
  47. package/src/fgca/__tests__/parse-canonical.test.ts +15 -1
  48. package/src/fgca/__tests__/validate.test.ts +2 -2
  49. package/src/fgca/parse-canonical.ts +20 -30
  50. package/src/fgca/preview-layout.ts +53 -1
  51. package/src/fgca/types.ts +2 -0
  52. package/src/fgca/validate.ts +16 -8
  53. package/src/theme/index.ts +1 -1
  54. package/src/theme/themes.ts +27 -2
  55. package/src/theme/tokens.ts +30 -0
  56. package/src/webview/render-activity-card.ts +2 -3
  57. package/src/webview/render-fgca.ts +2 -2
  58. package/src/webview/render-goals.ts +26 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -15,9 +15,43 @@ const minimalValid = {
15
15
  };
16
16
 
17
17
  describe('validateActivities', () => {
18
- it('ACT-001 — accepts valid notation field', () => {
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 equal "activities"
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 "activities", got "${String(raw.notation)}"` });
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
- if (!Array.isArray(raw.activities)) {
33
- errors.push({ code: 'SCHEMA_INVALID', message: 'activities must be an array', path: 'activities' });
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', 'assessments', 'goals', 'changes']);
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('four chain sections in order: drivers, assessments, goals, changes', () => {
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', 'assessments', 'goals', 'changes']);
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: activity');
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 → Assessments → Goals → Changes ────────────────────
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* `activity_goal` relation originating at
108
- * `fromId`. A relation with a non-null `valid_to` has ended (the activity was
109
- * re-aimed) and is excluded — the card shows the currently-served goals.
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
- if (str(doc['type']) !== 'activity_goal') continue;
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* `activity_stakeholder`
129
- * relation originating at `fromId`. A relation with a non-null `valid_to` has
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
- if (str(doc['type']) !== 'activity_stakeholder') continue;
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
- const activities = collectByNotation(sources.elements, 'activity');
169
- const projectRec = activities.get(projectId);
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 ACTIVITY element after
172
- // exhausting the canonical resolution scope (§6.1): canon/elements/**
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
- `activity_card.project "${projectId}" not found after searching ` +
179
+ `action_card.project "${projectId}" not found after searching ` +
179
180
  `canon/elements/** and canon/views/activities/** — ` +
180
- `add a YAML file with notation: activity and id: "${projectId}" ` +
181
- `(e.g. canon/elements/05_implementation/activities/${projectId}.yaml)`,
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 activities) {
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 — active `activity_stakeholder` relations from the project ──
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 activeActivityStakeholders(sources.relations, projectId)) {
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' | 'assessments' | 'goals' | 'changes';
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 "activity-card", got "${String(raw['notation'])}"`,
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
- const block = raw['activity_card'];
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: activity_card (object)' });
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: 'activity_card.id is required' });
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: `activity_card.id "${card['id']}" must match ACTIVITY_CARD-[<middle>-]<INTEGER>`,
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: 'activity_card.project is required (an ACTIVITY-… id)' });
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: `activity_card.project "${card['project']}" is malformed; must match ACTIVITY-[<middle>-]<INTEGER>`,
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
+ }