@transitrix/diagrams 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activities/__tests__/validate.test.js +32 -1
- package/dist/activities/cpm.js +11 -2
- package/dist/activities/gantt.js +6 -3
- package/dist/activities/types.d.ts +1 -0
- package/dist/activities/validate.js +24 -8
- package/dist/activity-card/__tests__/resolver.test.js +22 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/resolver.js +28 -20
- package/dist/activity-card/validate.js +22 -11
- package/dist/capability-map/index.d.ts +4 -0
- package/dist/capability-map/index.js +2 -0
- package/dist/capability-map/layout-tree.d.ts +42 -0
- package/dist/capability-map/layout-tree.js +65 -0
- package/dist/capability-map/render-capability-tree.d.ts +15 -0
- package/dist/capability-map/render-capability-tree.js +108 -0
- package/dist/capability-map/types.d.ts +2 -0
- package/dist/compliance/__tests__/impact.test.js +198 -0
- package/dist/compliance/html.js +1 -7
- package/dist/compliance/impact.d.ts +24 -1
- package/dist/compliance/impact.js +97 -21
- package/dist/factor/__tests__/validate.test.js +3 -3
- package/dist/factor/types.d.ts +1 -1
- package/dist/factor/validate.js +2 -2
- package/dist/fgca/__tests__/chain-table.test.js +2 -2
- package/dist/fgca/__tests__/layout.test.js +11 -11
- package/dist/fgca/__tests__/preview-layout.test.js +7 -7
- package/dist/fgca/__tests__/resolver.test.js +2 -2
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/chain-table.d.ts +1 -1
- package/dist/fgca/chain-table.js +4 -4
- package/dist/fgca/index.d.ts +2 -2
- package/dist/fgca/index.js +1 -1
- package/dist/fgca/layout.d.ts +2 -2
- package/dist/fgca/layout.js +9 -9
- package/dist/fgca/nodeTypes.d.ts +1 -1
- package/dist/fgca/nodeTypes.js +2 -2
- package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
- package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
- package/dist/fgca/parse-canonical.js +24 -13
- package/dist/fgca/preview-layout.d.ts +1 -1
- package/dist/fgca/preview-layout.js +4 -4
- package/dist/fgca/resolver.js +3 -1
- package/dist/fgca/types.d.ts +13 -11
- package/dist/fgca/types.js +2 -2
- package/dist/fgca/validate.d.ts +2 -2
- package/dist/fgca/validate.js +34 -23
- package/dist/process-blueprint/index.d.ts +1 -1
- package/dist/process-blueprint/layout.js +64 -37
- package/dist/process-blueprint/types.d.ts +14 -0
- package/dist/scenarios/index.d.ts +1 -1
- package/dist/scenarios/types.d.ts +4 -4
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +28 -3
- package/dist/theme/tokens.d.ts +47 -1
- package/dist/theme/tokens.js +31 -1
- package/dist/webview/__tests__/entry-notations.test.js +2 -0
- package/dist/webview/__tests__/render-process.test.d.ts +1 -0
- package/dist/webview/__tests__/render-process.test.js +463 -0
- package/dist/webview/__tests__/render-util.test.d.ts +1 -0
- package/dist/webview/__tests__/render-util.test.js +54 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +9 -5
- package/dist/webview/render-activities.d.ts +21 -1
- package/dist/webview/render-activities.js +91 -56
- package/dist/webview/render-activity-card.d.ts +11 -1
- package/dist/webview/render-activity-card.js +11 -2
- package/dist/webview/render-blocks.d.ts +24 -1
- package/dist/webview/render-blocks.js +58 -24
- package/dist/webview/render-fgca.d.ts +27 -0
- package/dist/webview/render-fgca.js +30 -16
- package/dist/webview/render-goals.d.ts +26 -1
- package/dist/webview/render-goals.js +30 -20
- package/dist/webview/render-process-blueprint.d.ts +11 -1
- package/dist/webview/render-process-blueprint.js +118 -53
- package/dist/webview/render-process.d.ts +95 -0
- package/dist/webview/render-process.js +392 -0
- package/dist/webview/render-scenarios.js +2 -2
- package/dist/webview/render-util.d.ts +19 -6
- package/dist/webview/render-util.js +23 -15
- package/package.json +15 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/cpm.ts +14 -2
- package/src/activities/gantt.ts +6 -5
- package/src/activities/types.ts +1 -0
- package/src/activities/validate.ts +24 -8
- package/src/activity-card/__tests__/resolver.test.ts +24 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/resolver.ts +29 -20
- package/src/activity-card/validate.ts +20 -11
- package/src/capability-map/index.ts +11 -0
- package/src/capability-map/layout-tree.ts +114 -0
- package/src/capability-map/render-capability-tree.ts +131 -0
- package/src/capability-map/types.ts +2 -0
- package/src/compliance/__tests__/impact.test.ts +219 -0
- package/src/compliance/html.ts +1 -8
- package/src/compliance/impact.ts +141 -23
- package/src/factor/__tests__/validate.test.ts +3 -3
- package/src/factor/types.ts +1 -1
- package/src/factor/validate.ts +2 -2
- package/src/fgca/__tests__/chain-table.test.ts +2 -2
- package/src/fgca/__tests__/layout.test.ts +13 -13
- package/src/fgca/__tests__/preview-layout.test.ts +7 -7
- package/src/fgca/__tests__/resolver.test.ts +2 -2
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/chain-table.ts +5 -5
- package/src/fgca/index.ts +3 -3
- package/src/fgca/layout.ts +11 -11
- package/src/fgca/nodeTypes.ts +3 -3
- package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
- package/src/fgca/parse-canonical.ts +26 -15
- package/src/fgca/preview-layout.ts +5 -5
- package/src/fgca/resolver.ts +3 -1
- package/src/fgca/types.ts +15 -13
- package/src/fgca/validate.ts +32 -21
- package/src/process-blueprint/index.ts +1 -0
- package/src/process-blueprint/layout.ts +71 -42
- package/src/process-blueprint/types.ts +15 -0
- package/src/scenarios/index.ts +2 -2
- package/src/scenarios/types.ts +4 -4
- package/src/scenarios/validate.ts +3 -3
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +29 -2
- package/src/theme/tokens.ts +33 -1
- package/src/webview/__tests__/entry-notations.test.ts +2 -0
- package/src/webview/__tests__/render-process.test.ts +538 -0
- package/src/webview/__tests__/render-util.test.ts +66 -0
- package/src/webview/entry.ts +11 -5
- package/src/webview/render-activities.ts +107 -67
- package/src/webview/render-activity-card.ts +11 -2
- package/src/webview/render-blocks.ts +78 -28
- package/src/webview/render-fgca.ts +53 -29
- package/src/webview/render-goals.ts +45 -23
- package/src/webview/render-process-blueprint.ts +156 -82
- package/src/webview/render-process.ts +507 -0
- package/src/webview/render-scenarios.ts +3 -3
- package/src/webview/render-util.ts +27 -14
|
@@ -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 });
|
|
@@ -130,7 +146,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
130
146
|
}
|
|
131
147
|
|
|
132
148
|
// ACT-009: non-negative numeric fields
|
|
133
|
-
const numericFields = ['duration', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
|
|
149
|
+
const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
|
|
134
150
|
for (const field of numericFields) {
|
|
135
151
|
const val = act[field];
|
|
136
152
|
if (val !== undefined && val !== null) {
|
|
@@ -140,8 +156,8 @@ export function validateActivities(input: unknown): ActivityValidationResult {
|
|
|
140
156
|
}
|
|
141
157
|
}
|
|
142
158
|
|
|
143
|
-
// ACT-011: warn if no duration
|
|
144
|
-
if (act.duration === undefined || act.duration === null) {
|
|
159
|
+
// ACT-011: warn if no duration (duration_days is accepted as an alias)
|
|
160
|
+
if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
|
|
145
161
|
warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
|
|
146
162
|
}
|
|
147
163
|
}
|
|
@@ -41,7 +41,7 @@ const UNRELATED = {
|
|
|
41
41
|
name: 'Other',
|
|
42
42
|
parent: 'ACTIVITY-SOMETHING-ELSE-1',
|
|
43
43
|
};
|
|
44
|
-
const FACTOR = { notation: '
|
|
44
|
+
const FACTOR = { notation: 'driver', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
|
|
45
45
|
const GOAL = { notation: 'goal', id: 'GOAL-EU-MARKET-1', name: 'Access EU market', factors: ['FACTOR-EU-MDR-1'] };
|
|
46
46
|
const CHANGE = {
|
|
47
47
|
notation: 'change',
|
|
@@ -233,6 +233,29 @@ describe('resolveActivityCard', () => {
|
|
|
233
233
|
expect(r.errors.some((e) => e.code === 'PC-001')).toBe(true);
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
it('PC-001 diagnostic discloses paths searched and actionable hint (#344)', () => {
|
|
237
|
+
const r = resolveActivityCard(CARD, { elements: [FACTOR, GOAL, CHANGE], relations });
|
|
238
|
+
const pc001 = r.errors.find((e) => e.code === 'PC-001');
|
|
239
|
+
expect(pc001).toBeDefined();
|
|
240
|
+
// Must disclose both searched paths (§7 PC-001)
|
|
241
|
+
expect(pc001!.message).toContain('canon/elements/**');
|
|
242
|
+
expect(pc001!.message).toContain('canon/views/activities/**');
|
|
243
|
+
// Must include actionable hint with expected file pattern (§7 PC-001)
|
|
244
|
+
expect(pc001!.message).toContain('notation: action');
|
|
245
|
+
expect(pc001!.message).toContain(`id: "${CARD.activity_card.project}"`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
|
|
249
|
+
// Regression: the resolver must accept elements regardless of which subfolder
|
|
250
|
+
// they were loaded from (e.g. canon/elements/05_implementation/activities/).
|
|
251
|
+
// The resolver is filesystem-free, so we simulate the deep-path scenario by
|
|
252
|
+
// including the PROJECT element in the elements pool — just as loadCanon does
|
|
253
|
+
// after walking canon/elements/** recursively.
|
|
254
|
+
const r = resolveActivityCard(CARD, sources);
|
|
255
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
256
|
+
expect(r.errors.some((e) => e.code === 'PC-001')).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
236
259
|
it('resolves activity_type and status from the Activity element', () => {
|
|
237
260
|
const r = resolveActivityCard(CARD, {
|
|
238
261
|
elements: [{ ...PROJECT, activity_type: 'programme', status: 'on_track' }, CHILD, FACTOR, GOAL, CHANGE],
|
|
@@ -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', () => {
|
|
@@ -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,14 +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
|
|
173
|
+
// PC-001 — project must resolve to an admitted ACTION (or legacy ACTIVITY)
|
|
174
|
+
// element after exhausting the canonical resolution scope (§6.1).
|
|
172
175
|
if (!projectRec) {
|
|
173
176
|
errors.push({
|
|
174
177
|
code: 'PC-001',
|
|
175
|
-
message:
|
|
178
|
+
message:
|
|
179
|
+
`action_card.project "${projectId}" not found after searching ` +
|
|
180
|
+
`canon/elements/** and canon/views/activities/** — ` +
|
|
181
|
+
`add a YAML file with notation: action and id: "${projectId}" ` +
|
|
182
|
+
`(e.g. canon/elements/05_implementation/actions/${projectId}.yaml)`,
|
|
176
183
|
});
|
|
177
184
|
return { valid: false, errors, warnings };
|
|
178
185
|
}
|
|
@@ -268,9 +275,10 @@ export function resolveActivityCard(
|
|
|
268
275
|
}
|
|
269
276
|
|
|
270
277
|
// ── Motivation chain — expand against canon DRIVER / GOAL / CHANGE elements ──
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
const
|
|
278
|
+
// Canon elements use `notation: driver`; `notation: factor` accepted for backward compat.
|
|
279
|
+
const driverElemsLegacy = collectByNotation(sources.elements, 'factor');
|
|
280
|
+
const driverElemsNew = collectByNotation(sources.elements, 'driver');
|
|
281
|
+
const driverElems = new Map([...driverElemsLegacy, ...driverElemsNew]);
|
|
274
282
|
const goalElems = collectByNotation(sources.elements, 'goal');
|
|
275
283
|
const changeElems = collectByNotation(sources.elements, 'change');
|
|
276
284
|
|
|
@@ -342,13 +350,14 @@ export function resolveActivityCard(
|
|
|
342
350
|
});
|
|
343
351
|
|
|
344
352
|
// ── Child activities — parent = project id ──────────────────────────────────
|
|
353
|
+
// Look in both `notation: action` and legacy `notation: activity` elements.
|
|
345
354
|
const childActivities: ResolvedChildActivity[] = [];
|
|
346
|
-
for (const [id, rec] of
|
|
355
|
+
for (const [id, rec] of allActionElements) {
|
|
347
356
|
if (str(rec['parent']) !== projectId) continue;
|
|
348
357
|
childActivities.push({
|
|
349
358
|
id,
|
|
350
359
|
name: str(rec['name']) ?? id,
|
|
351
|
-
activity_type: str(rec['activity_type']),
|
|
360
|
+
activity_type: str(rec['action_type']) ?? str(rec['activity_type']),
|
|
352
361
|
start_date: str(rec['start_date']),
|
|
353
362
|
end_date: str(rec['end_date']),
|
|
354
363
|
owner: str(rec['owner']),
|
|
@@ -394,7 +403,7 @@ export function resolveActivityCard(
|
|
|
394
403
|
valid_to: validToOk ? (validToRaw as string) : undefined,
|
|
395
404
|
start_date: str(projectRec['start_date']),
|
|
396
405
|
end_date: str(projectRec['end_date']),
|
|
397
|
-
activity_type: str(projectRec['activity_type']),
|
|
406
|
+
activity_type: str(projectRec['action_type']) ?? str(projectRec['activity_type']),
|
|
398
407
|
status: str(projectRec['status']),
|
|
399
408
|
};
|
|
400
409
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CapabilityMapHeader } from './types.js';
|
|
2
|
+
import { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_NODE_SEP } from './layout-tree.js';
|
|
3
|
+
import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
|
|
4
|
+
import { escXml } from '../webview/render-util.js';
|
|
5
|
+
|
|
6
|
+
const PAD = 24;
|
|
7
|
+
const BTN_R = 8;
|
|
8
|
+
const LEGEND_H = 40;
|
|
9
|
+
const LABEL_MAX_CHARS = 28;
|
|
10
|
+
const LABEL_TRIM_CHARS = 26;
|
|
11
|
+
|
|
12
|
+
const BAND_LABELS = ['Levels 0–2', 'Levels 3–4', 'Levels 5+'] as const;
|
|
13
|
+
|
|
14
|
+
function depthBand(depth: number): 0 | 1 | 2 {
|
|
15
|
+
if (depth <= 2) return 0;
|
|
16
|
+
if (depth <= 4) return 1;
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncate(s: string, max: number, trim: number): string {
|
|
21
|
+
return s.length > max ? s.slice(0, trim) + '…' : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RenderCapabilityTreeOptions {
|
|
25
|
+
collapsedIds?: Set<string>;
|
|
26
|
+
curvature?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders a capability-map as a left-to-right SVG node tree with DSM-matching
|
|
31
|
+
* visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
|
|
32
|
+
*
|
|
33
|
+
* The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
|
|
34
|
+
* that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
|
|
35
|
+
* in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
|
|
36
|
+
* for the webview click handler.
|
|
37
|
+
*/
|
|
38
|
+
export function renderCapabilityTreeSvg(
|
|
39
|
+
map: CapabilityMapHeader,
|
|
40
|
+
opts: RenderCapabilityTreeOptions = {},
|
|
41
|
+
): string {
|
|
42
|
+
const { collapsedIds = new Set(), curvature = DEFAULT_EDGE_CURVATURE } = opts;
|
|
43
|
+
const layout = layoutCapabilityTree(map, collapsedIds);
|
|
44
|
+
|
|
45
|
+
if (layout.nodes.length === 0) {
|
|
46
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ox = -layout.bounds.x + PAD;
|
|
50
|
+
const oy = -layout.bounds.y + PAD;
|
|
51
|
+
const svgW = layout.bounds.width + PAD * 2 + BTN_R;
|
|
52
|
+
const svgH = layout.bounds.height + PAD * 2 + LEGEND_H + TREE_NODE_SEP;
|
|
53
|
+
|
|
54
|
+
const nodeMap = new Map(layout.nodes.map(n => [n.id, n]));
|
|
55
|
+
|
|
56
|
+
const edgeSvg = layout.edges.map(e => {
|
|
57
|
+
const s = nodeMap.get(e.source);
|
|
58
|
+
const t = nodeMap.get(e.target);
|
|
59
|
+
if (!s || !t) return '';
|
|
60
|
+
const sx = s.x + ox + s.width;
|
|
61
|
+
const sy = s.y + oy + s.height / 2;
|
|
62
|
+
const tx = t.x + ox;
|
|
63
|
+
const ty = t.y + oy + t.height / 2;
|
|
64
|
+
return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature)}" class="diagram-edge"/>`;
|
|
65
|
+
}).filter(Boolean).join('\n');
|
|
66
|
+
|
|
67
|
+
const nodeSvg = layout.nodes.map(n => {
|
|
68
|
+
const x = n.x + ox;
|
|
69
|
+
const y = n.y + oy;
|
|
70
|
+
const band = depthBand(n.depth);
|
|
71
|
+
const mat = Math.max(1, Math.min(5, n.data.current_maturity | 0));
|
|
72
|
+
|
|
73
|
+
const nameText = truncate(n.data.name, LABEL_MAX_CHARS, LABEL_TRIM_CHARS);
|
|
74
|
+
const nameTitle = n.data.name.length > LABEL_MAX_CHARS ? escXml(n.data.name) : '';
|
|
75
|
+
|
|
76
|
+
// Maturity badge: left side of node
|
|
77
|
+
const badgeW = 28;
|
|
78
|
+
const badgeH = 18;
|
|
79
|
+
const badgeX = x + 10;
|
|
80
|
+
const badgeY = y + (TREE_NODE_HEIGHT - badgeH) / 2;
|
|
81
|
+
|
|
82
|
+
// Text area: right of badge
|
|
83
|
+
const textX = badgeX + badgeW + 8;
|
|
84
|
+
const nameY = y + TREE_NODE_HEIGHT / 2 - 8;
|
|
85
|
+
const idY = y + TREE_NODE_HEIGHT / 2 + 9;
|
|
86
|
+
|
|
87
|
+
// Collapse button: centered on right edge of node
|
|
88
|
+
const btnCx = x + TREE_NODE_WIDTH;
|
|
89
|
+
const btnCy = y + TREE_NODE_HEIGHT / 2;
|
|
90
|
+
|
|
91
|
+
const collapseBtn = n.hasChildren
|
|
92
|
+
? `<circle class="tree-collapse-btn" cx="${btnCx}" cy="${btnCy}" r="${BTN_R}" data-node-id="${escXml(n.id)}" data-collapsed="${n.isCollapsed}"/>
|
|
93
|
+
<text class="tree-collapse-lbl" x="${btnCx}" y="${btnCy}" text-anchor="middle">${n.isCollapsed ? '+' : '−'}</text>`
|
|
94
|
+
: '';
|
|
95
|
+
|
|
96
|
+
return `<g${nameTitle ? ` title="${nameTitle}"` : ''}>
|
|
97
|
+
<rect class="tree-level-${band}" x="${x}" y="${y}" width="${TREE_NODE_WIDTH}" height="${TREE_NODE_HEIGHT}" rx="11" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="3"/>
|
|
98
|
+
<rect class="tree-maturity-${mat}" x="${badgeX}" y="${badgeY}" width="${badgeW}" height="${badgeH}" rx="4"/>
|
|
99
|
+
<text class="text-pill" x="${badgeX + badgeW / 2}" y="${badgeY + badgeH / 2}" text-anchor="middle" fill="white">L${mat}</text>
|
|
100
|
+
<text class="text-primary" x="${textX}" y="${nameY}" dominant-baseline="central">${escXml(nameText)}</text>
|
|
101
|
+
<text class="text-id" x="${textX}" y="${idY}" dominant-baseline="central">${escXml(n.id)}</text>
|
|
102
|
+
${collapseBtn}
|
|
103
|
+
</g>`;
|
|
104
|
+
}).join('\n');
|
|
105
|
+
|
|
106
|
+
// Legend band: proportional coloured blocks per level-count band
|
|
107
|
+
const legendY = layout.bounds.height + PAD * 2 + TREE_NODE_SEP / 2;
|
|
108
|
+
const legendW = svgW - PAD * 2;
|
|
109
|
+
const totalNodes = layout.nodes.length || 1;
|
|
110
|
+
const bandCounts = [layout.levelCounts.band0, layout.levelCounts.band1, layout.levelCounts.band2];
|
|
111
|
+
let legendX = PAD;
|
|
112
|
+
const legendSvg = bandCounts.map((count, i) => {
|
|
113
|
+
const bw = Math.max(1, Math.round(legendW * count / totalNodes));
|
|
114
|
+
const bx = legendX;
|
|
115
|
+
legendX += bw;
|
|
116
|
+
if (count === 0) return '';
|
|
117
|
+
return `<rect class="tree-level-${i}" x="${bx}" y="${legendY}" width="${bw}" height="24" rx="4" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="1"/>
|
|
118
|
+
<text class="text-id" x="${bx + bw / 2}" y="${legendY + 12}" text-anchor="middle" dominant-baseline="central">${escXml(BAND_LABELS[i])}</text>`;
|
|
119
|
+
}).filter(Boolean).join('\n');
|
|
120
|
+
|
|
121
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
|
|
122
|
+
<defs>
|
|
123
|
+
<marker id="cap-tree-arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
|
|
124
|
+
<path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
|
|
125
|
+
</marker>
|
|
126
|
+
</defs>
|
|
127
|
+
${edgeSvg}
|
|
128
|
+
${nodeSvg}
|
|
129
|
+
${legendSvg}
|
|
130
|
+
</svg>`;
|
|
131
|
+
}
|