@transitrix/diagrams 1.8.11 → 1.8.18

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 (45) hide show
  1. package/dist/activities/validate.js +9 -1
  2. package/dist/blocks/index.d.ts +5 -4
  3. package/dist/blocks/index.js +3 -2
  4. package/dist/blocks/layout.d.ts +8 -1
  5. package/dist/blocks/layout.js +57 -0
  6. package/dist/blocks/templates/index.d.ts +9 -0
  7. package/dist/blocks/templates/index.js +11 -0
  8. package/dist/blocks/templates/raci.d.ts +9 -0
  9. package/dist/blocks/templates/raci.js +24 -0
  10. package/dist/blocks/types.d.ts +58 -0
  11. package/dist/blocks/validate.d.ts +31 -1
  12. package/dist/blocks/validate.js +127 -0
  13. package/dist/capability-map/validate.js +41 -0
  14. package/dist/fgca/parse-canonical.js +8 -0
  15. package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
  16. package/dist/repo-validate/check-element-hygiene.js +211 -0
  17. package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
  18. package/dist/repo-validate/check-strategy-chain.js +491 -0
  19. package/dist/repo-validate/index.d.ts +2 -1
  20. package/dist/repo-validate/index.js +1 -0
  21. package/dist/repo-validate/resolve-model.d.ts +5 -0
  22. package/dist/repo-validate/resolve-model.js +71 -0
  23. package/dist/repo-validate/types.d.ts +70 -1
  24. package/dist/repo-validate/types.js +22 -3
  25. package/dist/repo-validate/validate-repo.d.ts +14 -3
  26. package/dist/repo-validate/validate-repo.js +21 -8
  27. package/dist/webview/render-blocks.d.ts +23 -1
  28. package/dist/webview/render-blocks.js +100 -2
  29. package/package.json +1 -1
  30. package/src/activities/validate.ts +9 -1
  31. package/src/blocks/index.ts +16 -2
  32. package/src/blocks/layout.ts +70 -0
  33. package/src/blocks/templates/index.ts +13 -0
  34. package/src/blocks/templates/raci.ts +28 -0
  35. package/src/blocks/types.ts +67 -0
  36. package/src/blocks/validate.ts +157 -1
  37. package/src/capability-map/validate.ts +40 -0
  38. package/src/fgca/parse-canonical.ts +8 -0
  39. package/src/repo-validate/check-element-hygiene.ts +236 -0
  40. package/src/repo-validate/check-strategy-chain.ts +528 -0
  41. package/src/repo-validate/index.ts +2 -1
  42. package/src/repo-validate/resolve-model.ts +79 -0
  43. package/src/repo-validate/types.ts +95 -4
  44. package/src/repo-validate/validate-repo.ts +21 -8
  45. package/src/webview/render-blocks.ts +159 -2
@@ -1,8 +1,27 @@
1
- import type { Block } from './types.js';
1
+ import type { Block, GridColumn, GridHeader, GridRow } from './types.js';
2
2
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
3
 
4
4
  export type { ValidationError, ValidationWarning, ValidationResult };
5
5
 
6
+ /**
7
+ * A template-level check over an already well-formed grid document (matrix
8
+ * subset, §4a). Templates built on the `grid:` root (e.g. RACI) supply their
9
+ * own `GridRule`s for vocabulary-specific invariants — the base validator
10
+ * intentionally does not fix what `assign` values mean (08-blocks.md §6a), so
11
+ * those invariants never live inside `validateGrid` itself.
12
+ */
13
+ export interface GridRule {
14
+ /** Template-namespaced rule code, e.g. "RACI-001" — never a BL-0xx code. */
15
+ ruleId: string;
16
+ severity: 'error' | 'warning';
17
+ check(grid: GridHeader): Array<{ message: string; path?: string }>;
18
+ }
19
+
20
+ export interface ValidateGridOptions {
21
+ /** Extra template-level rules to run once the base grid is well-formed. */
22
+ rules?: GridRule[];
23
+ }
24
+
6
25
  /** Document-level ID grammar: BLOCKS-[<middle>-]<INTEGER>. */
7
26
  const BLOCKS_DOC_ID_RE = /^BLOCKS(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
8
27
 
@@ -188,6 +207,143 @@ export function validateNestedBlocks(input: unknown): ValidationResult {
188
207
  return { valid: errors.length === 0, errors, warnings };
189
208
  }
190
209
 
210
+ /** Matrix subset (§4a): validate a `grid:` root document — BL-021..BL-025. */
211
+ export function validateGrid(input: unknown, options: ValidateGridOptions = {}): ValidationResult {
212
+ const errors: ValidationError[] = [];
213
+ const warnings: ValidationWarning[] = [];
214
+
215
+ if (!input || typeof input !== 'object') {
216
+ return { valid: false, errors: [{ code: 'BL-001', message: 'Input must be an object' }], warnings };
217
+ }
218
+
219
+ const raw = input as Record<string, unknown>;
220
+
221
+ if ('notation' in raw && raw['notation'] !== 'blocks') {
222
+ errors.push({
223
+ code: 'BL-001',
224
+ message: `notation must be "blocks", got "${String(raw['notation'])}"`,
225
+ });
226
+ }
227
+
228
+ if (!('grid' in raw) || !raw['grid'] || typeof raw['grid'] !== 'object') {
229
+ errors.push({ code: 'BL-020', message: 'Missing required root key: grid' });
230
+ return { valid: false, errors, warnings };
231
+ }
232
+
233
+ const grid = raw['grid'] as Record<string, unknown>;
234
+ const rawColumns = grid['columns'];
235
+ const rawRows = grid['rows'];
236
+
237
+ if (!Array.isArray(rawColumns) || rawColumns.length === 0) {
238
+ errors.push({ code: 'BL-021', message: 'grid.columns must be a non-empty array' });
239
+ }
240
+ if (!Array.isArray(rawRows) || rawRows.length === 0) {
241
+ errors.push({ code: 'BL-022', message: 'grid.rows must be a non-empty array' });
242
+ }
243
+ if (errors.length > 0) return { valid: false, errors, warnings };
244
+
245
+ const columnIds = new Set<string>();
246
+ const columns: GridColumn[] = [];
247
+ (rawColumns as unknown[]).forEach((entry, i) => {
248
+ const path = `grid.columns[${i}]`;
249
+ const c = entry as Record<string, unknown>;
250
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(c['id']) || !isNonEmptyString(c['name'])) {
251
+ errors.push({ code: 'BL-023', message: `${path} must have non-empty id and name`, path });
252
+ return;
253
+ }
254
+ const id = c['id'].trim();
255
+ if (columnIds.has(id)) {
256
+ errors.push({ code: 'BL-024', message: `Duplicate column id: "${id}"`, path });
257
+ } else {
258
+ columnIds.add(id);
259
+ }
260
+ columns.push({ id, name: c['name'] });
261
+ });
262
+
263
+ const rowIds = new Set<string>();
264
+ const rows: GridRow[] = [];
265
+ (rawRows as unknown[]).forEach((entry, i) => {
266
+ const path = `grid.rows[${i}]`;
267
+ const r = entry as Record<string, unknown>;
268
+ if (!entry || typeof entry !== 'object' || !isNonEmptyString(r['id']) || !isNonEmptyString(r['name'])) {
269
+ errors.push({ code: 'BL-023', message: `${path} must have non-empty id and name`, path });
270
+ return;
271
+ }
272
+ const id = r['id'].trim();
273
+ if (rowIds.has(id)) {
274
+ errors.push({ code: 'BL-024', message: `Duplicate row id: "${id}"`, path });
275
+ } else {
276
+ rowIds.add(id);
277
+ }
278
+
279
+ const rawAssign = r['assign'];
280
+ let assign: Record<string, unknown> | undefined;
281
+ if (rawAssign !== undefined) {
282
+ if (!rawAssign || typeof rawAssign !== 'object' || Array.isArray(rawAssign)) {
283
+ errors.push({ code: 'BL-023', message: `${path}.assign must be a map when present`, path });
284
+ } else {
285
+ assign = rawAssign as Record<string, unknown>;
286
+ for (const colId of Object.keys(assign)) {
287
+ if (!columnIds.has(colId)) {
288
+ errors.push({
289
+ code: 'BL-025',
290
+ message: `${path}.assign references unknown column id "${colId}"`,
291
+ path,
292
+ });
293
+ }
294
+ }
295
+ }
296
+ }
297
+ rows.push({ id, name: r['name'], assign });
298
+ });
299
+
300
+ if (errors.length > 0) return { valid: false, errors, warnings };
301
+
302
+ const grid_: GridHeader = { columns, rows };
303
+ for (const rule of options.rules ?? []) {
304
+ for (const finding of rule.check(grid_)) {
305
+ const entry = { code: rule.ruleId, message: finding.message, path: finding.path };
306
+ if (rule.severity === 'error') errors.push(entry);
307
+ else warnings.push(entry);
308
+ }
309
+ }
310
+
311
+ return { valid: errors.length === 0, errors, warnings };
312
+ }
313
+
314
+ export interface ValidateBlocksOptions extends ValidateGridOptions {}
315
+
316
+ /**
317
+ * Root-form dispatcher (§4/§4a): a document declares exactly one of
318
+ * `nested_blocks` (tree form) or `grid` (matrix subset) — never both, never
319
+ * neither (BL-020). Delegates to whichever form is present.
320
+ */
321
+ export function validateBlocks(input: unknown, options: ValidateBlocksOptions = {}): ValidationResult {
322
+ if (!input || typeof input !== 'object') {
323
+ return { valid: false, errors: [{ code: 'BL-001', message: 'Input must be an object' }], warnings: [] };
324
+ }
325
+
326
+ const raw = input as Record<string, unknown>;
327
+ const hasNested = raw['nested_blocks'] !== undefined && raw['nested_blocks'] !== null;
328
+ const hasGrid = raw['grid'] !== undefined && raw['grid'] !== null;
329
+
330
+ if (hasNested && hasGrid) {
331
+ return {
332
+ valid: false,
333
+ errors: [{ code: 'BL-020', message: 'Document root must declare exactly one of nested_blocks or grid, not both' }],
334
+ warnings: [],
335
+ };
336
+ }
337
+ if (hasGrid) return validateGrid(input, options);
338
+ if (hasNested) return validateNestedBlocks(input);
339
+
340
+ return {
341
+ valid: false,
342
+ errors: [{ code: 'BL-020', message: 'Document root must declare exactly one of nested_blocks or grid' }],
343
+ warnings: [],
344
+ };
345
+ }
346
+
191
347
  /**
192
348
  * Convenience: validate a single `Block` subtree. Used by tests and by
193
349
  * downstream consumers that need to check a sub-document.
@@ -21,10 +21,17 @@ export function validateCapabilityMap(input: unknown): ValidationResult {
21
21
  }
22
22
  const raw = input as Record<string, unknown>;
23
23
 
24
+ // HDR-001/002 — DSM's own rule codes for the same header check CMAP-001
25
+ // already makes (api02/internal/importer/capabilities.go ValidateCapMap),
26
+ // added alongside it (not instead of it) so DSM can map a finding straight
27
+ // back onto its own import-log taxonomy while CMAP-001 stays the richer,
28
+ // pre-existing Studio-native code for this notation's own validator.
24
29
  if (!('notation' in raw)) {
25
30
  errors.push({ code: 'CMAP-001', message: 'Missing required field: notation' });
31
+ errors.push({ code: 'HDR-001', message: 'notation field is missing' });
26
32
  } else if (raw['notation'] !== 'capability-map') {
27
33
  errors.push({ code: 'CMAP-001', message: `notation must be "capability-map", got "${raw['notation']}"` });
34
+ errors.push({ code: 'HDR-002', message: `notation must be "capability-map", got "${raw['notation']}"` });
28
35
  }
29
36
  if (errors.length > 0) return { valid: false, errors, warnings };
30
37
 
@@ -116,6 +123,39 @@ function validateCapabilityTree(
116
123
  if (node['applications'] !== undefined && !Array.isArray(node['applications']))
117
124
  errors.push({ code: 'CMAP-003', message: `${nodePath}: applications must be an array` });
118
125
 
126
+ // LIFECYCLE-001/004 — DSM's own rule codes (capabilities.go
127
+ // ValidateCapMap/validateCapMapNodes) for `valid_from`/`valid_to`, the
128
+ // CONTRACT.md §7 primitive-lifecycle fields. Net new: this notation's own
129
+ // validator has no equivalent check today — `CapabilityNode` (types.ts)
130
+ // does not even declare these fields, since Studio's own schema tracks
131
+ // lifecycle via `target_date` instead. The fields are still read directly
132
+ // off the raw untyped node here because real capability-map documents
133
+ // (e.g. organizations/acme_corp) author them inline per §7 regardless.
134
+ {
135
+ const id = typeof node['id'] === 'string' ? (node['id'] as string) : nodePath;
136
+ const validFrom = node['valid_from'];
137
+ const validTo = node['valid_to'];
138
+ let fromDate: string | undefined;
139
+ let toDate: string | undefined;
140
+ if (validFrom !== undefined && validFrom !== null) {
141
+ if (typeof validFrom !== 'string' || !DATE_RE.test(validFrom)) {
142
+ errors.push({ code: 'LIFECYCLE-001', message: `capability ${id} valid_from "${String(validFrom)}" is not a valid YYYY-MM-DD date (must be quoted)` });
143
+ } else {
144
+ fromDate = validFrom;
145
+ }
146
+ }
147
+ if (validTo !== undefined && validTo !== null) {
148
+ if (typeof validTo !== 'string' || !DATE_RE.test(validTo)) {
149
+ errors.push({ code: 'LIFECYCLE-001', message: `capability ${id} valid_to "${String(validTo)}" is not a valid YYYY-MM-DD date (must be quoted)` });
150
+ } else {
151
+ toDate = validTo;
152
+ }
153
+ }
154
+ if (fromDate && toDate && toDate < fromDate) {
155
+ errors.push({ code: 'LIFECYCLE-004', message: `capability ${id} valid_to ${toDate} is before valid_from ${fromDate}` });
156
+ }
157
+ }
158
+
119
159
  if (node['children'] !== undefined) {
120
160
  if (!Array.isArray(node['children'])) {
121
161
  errors.push({ code: 'CMAP-003', message: `${nodePath}: children must be an array` });
@@ -130,6 +130,14 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
130
130
  }
131
131
  const activitiesRaw = asObjectArray(raw['actions']);
132
132
  if (activitiesRaw === null && 'activities' in raw) {
133
+ // DSM's Go ValidateFGCA (fgca.go) still accepts this as the deprecated
134
+ // "activities" key and promotes it with a DGCA-DEPR warning, not an
135
+ // error. Deliberately NOT matched here — PR #320 ("drop deprecated
136
+ // notation aliases fgca/fga/activities/activity-card") already removed
137
+ // that leniency from this validator on purpose. Re-introducing
138
+ // DGCA-DEPR would reverse a deliberate, shipped decision, not fill a
139
+ // gap; see docs/validation.md's "Standalone-element envelope rules"
140
+ // section for the full note.
133
141
  errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
134
142
  }
135
143
 
@@ -0,0 +1,236 @@
1
+ // Standalone-element envelope checks — the error-severity subset of DSM's Go
2
+ // `Validate*Element` functions (`api02/internal/importer/{goal_element,
3
+ // action_element}.go` in transitrix-dsm), ported onto the standalone-element
4
+ // repo shape (`canon/elements/**`) that `validate --scope=repo` already
5
+ // loads. Sibling to `check-strategy-chain.ts` (which covers cross-element
6
+ // reference/cycle rules); this file covers the per-element envelope rules —
7
+ // id presence/grammar, name presence, and the ACTION type vocabulary +
8
+ // deprecated-alias warnings.
9
+ //
10
+ // DSM identifies "this file is a GOAL/ACTION element" by its folder location
11
+ // during the import walk (`canon/elements/01_motivation/goals/`,
12
+ // `canon/elements/05_implementation/actions/`) — the validator functions
13
+ // receive an already-typed struct, so a wrong `notation` value on that
14
+ // struct is still checkable (`GOAL-ELEM-001`/the notation branch of
15
+ // `ACTION-001`). This repo-scope model has no such pre-typing
16
+ // (`RepoModelInput.elements` is a flat, untyped list from `canon/elements/**`)
17
+ // — candidate selection here is content-based (the element's own `notation`
18
+ // field), same convention as `check-strategy-chain.ts`'s `collectByNotation`.
19
+ // That means a candidate is by construction already notation-correct, so
20
+ // GOAL-ELEM-001's and ACTION-001's "wrong notation" case can never fire here
21
+ // — tried an id-prefix-based candidate signal instead (matching on a `GOAL-`/
22
+ // `ACTION-` id TYPE prefix regardless of notation) to cover that case, but it
23
+ // produced false positives against this repo's own test fixtures, which
24
+ // reuse a `GOAL-`-prefixed id under a `goals/` path for an unrelated
25
+ // notation as a referential-integrity test double
26
+ // (`validate-repo.test.ts`'s `GOAL-OPS-1`/notation:'assessment' fixture) —
27
+ // there is no reliable, adopter-folder-layout-independent way to tell
28
+ // "misfiled element" from "id reused for something else" without DSM's own
29
+ // folder-based typing. Flagged for a decision, same as `GOALS-008`: skip
30
+ // permanently, or scope a follow-up once the repo-scope model gains
31
+ // path-aware typing.
32
+ //
33
+ // Rule codes are DSM's own — not invented here — so DSM can map a CLI
34
+ // finding straight back onto its import-log taxonomy (`RepoFinding.ruleId`).
35
+ //
36
+ // Ported (error-severity, blocking):
37
+ // GOAL-ELEM-002 — GOAL element `id` is missing.
38
+ // GOAL-ELEM-003 — GOAL element `name` is missing.
39
+ // ACTION-001 — ACTION element `id` or `name` is missing.
40
+ // ACTION-002 — ACTION element `type` is set but not one of
41
+ // Initiative | Strategic Initiative | Programme |
42
+ // Project | Task (Strategic Initiative is an accepted
43
+ // alias for Initiative). DSM treats this as fatal — the
44
+ // type vocabulary has real behavioural teeth downstream.
45
+ //
46
+ // Ported (warning-severity, advisory):
47
+ // GOAL-ELEM-002 — GOAL element `id` does not match the canonical
48
+ // `GOAL-[<middle>-]<INTEGER>` grammar (non-fatal —
49
+ // DSM still imports it).
50
+ // ACTION-001 — ACTION element `id` does not match the canonical
51
+ // `ACTION-[<middle>-]<INTEGER>` grammar.
52
+ // ACTION-005 — ACTION element uses a deprecated alias: `notation:
53
+ // activity` (pre-2026-06-25), an `ACTIVITY-` id prefix,
54
+ // or the `activity_type` field (rename to `type`).
55
+ //
56
+ // Not ported: GOALS-007 (duplicate id) / ACT-004 (duplicate id) — already
57
+ // covered generically by `validate-repo.ts`'s `checkIdUniqueness`, which
58
+ // spans every canon element/relation id, not just GOAL/ACTION. Porting a
59
+ // second, notation-scoped duplicate check here would just double-report the
60
+ // same defect under two rule codes.
61
+
62
+ import { docId } from './validate-repo.js';
63
+ import type { RepoDoc, RepoFinding, RepoModelInput } from './types.js';
64
+
65
+ const PScope: RepoFinding['scope'] = 'repo';
66
+
67
+ const GOAL_ELEM_ID_RE = /^GOAL-([A-Z0-9]+-)*[0-9]+$/;
68
+ const ACTION_ELEM_ID_RE = /^ACTION-([A-Z0-9]+-)*[0-9]+$/;
69
+
70
+ /** ACTION scale-level vocabulary (elements/24-action.md §1). "Strategic
71
+ * Initiative" is an accepted alias for "Initiative", matching DSM's
72
+ * `actionTypeVocabulary` (action_element.go). */
73
+ const ACTION_TYPE_VOCABULARY = new Set([
74
+ 'Initiative',
75
+ 'Strategic Initiative',
76
+ 'Programme',
77
+ 'Project',
78
+ 'Task',
79
+ ]);
80
+
81
+ function readString(data: Record<string, unknown>, key: string): string | undefined {
82
+ const v = data[key];
83
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
84
+ }
85
+
86
+ interface ElementCandidate {
87
+ id: string | undefined;
88
+ doc: RepoDoc;
89
+ data: Record<string, unknown>;
90
+ }
91
+
92
+ function collectCandidates(
93
+ elements: RepoDoc[],
94
+ matches: (data: Record<string, unknown>) => boolean,
95
+ ): ElementCandidate[] {
96
+ const out: ElementCandidate[] = [];
97
+ for (const doc of elements) {
98
+ if (!doc.data) continue;
99
+ if (!matches(doc.data)) continue;
100
+ out.push({ id: docId(doc) ?? undefined, doc, data: doc.data });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** GOAL-ELEM-002/003 — the GOAL element envelope: id presence + grammar,
106
+ * name presence. Candidate selection is content-based (`notation === 'goal'`)
107
+ * — see the module header for why `GOAL-ELEM-001` (wrong notation) isn't
108
+ * ported here. */
109
+ function checkGoalElements(elements: RepoDoc[], findings: RepoFinding[]): void {
110
+ const candidates = collectCandidates(elements, (data) => data['notation'] === 'goal');
111
+
112
+ for (const { id, data } of candidates) {
113
+ const where = id ?? '';
114
+
115
+ if (!id) {
116
+ findings.push({
117
+ scope: PScope,
118
+ id: '',
119
+ ruleId: 'GOAL-ELEM-002',
120
+ message: 'GOAL-ELEM-002: GOAL element id is missing.',
121
+ });
122
+ } else if (!GOAL_ELEM_ID_RE.test(id)) {
123
+ findings.push({
124
+ scope: PScope,
125
+ id,
126
+ ruleId: 'GOAL-ELEM-002',
127
+ severity: 'warning',
128
+ message: `GOAL-ELEM-002: goal id '${id}' does not match GOAL-[<middle>-]<INTEGER>.`,
129
+ });
130
+ }
131
+
132
+ if (!readString(data, 'name')) {
133
+ findings.push({
134
+ scope: PScope,
135
+ id: where,
136
+ ruleId: 'GOAL-ELEM-003',
137
+ message: `GOAL-ELEM-003: GOAL element '${where || '(no id)'}' name is missing.`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+
143
+ /** ACTION-001/002/005 — the ACTION element envelope: id presence + grammar,
144
+ * name presence, type vocabulary, and the pre-2026-06-25 deprecated aliases
145
+ * (`notation: activity`, `ACTIVITY-` id prefix, `activity_type` field).
146
+ * Candidate selection is content-based (`notation === 'action'` or the
147
+ * deprecated `'activity'` alias) — see the module header for why
148
+ * `ACTION-001`'s "wrong notation" case isn't ported here. */
149
+ function checkActionElements(elements: RepoDoc[], findings: RepoFinding[]): void {
150
+ const candidates = collectCandidates(
151
+ elements,
152
+ (data) => data['notation'] === 'action' || data['notation'] === 'activity',
153
+ );
154
+
155
+ for (const { id, data } of candidates) {
156
+ const where = id ?? '';
157
+
158
+ if (data['notation'] === 'activity') {
159
+ findings.push({
160
+ scope: PScope,
161
+ id: where,
162
+ ruleId: 'ACTION-005',
163
+ severity: 'warning',
164
+ message: `ACTION-005: element '${where || '(no id)'}' notation 'activity' is deprecated; migrate to 'action'.`,
165
+ });
166
+ }
167
+
168
+ if (!id) {
169
+ findings.push({
170
+ scope: PScope,
171
+ id: '',
172
+ ruleId: 'ACTION-001',
173
+ message: 'ACTION-001: ACTION element id is missing.',
174
+ });
175
+ } else if (id.startsWith('ACTIVITY-')) {
176
+ findings.push({
177
+ scope: PScope,
178
+ id,
179
+ ruleId: 'ACTION-005',
180
+ severity: 'warning',
181
+ message: `ACTION-005: id '${id}' uses the deprecated ACTIVITY- prefix; migrate to ACTION-.`,
182
+ });
183
+ } else if (!ACTION_ELEM_ID_RE.test(id)) {
184
+ findings.push({
185
+ scope: PScope,
186
+ id,
187
+ ruleId: 'ACTION-001',
188
+ severity: 'warning',
189
+ message: `ACTION-001: action id '${id}' does not match ACTION-[<middle>-]<INTEGER>.`,
190
+ });
191
+ }
192
+
193
+ if (!readString(data, 'name')) {
194
+ findings.push({
195
+ scope: PScope,
196
+ id: where,
197
+ ruleId: 'ACTION-001',
198
+ message: `ACTION-001: ACTION element '${where || '(no id)'}' name is missing.`,
199
+ });
200
+ }
201
+
202
+ let actionType = readString(data, 'type');
203
+ if (!actionType) {
204
+ const legacy = readString(data, 'activity_type');
205
+ if (legacy) {
206
+ findings.push({
207
+ scope: PScope,
208
+ id: where,
209
+ ruleId: 'ACTION-005',
210
+ severity: 'warning',
211
+ message: `ACTION-005: element '${where || '(no id)'}' field 'activity_type' is deprecated; migrate to 'type'.`,
212
+ });
213
+ actionType = legacy;
214
+ }
215
+ }
216
+ if (actionType && !ACTION_TYPE_VOCABULARY.has(actionType)) {
217
+ findings.push({
218
+ scope: PScope,
219
+ id: where,
220
+ ruleId: 'ACTION-002',
221
+ message: `ACTION-002: element '${where || '(no id)'}' type '${actionType}' is not one of Initiative, Programme, Project, Task.`,
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Run the standalone-element envelope checks (GOAL-ELEM-001..003,
229
+ * ACTION-001/002/005) over the loaded element set and append findings.
230
+ * Called from `validateRepoModel` alongside `checkStrategyChainSemantics`.
231
+ * Pure, deterministic order.
232
+ */
233
+ export function checkElementHygiene(input: RepoModelInput, findings: RepoFinding[]): void {
234
+ checkGoalElements(input.elements, findings);
235
+ checkActionElements(input.elements, findings);
236
+ }