@transitrix/diagrams 1.13.3 → 1.14.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 (67) hide show
  1. package/dist/activities/action-fields.d.ts +6 -0
  2. package/dist/activities/action-fields.js +34 -0
  3. package/dist/activities/validate.d.ts +3 -1
  4. package/dist/activities/validate.js +36 -34
  5. package/dist/applications/validate.js +27 -26
  6. package/dist/capability-map/validate.d.ts +4 -1
  7. package/dist/capability-map/validate.js +32 -30
  8. package/dist/codex/validate.js +3 -3
  9. package/dist/compliance/canon-catalog.js +1 -1
  10. package/dist/compliance/classify.d.ts +4 -1
  11. package/dist/compliance/classify.js +21 -1
  12. package/dist/compliance/coverage-metric.js +3 -3
  13. package/dist/compliance/impact.js +4 -4
  14. package/dist/compliance/index.d.ts +3 -0
  15. package/dist/compliance/index.js +1 -0
  16. package/dist/compliance/requirement-chain.d.ts +111 -0
  17. package/dist/compliance/requirement-chain.js +501 -0
  18. package/dist/compliance/reverse-index.js +4 -0
  19. package/dist/compliance/types.d.ts +20 -0
  20. package/dist/fgca/parse-canonical.d.ts +0 -25
  21. package/dist/fgca/parse-canonical.js +16 -26
  22. package/dist/fgca/projection-origin.d.ts +2 -0
  23. package/dist/fgca/projection-origin.js +2 -0
  24. package/dist/fgca/resolver.d.ts +0 -8
  25. package/dist/fgca/resolver.js +2 -0
  26. package/dist/products/validate.js +30 -25
  27. package/dist/repo-validate/check-strategy-chain.d.ts +8 -2
  28. package/dist/repo-validate/check-strategy-chain.js +64 -64
  29. package/dist/repo-validate/types.d.ts +2 -0
  30. package/dist/repo-validate/validate-repo.js +2 -0
  31. package/dist/risk/index.d.ts +1 -1
  32. package/dist/risk/types.d.ts +8 -1
  33. package/dist/risk/validate.js +19 -4
  34. package/dist/scenarios/validate.js +4 -0
  35. package/dist/schema-finding.d.ts +3 -0
  36. package/dist/schema-finding.js +6 -0
  37. package/dist/webview/entry.js +7 -2
  38. package/dist/webview/render-goals.js +2 -1
  39. package/package.json +1 -1
  40. package/src/activities/action-fields.ts +31 -0
  41. package/src/activities/validate.ts +37 -34
  42. package/src/applications/validate.ts +27 -26
  43. package/src/capability-map/validate.ts +33 -29
  44. package/src/codex/validate.ts +3 -3
  45. package/src/compliance/canon-catalog.ts +1 -1
  46. package/src/compliance/classify.ts +24 -1
  47. package/src/compliance/coverage-metric.ts +3 -3
  48. package/src/compliance/impact.ts +4 -4
  49. package/src/compliance/index.ts +3 -0
  50. package/src/compliance/requirement-chain.ts +430 -0
  51. package/src/compliance/reverse-index.ts +3 -0
  52. package/src/compliance/types.ts +21 -0
  53. package/src/fgca/parse-canonical.ts +14 -1
  54. package/src/fgca/projection-origin.ts +2 -0
  55. package/src/fgca/resolver.ts +2 -0
  56. package/src/products/validate.ts +31 -25
  57. package/src/repo-validate/check-strategy-chain.ts +69 -64
  58. package/src/repo-validate/types.ts +2 -0
  59. package/src/repo-validate/validate-repo.ts +2 -0
  60. package/src/risk/index.ts +1 -1
  61. package/src/risk/types.ts +9 -1
  62. package/src/risk/validate.ts +19 -5
  63. package/src/scenarios/validate.ts +5 -0
  64. package/src/schema-finding.ts +8 -0
  65. package/src/webview/entry.ts +6 -2
  66. package/src/webview/render-goals.ts +2 -1
  67. package/src/webview/styles.css +8 -0
@@ -1,3 +1,4 @@
1
+ import { schemaFinding } from '../schema-finding.js';
1
2
  import type { ProductsCatalogueFile, ProductType, ProductStatus } from './types.js';
2
3
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
4
 
@@ -11,8 +12,8 @@ export function validateProductsCatalogue(input: unknown): ValidationResult {
11
12
  const errors: ValidationError[] = [];
12
13
  const warnings: ValidationWarning[] = [];
13
14
 
14
- if (!input || typeof input !== 'object') {
15
- return { valid: false, errors: [{ code: 'PROD-001', message: 'Input must be an object' }], warnings };
15
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
16
+ return { valid: false, errors: [schemaFinding('products inline', '$', 'mapping', input)], warnings };
16
17
  }
17
18
 
18
19
  const raw = input as Record<string, unknown>;
@@ -26,84 +27,89 @@ export function validateProductsCatalogue(input: unknown): ValidationResult {
26
27
 
27
28
  if (errors.length > 0) return { valid: false, errors, warnings };
28
29
 
30
+ if (('view_config' in raw || 'view' in raw) && !('products_catalogue' in raw)) {
31
+ return { valid: true, errors, warnings: [{ code: 'NOTATION-SKIP-001',
32
+ message: 'products projection form is unsupported and remains unvalidated.' }] };
33
+ }
34
+
29
35
  // PROD-002: catalogue header fields
30
36
  const cat = (raw['products_catalogue'] ?? {}) as Record<string, unknown>;
31
- if (!raw['products_catalogue'] || typeof raw['products_catalogue'] !== 'object') {
32
- errors.push({ code: 'PROD-002', message: 'Missing required field: products_catalogue' });
37
+ if (!raw['products_catalogue'] || typeof raw['products_catalogue'] !== 'object' || Array.isArray(raw['products_catalogue'])) {
38
+ errors.push(schemaFinding('products inline', 'products_catalogue', 'mapping', raw['products_catalogue']));
33
39
  return { valid: false, errors, warnings };
34
40
  }
35
41
  if (!cat['id'] || typeof cat['id'] !== 'string' || !(cat['id'] as string).trim()) {
36
- errors.push({ code: 'PROD-002', message: 'products_catalogue.id is required' });
42
+ errors.push(schemaFinding('products inline', 'products_catalogue.id', 'nonempty string', cat['id']));
37
43
  }
38
44
  if (!cat['name'] || typeof cat['name'] !== 'string' || !(cat['name'] as string).trim()) {
39
- errors.push({ code: 'PROD-002', message: 'products_catalogue.name is required' });
45
+ errors.push(schemaFinding('products inline', 'products_catalogue.name', 'nonempty string', cat['name']));
40
46
  }
41
47
  if (!cat['updated_at'] || typeof cat['updated_at'] !== 'string') {
42
- errors.push({ code: 'PROD-002', message: 'products_catalogue.updated_at is required' });
48
+ errors.push(schemaFinding('products inline', 'products_catalogue.updated_at', 'YYYY-MM-DD string', cat['updated_at']));
43
49
  }
44
50
 
45
51
  if (errors.length > 0) return { valid: false, errors, warnings };
46
52
 
47
- // PROD-007: updated_at format
53
+ // Schema: updated_at format
48
54
  if (!DATE_RE.test(cat['updated_at'] as string)) {
49
- errors.push({ code: 'PROD-007', message: `products_catalogue.updated_at must be YYYY-MM-DD, got "${cat['updated_at']}"` });
55
+ errors.push(schemaFinding('products inline', 'products_catalogue.updated_at', 'YYYY-MM-DD string', cat['updated_at']));
50
56
  }
51
57
 
52
58
  const products = cat['products'];
53
59
  if (!Array.isArray(products)) {
54
- errors.push({ code: 'PROD-002', message: 'products_catalogue.products must be an array' });
60
+ errors.push(schemaFinding('products inline', 'products_catalogue.products', 'array', cat['products']));
55
61
  return { valid: false, errors, warnings };
56
62
  }
57
63
 
58
- // PROD-008: unique product_id
64
+ // Schema: unique product_id
59
65
  const seenIds = new Set<string>();
60
66
 
61
67
  for (let i = 0; i < products.length; i++) {
62
68
  const rawProduct = products[i];
63
69
  const idx = `products[${i}]`;
64
70
 
65
- if (!rawProduct || typeof rawProduct !== 'object') {
66
- errors.push({ code: 'PROD-003', message: `${idx} must be an object` });
71
+ if (!rawProduct || typeof rawProduct !== 'object' || Array.isArray(rawProduct)) {
72
+ errors.push(schemaFinding('products inline', `${idx}`, 'mapping', rawProduct));
67
73
  continue;
68
74
  }
69
75
  const p = rawProduct as Record<string, unknown>;
70
76
 
71
- // PROD-003: required per-product fields
77
+ // Schema: required per-product fields
72
78
  if (!p['product_id'] || typeof p['product_id'] !== 'string' || !(p['product_id'] as string).trim()) {
73
- errors.push({ code: 'PROD-003', message: `${idx}: product_id is required` });
79
+ errors.push(schemaFinding('products inline', `${idx}.product_id`, 'nonempty string', p['product_id']));
74
80
  } else {
75
81
  const pid = p['product_id'] as string;
76
82
  if (seenIds.has(pid)) {
77
- errors.push({ code: 'PROD-008', message: `Duplicate product_id: "${pid}"` });
83
+ errors.push(schemaFinding('products inline', `${idx}.product_id`, 'unique identifier', p['product_id']));
78
84
  }
79
85
  seenIds.add(pid);
80
86
  }
81
87
 
82
88
  if (!p['name'] || typeof p['name'] !== 'string' || !(p['name'] as string).trim()) {
83
- errors.push({ code: 'PROD-003', message: `${idx}: name is required` });
89
+ errors.push(schemaFinding('products inline', `${idx}.name`, 'nonempty string', p['name']));
84
90
  }
85
91
  if (!p['type']) {
86
- errors.push({ code: 'PROD-003', message: `${idx}: type is required` });
92
+ errors.push(schemaFinding('products inline', `${idx}.type`, `one of ${[...VALID_TYPES].join(", ")}`, p['type']));
87
93
  }
88
94
  if (!p['status']) {
89
- errors.push({ code: 'PROD-003', message: `${idx}: status is required` });
95
+ errors.push(schemaFinding('products inline', `${idx}.status`, `one of ${[...VALID_STATUSES].join(", ")}`, p['status']));
90
96
  }
91
97
 
92
- // PROD-004: type enum
98
+ // Schema: type enum
93
99
  if (p['type'] && !VALID_TYPES.has(p['type'] as ProductType)) {
94
- errors.push({ code: 'PROD-004', message: `${idx}: type "${p['type']}" must be one of: digital_product, service, platform, bundle` });
100
+ errors.push(schemaFinding('products inline', `${idx}.type`, `one of ${[...VALID_TYPES].join(", ")}`, p['type']));
95
101
  }
96
102
 
97
- // PROD-005: status enum
103
+ // Schema: status enum
98
104
  if (p['status'] && !VALID_STATUSES.has(p['status'] as ProductStatus)) {
99
- errors.push({ code: 'PROD-005', message: `${idx}: status "${p['status']}" must be one of: Draft, Active, Deprecated` });
105
+ errors.push(schemaFinding('products inline', `${idx}.status`, `one of ${[...VALID_STATUSES].join(", ")}`, p['status']));
100
106
  }
101
107
 
102
- // PROD-006: maturity range
108
+ // Schema: maturity range
103
109
  if (p['maturity'] !== undefined) {
104
110
  const m = p['maturity'];
105
111
  if (typeof m !== 'number' || !Number.isInteger(m) || m < 1 || m > 5) {
106
- errors.push({ code: 'PROD-006', message: `${idx}: maturity must be an integer 1–5, got "${m}"` });
112
+ errors.push(schemaFinding('products inline', `${idx}.maturity`, 'integer in 1–5', p['maturity']));
107
113
  }
108
114
  }
109
115
  }
@@ -1,3 +1,4 @@
1
+ import { validateActionFields } from '../activities/action-fields.js';
1
2
  // Strategy-chain semantic checks — the error-severity subset of DSM's Go
2
3
  // `Validate*` functions (`api02/internal/importer/{goals,activities,fgca}.go`
3
4
  // in transitrix-dsm), ported onto the standalone-element repo shape
@@ -5,9 +6,8 @@
5
6
  // can drop those Go functions without regressing the checks they enforce
6
7
  // today.
7
8
  //
8
- // Rule codes are DSM's own (`GOALS-010`, `ACT-006`..`009`, `FGCA-008`..`011`)
9
- // — not invented here — so DSM can map a CLI finding straight back onto its
10
- // import-log taxonomy (`RepoFinding.ruleId`).
9
+ // ACTION identities follow the versioned element contract; schedule ACT codes
10
+ // remain separate. Stored historical findings must retain their original codes.
11
11
  //
12
12
  // Scope — DSM's full rule set is now ported except GOALS-008 (see below).
13
13
  // `RepoFinding` grew a `severity` field precisely so DSM's warn-severity
@@ -43,20 +43,20 @@
43
43
  //
44
44
  // Ported (error-severity, blocking):
45
45
  // GOALS-010 — GOAL `parent` chain contains a cycle.
46
- // ACT-006 — ACTION `predecessors` graph contains a cycle.
47
- // ACT-007 — ACTION lists itself as its own predecessor.
48
- // ACT-008 — ACTION `start_date`/`end_date` unparseable, or end before start.
49
- // ACT-009 — ACTION numeric field (`duration`/`duration_days`, `labor_cost`,
46
+ // ACTION-008 — ACTION `predecessors` graph contains a cycle.
47
+ // ACTION-009 — ACTION lists itself as its own predecessor.
48
+ // ACTION-010 — ACTION `start_date`/`end_date` unparseable, or end before start.
49
+ // ACTION-011 — ACTION numeric field (`duration`/`duration_days`, `labor_cost`,
50
50
  // `resources_cost`, `effort`, `score`) is negative.
51
- // FGCA-008 — GOAL.factors references an undefined DRIVER.
52
- // FGCA-009 — CHANGE.goals references an undefined GOAL.
53
- // FGCA-010 — ACTION.delivers_changes references an undefined CHANGE.
54
- // FGCA-011 — ACTION.goals references an undefined GOAL.
51
+ // DGCA-REPO-008 — GOAL.factors references an undefined DRIVER.
52
+ // DGCA-REPO-009 — CHANGE.goals references an undefined GOAL.
53
+ // DGCA-REPO-010 — ACTION.delivers_changes references an undefined CHANGE.
54
+ // DGCA-REPO-011 — ACTION.goals references an undefined GOAL.
55
55
  //
56
56
  // Ported (warning-severity, advisory):
57
57
  // GOALS-009 — GOAL.parent is set but does not resolve to a known GOAL (orphan).
58
58
  // GOALS-011 — GOAL has no `parent` and `level` >= 1 (backlog).
59
- // ACT-005 — ACTION.predecessors entry or ACTION.parent does not resolve
59
+ // ACTION-007 — ACTION.predecessors entry or ACTION.parent does not resolve
60
60
  // to a known ACTION (orphan).
61
61
  // FGCA-012 — a DRIVER is not referenced by any GOAL.factors (unreferenced).
62
62
  // FGCA-013 — a GOAL is not referenced by any CHANGE.goals or ACTION.goals
@@ -244,34 +244,34 @@ function checkGoalParentResolution(goals: ChainElement[], findings: RepoFinding[
244
244
  }
245
245
  }
246
246
 
247
- /** ACT-007 — an ACTION cannot list itself as its own predecessor. */
247
+ /** ACTION-009 — an ACTION cannot list itself as its own predecessor. */
248
248
  function checkActionSelfPredecessor(actions: ChainElement[], findings: RepoFinding[]): void {
249
249
  for (const a of actions) {
250
250
  if (readStringArray(a.data, 'predecessors').includes(a.id)) {
251
251
  findings.push({
252
252
  scope: PScope,
253
253
  id: a.id,
254
- ruleId: 'ACT-007',
255
- message: `ACT-007: action '${a.id}' lists itself as a predecessor.`,
254
+ ruleId: 'ACTION-009',
255
+ message: `ACTION-009: action '${a.id}' lists itself as a predecessor.`,
256
256
  });
257
257
  }
258
258
  }
259
259
  }
260
260
 
261
- /** ACT-006 — the ACTION `predecessors` graph must not contain a cycle. */
261
+ /** ACTION-008 — the ACTION `predecessors` graph must not contain a cycle. */
262
262
  function checkActionPredecessorCycle(actions: ChainElement[], findings: RepoFinding[]): void {
263
263
  const cyc = findPredecessorCycle(actions);
264
264
  if (cyc) {
265
265
  findings.push({
266
266
  scope: PScope,
267
267
  id: cyc,
268
- ruleId: 'ACT-006',
269
- message: `ACT-006: predecessor graph contains a cycle involving action '${cyc}'.`,
268
+ ruleId: 'ACTION-008',
269
+ message: `ACTION-008: predecessor graph contains a cycle involving action '${cyc}'.`,
270
270
  });
271
271
  }
272
272
  }
273
273
 
274
- /** ACT-005 — an ACTION `predecessors` entry or `parent` that does not resolve
274
+ /** ACTION-007 — an ACTION `predecessors` entry or `parent` that does not resolve
275
275
  * to a known ACTION is an orphan reference (warning — advisory in DSM, same
276
276
  * as the goal-parent orphan checks above). */
277
277
  function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFinding[]): void {
@@ -282,9 +282,9 @@ function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFind
282
282
  findings.push({
283
283
  scope: PScope,
284
284
  id: a.id,
285
- ruleId: 'ACT-005',
285
+ ruleId: 'ACTION-007',
286
286
  severity: 'warning',
287
- message: `ACT-005: action '${a.id}' predecessor '${p}' does not resolve to a known action.`,
287
+ message: `ACTION-007: action '${a.id}' predecessor '${p}' does not resolve to a known action.`,
288
288
  });
289
289
  }
290
290
  }
@@ -293,9 +293,9 @@ function checkActionOrphanReferences(actions: ChainElement[], findings: RepoFind
293
293
  findings.push({
294
294
  scope: PScope,
295
295
  id: a.id,
296
- ruleId: 'ACT-005',
296
+ ruleId: 'ACTION-007',
297
297
  severity: 'warning',
298
- message: `ACT-005: action '${a.id}' parent '${parent}' does not resolve to a known action.`,
298
+ message: `ACTION-007: action '${a.id}' parent '${parent}' does not resolve to a known action.`,
299
299
  });
300
300
  }
301
301
  }
@@ -316,34 +316,34 @@ function parseIsoDate(value: string): Date | undefined {
316
316
  return dt;
317
317
  }
318
318
 
319
- /** ACT-008 — `start_date`/`end_date` must be valid ISO dates, and `end_date`
319
+ /** ACTION-010 — `start_date`/`end_date` must be valid ISO dates, and `end_date`
320
320
  * must not be before `start_date` (equal is allowed — e.g. a milestone). */
321
321
  function checkActionDates(actions: ChainElement[], findings: RepoFinding[]): void {
322
322
  for (const a of actions) {
323
- const startRaw = readString(a.data, 'start_date');
324
- const endRaw = readString(a.data, 'end_date');
323
+ const startRaw = a.data.start_date == null ? undefined : a.data.start_date;
324
+ const endRaw = a.data.end_date == null ? undefined : a.data.end_date;
325
325
  let start: Date | undefined;
326
326
  let end: Date | undefined;
327
327
 
328
328
  if (startRaw !== undefined) {
329
- start = parseIsoDate(startRaw);
329
+ start = typeof startRaw === 'string' ? parseIsoDate(startRaw) : undefined;
330
330
  if (!start) {
331
331
  findings.push({
332
332
  scope: PScope,
333
333
  id: a.id,
334
- ruleId: 'ACT-008',
335
- message: `ACT-008: action '${a.id}' start_date '${startRaw}' is not a valid YYYY-MM-DD date.`,
334
+ ruleId: 'ACTION-010',
335
+ message: `ACTION-010: action '${a.id}' start_date '${startRaw}' is not a valid YYYY-MM-DD date.`,
336
336
  });
337
337
  }
338
338
  }
339
339
  if (endRaw !== undefined) {
340
- end = parseIsoDate(endRaw);
340
+ end = typeof endRaw === 'string' ? parseIsoDate(endRaw) : undefined;
341
341
  if (!end) {
342
342
  findings.push({
343
343
  scope: PScope,
344
344
  id: a.id,
345
- ruleId: 'ACT-008',
346
- message: `ACT-008: action '${a.id}' end_date '${endRaw}' is not a valid YYYY-MM-DD date.`,
345
+ ruleId: 'ACTION-010',
346
+ message: `ACTION-010: action '${a.id}' end_date '${endRaw}' is not a valid YYYY-MM-DD date.`,
347
347
  });
348
348
  }
349
349
  }
@@ -351,36 +351,24 @@ function checkActionDates(actions: ChainElement[], findings: RepoFinding[]): voi
351
351
  findings.push({
352
352
  scope: PScope,
353
353
  id: a.id,
354
- ruleId: 'ACT-008',
355
- message: `ACT-008: action '${a.id}' end_date '${endRaw}' is before start_date '${startRaw}'.`,
354
+ ruleId: 'ACTION-010',
355
+ message: `ACTION-010: action '${a.id}' end_date '${endRaw}' is before start_date '${startRaw}'.`,
356
356
  });
357
357
  }
358
358
  }
359
359
  }
360
360
 
361
- /** ACT-009 — numeric scheduling/cost fields must not be negative. `duration`
362
- * and `duration_days` are both checked — the canonical field is `duration`
363
- * (elements/24-action.md §2), but `duration_days` is an accepted alias in
364
- * this codebase's document-form validator (activities/validate.ts) and is
365
- * the field acme_corp's own ACTION elements actually use. */
366
- function checkActionNegativeNumbers(actions: ChainElement[], findings: RepoFinding[]): void {
367
- const fields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score'] as const;
361
+ /** Shared ACTION type and versioned numeric checks. */
362
+ function checkActionFields(actions: ChainElement[], findings: RepoFinding[], version?: string): void {
368
363
  for (const a of actions) {
369
- for (const field of fields) {
370
- const v = readFiniteNumber(a.data, field);
371
- if (v !== undefined && v < 0) {
372
- findings.push({
373
- scope: PScope,
374
- id: a.id,
375
- ruleId: 'ACT-009',
376
- message: `ACT-009: action '${a.id}' field '${field}' is negative (${v}).`,
377
- });
378
- }
364
+ for (const error of validateActionFields(a.data, version)) {
365
+ findings.push({ scope: PScope, id: a.id, ruleId: error.code,
366
+ message: `${a.doc.path}: canonical action '${a.id}' ${error.message}` });
379
367
  }
380
368
  }
381
369
  }
382
370
 
383
- /** FGCA-008..011 — inline strategy-chain cross-references must resolve within
371
+ /** DGCA-REPO-008..011 — inline strategy-chain cross-references must resolve within
384
372
  * the repo: GOAL.factors -> DRIVER, CHANGE.goals -> GOAL, ACTION.goals ->
385
373
  * GOAL, ACTION.delivers_changes -> CHANGE (ELEMENT_PRIMITIVES.md §7.1-§7.4). */
386
374
  function checkStrategyChainReferences(
@@ -400,8 +388,8 @@ function checkStrategyChainReferences(
400
388
  findings.push({
401
389
  scope: PScope,
402
390
  id: g.id,
403
- ruleId: 'FGCA-008',
404
- message: `FGCA-008: goal '${g.id}' references undefined driver '${f}'.`,
391
+ ruleId: 'DGCA-REPO-008',
392
+ message: `DGCA-REPO-008: goal '${g.id}' references undefined driver '${f}'.`,
405
393
  });
406
394
  }
407
395
  }
@@ -412,8 +400,8 @@ function checkStrategyChainReferences(
412
400
  findings.push({
413
401
  scope: PScope,
414
402
  id: c.id,
415
- ruleId: 'FGCA-009',
416
- message: `FGCA-009: change '${c.id}' references undefined goal '${g}'.`,
403
+ ruleId: 'DGCA-REPO-009',
404
+ message: `DGCA-REPO-009: change '${c.id}' references undefined goal '${g}'.`,
417
405
  });
418
406
  }
419
407
  }
@@ -424,8 +412,8 @@ function checkStrategyChainReferences(
424
412
  findings.push({
425
413
  scope: PScope,
426
414
  id: a.id,
427
- ruleId: 'FGCA-010',
428
- message: `FGCA-010: action '${a.id}' references undefined change '${c}'.`,
415
+ ruleId: 'DGCA-REPO-010',
416
+ message: `DGCA-REPO-010: action '${a.id}' references undefined change '${c}'.`,
429
417
  });
430
418
  }
431
419
  }
@@ -434,8 +422,8 @@ function checkStrategyChainReferences(
434
422
  findings.push({
435
423
  scope: PScope,
436
424
  id: a.id,
437
- ruleId: 'FGCA-011',
438
- message: `FGCA-011: action '${a.id}' references undefined goal '${g}'.`,
425
+ ruleId: 'DGCA-REPO-011',
426
+ message: `DGCA-REPO-011: action '${a.id}' references undefined goal '${g}'.`,
439
427
  });
440
428
  }
441
429
  }
@@ -505,8 +493,8 @@ function checkStrategyChainOrphans(
505
493
  }
506
494
 
507
495
  /**
508
- * Run the strategy-chain semantic checks (GOALS-009..011, ACT-005..009,
509
- * FGCA-008..014 except GOALS-008 — see the module header) over the loaded
496
+ * Run the strategy-chain semantic checks (GOALS-009..011, ACTION-007..011,
497
+ * DGCA-REPO-008..014 except GOALS-008 — see the module header) over the loaded
510
498
  * element set and append findings. Called from `validateRepoModel` after the
511
499
  * structural phases. Pure, deterministic order.
512
500
  */
@@ -522,7 +510,24 @@ export function checkStrategyChainSemantics(input: RepoModelInput, findings: Rep
522
510
  checkActionPredecessorCycle(actions, findings);
523
511
  checkActionOrphanReferences(actions, findings);
524
512
  checkActionDates(actions, findings);
525
- checkActionNegativeNumbers(actions, findings);
513
+ checkActionFields(actions, findings, input.methodologyVersion);
526
514
  checkStrategyChainReferences(goals, actions, drivers, changes, findings);
527
- checkStrategyChainOrphans(goals, actions, drivers, changes, findings);
515
+ if (!usesCoverageObservations(input)) checkStrategyChainOrphans(goals, actions, drivers, changes, findings);
516
+ }
517
+
518
+
519
+ function usesCoverageObservations(input: RepoModelInput): boolean {
520
+ return /^\d+\./.test(input.methodologyVersion ?? '') && Number(input.methodologyVersion!.split('.')[0]) >= 5;
521
+ }
522
+
523
+ /** Removed FGCA orphan rules become visible coverage observations, not new aliases. */
524
+ export function collectStrategyCoverageObservations(input: RepoModelInput): Array<{id: string; kind: 'unreferenced'; message: string}> {
525
+ if (!usesCoverageObservations(input)) return [];
526
+ const findings: RepoFinding[] = [];
527
+ checkStrategyChainOrphans(
528
+ collectByNotation(input.elements, isGoalNotation),
529
+ collectByNotation(input.elements, isActionNotation),
530
+ collectByNotation(input.elements, isDriverNotation),
531
+ collectByNotation(input.elements, isChangeNotation), findings);
532
+ return findings.map(f => ({ id: f.id, kind: 'unreferenced', message: f.message.replace(/^FGCA-01[234]: /, '') }));
528
533
  }
@@ -74,6 +74,8 @@ export interface RepoDoc {
74
74
  * canon zone exactly as `lint.py` partitions them (`canon/elements/**` vs
75
75
  * `canon/relations/**`). */
76
76
  export interface RepoModelInput {
77
+ /** Catalogue manifest version; document spec_version never selects compatibility. */
78
+ methodologyVersion?: string;
77
79
  /** Documents found under `canon/elements/**`. */
78
80
  elements: RepoDoc[];
79
81
  /** Documents found under `canon/relations/**`. */
@@ -135,6 +135,7 @@ function checkReferentialIntegrity(input: RepoModelInput, findings: RepoFinding[
135
135
  findings.push({
136
136
  scope: PScope,
137
137
  id: relId,
138
+ ruleId: 'REL-002',
138
139
  message: `Referential integrity: relation '${relId || doc.path}' endpoint '${fromId}' (from) does not resolve to a known element.`,
139
140
  });
140
141
  }
@@ -142,6 +143,7 @@ function checkReferentialIntegrity(input: RepoModelInput, findings: RepoFinding[
142
143
  findings.push({
143
144
  scope: PScope,
144
145
  id: relId,
146
+ ruleId: 'REL-002',
145
147
  message: `Referential integrity: relation '${relId || doc.path}' endpoint '${toId}' (to) does not resolve to a known element.`,
146
148
  });
147
149
  }
package/src/risk/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { Risk, RiskLevel } from './types.js';
1
+ export type { Risk, RiskLevel, RiskScale } from './types.js';
2
2
  export { RISK_LEVELS } from './types.js';
3
3
  export { validateRisk } from './validate.js';
4
4
  export type { RiskValidateOptions } from './validate.js';
package/src/risk/types.ts CHANGED
@@ -4,7 +4,14 @@
4
4
  import type { GateChecks } from '../requirement/types.js';
5
5
 
6
6
  /** Likelihood / impact / residual severity vocabulary (§7.26). */
7
- export type RiskLevel = 'low' | 'medium' | 'high';
7
+ export type RiskLevel = 'low' | 'medium' | 'high' | number;
8
+
9
+ export interface RiskScale {
10
+ id: string;
11
+ min: number;
12
+ max: number;
13
+ direction: 'higher' | 'lower';
14
+ }
8
15
 
9
16
  export const RISK_LEVELS: readonly RiskLevel[] = ['low', 'medium', 'high'];
10
17
 
@@ -12,6 +19,7 @@ export interface Risk {
12
19
  notation: 'risk';
13
20
  id: string;
14
21
  name: string;
22
+ risk_scale?: RiskScale;
15
23
  likelihood: RiskLevel;
16
24
  impact: RiskLevel;
17
25
  residual: RiskLevel;
@@ -3,7 +3,7 @@
3
3
  // Codes:
4
4
  // RISK-001 — shape / id grammar / required envelope + per-type fields
5
5
  // (likelihood, impact, residual, owner_role, threatens).
6
- // RISK-002 — likelihood / impact / residual outside {low, medium, high}.
6
+ // RISK-002 — invalid qualitative/numeric degree or adopter scale.
7
7
  // RISK-003 — threatens is empty, or an entry does not resolve to an
8
8
  // admitted element in canon (no TYPE restriction — any core
9
9
  // element may be threatened).
@@ -62,13 +62,27 @@ export function validateRisk(input: unknown, options: RiskValidateOptions = {}):
62
62
  errors.push({ code: 'RISK-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
63
63
  }
64
64
 
65
- // likelihood / impact / residual — required (RISK-001) + enum (RISK-002).
66
- for (const f of ['likelihood', 'impact', 'residual'] as const) {
65
+ // Authored numeric degrees use one explicitly declared adopter scale.
66
+ const fields = ['likelihood', 'impact', 'residual'] as const;
67
+ const scale = r.risk_scale as Record<string, unknown> | undefined;
68
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
69
+ const validScale = scale !== null && typeof scale === 'object' && !Array.isArray(scale)
70
+ && typeof scale.id === 'string' && scale.id.trim().length > 0
71
+ && finite(scale.min) && finite(scale.max) && scale.min < scale.max
72
+ && (scale.direction === 'higher' || scale.direction === 'lower');
73
+ if ((scale !== undefined || fields.some(f => typeof r[f] === 'number')) && !validScale) {
74
+ errors.push({ code: 'RISK-002', message: 'risk_scale requires id, finite min < max, and direction higher or lower.', path: 'risk_scale' });
75
+ }
76
+ for (const f of fields) {
67
77
  const v = r[f];
68
78
  if (v === undefined || v === null || v === '') {
69
79
  errors.push({ code: 'RISK-001', message: `${f} is required.`, path: f });
70
- } else if (!(RISK_LEVELS as readonly string[]).includes(v as string)) {
71
- errors.push({ code: 'RISK-002', message: `${f} "${String(v)}" must be one of ${RISK_LEVELS.join(', ')}.`, path: f });
80
+ } else if (typeof v === 'number') {
81
+ if (!finite(v) || (validScale && (v < (scale!.min as number) || v > (scale!.max as number)))) {
82
+ errors.push({ code: 'RISK-002', message: `${f} must be finite and within risk_scale bounds.`, path: f });
83
+ }
84
+ } else if (!(RISK_LEVELS as readonly unknown[]).includes(v)) {
85
+ errors.push({ code: 'RISK-002', message: `${f} must be low, medium, high, or a number with risk_scale.`, path: f });
72
86
  }
73
87
  }
74
88
 
@@ -38,6 +38,11 @@ export function validateScenario(input: unknown): ValidationResult {
38
38
  }
39
39
  if (errors.length > 0) return { valid: false, errors, warnings };
40
40
 
41
+ if (('view_config' in raw || 'view' in raw || 'scenarios' in raw) && !('scenario' in raw)) {
42
+ return { valid: true, errors, warnings: [{ code: 'NOTATION-SKIP-001',
43
+ message: 'scenarios projection or scenario-set form is unsupported and remains unvalidated.' }] };
44
+ }
45
+
41
46
  const scn = raw['scenario'];
42
47
  if (!scn || typeof scn !== 'object') {
43
48
  errors.push({ code: 'SCN-002', message: 'Missing required field: scenario' });
@@ -0,0 +1,8 @@
1
+ import type { ValidationError } from './validation-types.js';
2
+
3
+ /** Shared schema identity; callers supply the owning form and exact field. */
4
+ export function schemaFinding(form: string, path: string, expected: string, actual: unknown): ValidationError {
5
+ const type = actual === null ? 'null' : Array.isArray(actual) ? 'array' : typeof actual;
6
+ return { code: 'SCHEMA_INVALID', path,
7
+ message: `${form} field '${path}': expected ${expected}; actual ${type} (${String(actual)}).` };
8
+ }
@@ -275,10 +275,14 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
275
275
  return renderFromValidation('scenarios', validateScenario(doc), () =>
276
276
  renderScenarioHtml((doc as ScenarioFile).scenario),
277
277
  );
278
- case 'capability-map':
279
- return renderFromValidation('capability-map', validateCapabilityMap(doc), () =>
278
+ case 'capability-map': {
279
+ const validation = validateCapabilityMap(doc);
280
+ const unresolved = validation.warnings.find(w => w.code === 'NOTATION-SKIP-001');
281
+ if (validation.valid && unresolved) return errorResult('capability-map', unresolved.code, unresolved.message);
282
+ return renderFromValidation('capability-map', validation, () =>
280
283
  renderCapabilityMapHtml((doc as CapabilityMapFile).capability_map),
281
284
  );
285
+ }
282
286
  default:
283
287
  return errorResult(
284
288
  kind,
@@ -56,7 +56,8 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
56
56
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
57
57
  : '';
58
58
 
59
- return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, embedCssTheme: 'transitrix' });
59
+ // Reserve a separate header row; the theme centers text on its y coordinate.
60
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, topInset: title ? PAD : 0, embedCssTheme: 'transitrix' });
60
61
  }
61
62
 
62
63
  export interface RenderGoalsLayoutOptions {
@@ -70,6 +70,14 @@ body {
70
70
  margin-left: 6px;
71
71
  }
72
72
 
73
+ /* The bundled SVG renderer uses its light palette independently of IDE chrome.
74
+ * Give transparent SVG titles and edges a matching canvas. */
75
+ .tx-svg-host {
76
+ background: #ffffff;
77
+ color: #0d2b35;
78
+ min-height: calc(100vh - 24px);
79
+ }
80
+
73
81
  .tx-svg-host svg {
74
82
  max-width: 100%;
75
83
  height: auto;