@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.
- package/dist/activities/validate.js +9 -1
- package/dist/blocks/index.d.ts +5 -4
- package/dist/blocks/index.js +3 -2
- package/dist/blocks/layout.d.ts +8 -1
- package/dist/blocks/layout.js +57 -0
- package/dist/blocks/templates/index.d.ts +9 -0
- package/dist/blocks/templates/index.js +11 -0
- package/dist/blocks/templates/raci.d.ts +9 -0
- package/dist/blocks/templates/raci.js +24 -0
- package/dist/blocks/types.d.ts +58 -0
- package/dist/blocks/validate.d.ts +31 -1
- package/dist/blocks/validate.js +127 -0
- package/dist/capability-map/validate.js +41 -0
- package/dist/fgca/parse-canonical.js +8 -0
- package/dist/repo-validate/check-element-hygiene.d.ts +8 -0
- package/dist/repo-validate/check-element-hygiene.js +211 -0
- package/dist/repo-validate/check-strategy-chain.d.ts +8 -0
- package/dist/repo-validate/check-strategy-chain.js +491 -0
- package/dist/repo-validate/index.d.ts +2 -1
- package/dist/repo-validate/index.js +1 -0
- package/dist/repo-validate/resolve-model.d.ts +5 -0
- package/dist/repo-validate/resolve-model.js +71 -0
- package/dist/repo-validate/types.d.ts +70 -1
- package/dist/repo-validate/types.js +22 -3
- package/dist/repo-validate/validate-repo.d.ts +14 -3
- package/dist/repo-validate/validate-repo.js +21 -8
- package/dist/webview/render-blocks.d.ts +23 -1
- package/dist/webview/render-blocks.js +100 -2
- package/package.json +1 -1
- package/src/activities/validate.ts +9 -1
- package/src/blocks/index.ts +16 -2
- package/src/blocks/layout.ts +70 -0
- package/src/blocks/templates/index.ts +13 -0
- package/src/blocks/templates/raci.ts +28 -0
- package/src/blocks/types.ts +67 -0
- package/src/blocks/validate.ts +157 -1
- package/src/capability-map/validate.ts +40 -0
- package/src/fgca/parse-canonical.ts +8 -0
- package/src/repo-validate/check-element-hygiene.ts +236 -0
- package/src/repo-validate/check-strategy-chain.ts +528 -0
- package/src/repo-validate/index.ts +2 -1
- package/src/repo-validate/resolve-model.ts +79 -0
- package/src/repo-validate/types.ts +95 -4
- package/src/repo-validate/validate-repo.ts +21 -8
- package/src/webview/render-blocks.ts +159 -2
package/src/blocks/validate.ts
CHANGED
|
@@ -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
|
+
}
|