@transitrix/diagrams 1.14.0-rc.0 → 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 (52) 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/coverage-metric.js +3 -3
  10. package/dist/compliance/impact.js +4 -4
  11. package/dist/fgca/parse-canonical.d.ts +0 -25
  12. package/dist/fgca/parse-canonical.js +16 -26
  13. package/dist/fgca/projection-origin.d.ts +2 -0
  14. package/dist/fgca/projection-origin.js +2 -0
  15. package/dist/fgca/resolver.d.ts +0 -8
  16. package/dist/fgca/resolver.js +2 -0
  17. package/dist/products/validate.js +30 -25
  18. package/dist/repo-validate/check-strategy-chain.d.ts +8 -2
  19. package/dist/repo-validate/check-strategy-chain.js +64 -64
  20. package/dist/repo-validate/types.d.ts +2 -0
  21. package/dist/repo-validate/validate-repo.js +2 -0
  22. package/dist/risk/index.d.ts +1 -1
  23. package/dist/risk/types.d.ts +8 -1
  24. package/dist/risk/validate.js +19 -4
  25. package/dist/scenarios/validate.js +4 -0
  26. package/dist/schema-finding.d.ts +3 -0
  27. package/dist/schema-finding.js +6 -0
  28. package/dist/webview/entry.js +7 -2
  29. package/dist/webview/render-goals.js +2 -1
  30. package/package.json +1 -1
  31. package/src/activities/action-fields.ts +31 -0
  32. package/src/activities/validate.ts +37 -34
  33. package/src/applications/validate.ts +27 -26
  34. package/src/capability-map/validate.ts +33 -29
  35. package/src/codex/validate.ts +3 -3
  36. package/src/compliance/coverage-metric.ts +3 -3
  37. package/src/compliance/impact.ts +4 -4
  38. package/src/fgca/parse-canonical.ts +14 -1
  39. package/src/fgca/projection-origin.ts +2 -0
  40. package/src/fgca/resolver.ts +2 -0
  41. package/src/products/validate.ts +31 -25
  42. package/src/repo-validate/check-strategy-chain.ts +69 -64
  43. package/src/repo-validate/types.ts +2 -0
  44. package/src/repo-validate/validate-repo.ts +2 -0
  45. package/src/risk/index.ts +1 -1
  46. package/src/risk/types.ts +9 -1
  47. package/src/risk/validate.ts +19 -5
  48. package/src/scenarios/validate.ts +5 -0
  49. package/src/schema-finding.ts +8 -0
  50. package/src/webview/entry.ts +6 -2
  51. package/src/webview/render-goals.ts +2 -1
  52. package/src/webview/styles.css +8 -0
@@ -24,6 +24,10 @@ export function validateScenario(input) {
24
24
  }
25
25
  if (errors.length > 0)
26
26
  return { valid: false, errors, warnings };
27
+ if (('view_config' in raw || 'view' in raw || 'scenarios' in raw) && !('scenario' in raw)) {
28
+ return { valid: true, errors, warnings: [{ code: 'NOTATION-SKIP-001',
29
+ message: 'scenarios projection or scenario-set form is unsupported and remains unvalidated.' }] };
30
+ }
27
31
  const scn = raw['scenario'];
28
32
  if (!scn || typeof scn !== 'object') {
29
33
  errors.push({ code: 'SCN-002', message: 'Missing required field: scenario' });
@@ -0,0 +1,3 @@
1
+ import type { ValidationError } from './validation-types.js';
2
+ /** Shared schema identity; callers supply the owning form and exact field. */
3
+ export declare function schemaFinding(form: string, path: string, expected: string, actual: unknown): ValidationError;
@@ -0,0 +1,6 @@
1
+ /** Shared schema identity; callers supply the owning form and exact field. */
2
+ export function schemaFinding(form, path, expected, actual) {
3
+ const type = actual === null ? 'null' : Array.isArray(actual) ? 'array' : typeof actual;
4
+ return { code: 'SCHEMA_INVALID', path,
5
+ message: `${form} field '${path}': expected ${expected}; actual ${type} (${String(actual)}).` };
6
+ }
@@ -185,8 +185,13 @@ function dispatchValidate(kind, doc) {
185
185
  return renderFromValidation('process-map', validateProcessMap(doc), () => renderProcessMapHtml(doc.process_map));
186
186
  case 'scenarios':
187
187
  return renderFromValidation('scenarios', validateScenario(doc), () => renderScenarioHtml(doc.scenario));
188
- case 'capability-map':
189
- return renderFromValidation('capability-map', validateCapabilityMap(doc), () => renderCapabilityMapHtml(doc.capability_map));
188
+ case 'capability-map': {
189
+ const validation = validateCapabilityMap(doc);
190
+ const unresolved = validation.warnings.find(w => w.code === 'NOTATION-SKIP-001');
191
+ if (validation.valid && unresolved)
192
+ return errorResult('capability-map', unresolved.code, unresolved.message);
193
+ return renderFromValidation('capability-map', validation, () => renderCapabilityMapHtml(doc.capability_map));
194
+ }
190
195
  default:
191
196
  return errorResult(kind, 'NOTATION-NOT-WIRED', `Notation '${kind}' is not wired into the IntelliJ bundle yet.`);
192
197
  }
@@ -41,7 +41,8 @@ export function renderGoalsSvg(tree, options = {}) {
41
41
  const title = treeName
42
42
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
43
43
  : '';
44
- return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, embedCssTheme: 'transitrix' });
44
+ // Reserve a separate header row; the theme centers text on its y coordinate.
45
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, edgeStyle, title, topInset: title ? PAD : 0, embedCssTheme: 'transitrix' });
45
46
  }
46
47
  /**
47
48
  * The single goals SVG emitter shared by every host. Takes an already-computed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.14.0-rc.0",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -0,0 +1,31 @@
1
+ /** ACTION field types and the manifest-selected 7.0.0 numeric boundary. */
2
+ export function validateActionFields(
3
+ action: Record<string, unknown>, methodologyVersion?: string,
4
+ ): Array<{ code: string; message: string; path: string }> {
5
+ const errors: Array<{ code: string; message: string; path: string }> = [];
6
+ const version = /^(\d+)\.(\d+)\.(\d+)(-[^+]+)?(?:\+.*)?$/.exec(methodologyVersion ?? '');
7
+ const nonnegative = version !== null && (Number(version[1]) > 7 ||
8
+ (Number(version[1]) === 7 && (Number(version[2]) > 0 || Number(version[3]) > 0 || !version[4])));
9
+ const type = (value: unknown): string => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
10
+ const schema = (path: string, expected: string, value: unknown) => errors.push({
11
+ code: 'SCHEMA_INVALID', path,
12
+ message: `ACTION field '${path}' expected ${expected}, actual ${type(value)} (${String(value)}).`,
13
+ });
14
+ for (const field of ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort']) {
15
+ const value = action[field];
16
+ if (value === undefined || (value === null && (field === 'duration' || field === 'duration_days'))) continue;
17
+ const integer = field === 'score' || field === 'sort';
18
+ if (typeof value !== 'number' || !Number.isFinite(value) || (integer && !Number.isInteger(value))) {
19
+ schema(field, integer ? 'finite integer' : 'finite number', value);
20
+ } else if (nonnegative && field !== 'sort' && value < 0) {
21
+ errors.push({ code: 'ACTION-011', path: field, message: `ACTION field '${field}' is negative (${value}).` });
22
+ }
23
+ }
24
+ if (action.predecessors !== undefined) {
25
+ if (!Array.isArray(action.predecessors)) schema('predecessors', 'array of strings', action.predecessors);
26
+ else action.predecessors.forEach((value, index) => {
27
+ if (typeof value !== 'string') schema(`predecessors[${index}]`, 'string', value);
28
+ });
29
+ }
30
+ return errors;
31
+ }
@@ -1,3 +1,4 @@
1
+ import { validateActionFields } from './action-fields.js';
1
2
  import type {
2
3
  ActivityDoc,
3
4
  Activity,
@@ -8,6 +9,11 @@ import type {
8
9
  import { isObject, str, reachesRootViaParent } from '../canon-resolver-utils.js';
9
10
  import { ACT_021_OMITTED_KEY } from './resolver.js';
10
11
 
12
+ function validDate(value: string): boolean {
13
+ const date = new Date(value);
14
+ return DATE_RE.test(value) && Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
15
+ }
16
+
11
17
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
12
18
  const VALID_WEEKDAYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
13
19
 
@@ -61,7 +67,7 @@ function collectAct021FromAuthored(
61
67
  return out;
62
68
  }
63
69
 
64
- export function validateActivities(input: unknown): ActivityValidationResult {
70
+ export function validateActivities(input: unknown, options: { methodologyVersion?: string } = {}): ActivityValidationResult {
65
71
  const errors: ActivityValidationError[] = [];
66
72
  const warnings: ActivityValidationWarning[] = [];
67
73
 
@@ -104,7 +110,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
104
110
  warnings.push(...collectAct021FromAuthored(raw, rawArray));
105
111
  }
106
112
 
107
- // ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
113
+ // ACT-007 / ACT-008: project.calendar validation (run before per-activity loop;
108
114
  // surfaces clearly even when activities also have issues).
109
115
  if (raw.project && typeof raw.project === 'object') {
110
116
  const project = raw.project as Record<string, unknown>;
@@ -113,7 +119,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
113
119
  const calendar = cal as Record<string, unknown>;
114
120
  if (calendar.working_days !== undefined) {
115
121
  if (!Array.isArray(calendar.working_days)) {
116
- errors.push({ code: 'ACT-014', message: 'project.calendar.working_days must be an array of weekday names', path: 'project.calendar.working_days' });
122
+ errors.push({ code: 'ACT-007', message: 'project.calendar.working_days must be an array of weekday names', path: 'project.calendar.working_days' });
117
123
  } else {
118
124
  const seen = new Set<string>();
119
125
  for (let i = 0; i < calendar.working_days.length; i++) {
@@ -121,13 +127,13 @@ export function validateActivities(input: unknown): ActivityValidationResult {
121
127
  const day = typeof raw === 'string' ? raw.toLowerCase() : '';
122
128
  if (!VALID_WEEKDAYS.has(day)) {
123
129
  errors.push({
124
- code: 'ACT-014',
130
+ code: 'ACT-007',
125
131
  message: `project.calendar.working_days[${i}] "${String(raw)}" must be one of: mon, tue, wed, thu, fri, sat, sun`,
126
132
  path: `project.calendar.working_days[${i}]`,
127
133
  });
128
134
  } else if (seen.has(day)) {
129
135
  errors.push({
130
- code: 'ACT-014',
136
+ code: 'ACT-007',
131
137
  message: `project.calendar.working_days has duplicate entry "${day}"`,
132
138
  path: `project.calendar.working_days[${i}]`,
133
139
  });
@@ -139,13 +145,13 @@ export function validateActivities(input: unknown): ActivityValidationResult {
139
145
  }
140
146
  if (calendar.holidays !== undefined) {
141
147
  if (!Array.isArray(calendar.holidays)) {
142
- errors.push({ code: 'ACT-015', message: 'project.calendar.holidays must be an array of ISO 8601 dates', path: 'project.calendar.holidays' });
148
+ errors.push({ code: 'ACT-008', message: 'project.calendar.holidays must be an array of ISO 8601 dates', path: 'project.calendar.holidays' });
143
149
  } else {
144
150
  for (let i = 0; i < calendar.holidays.length; i++) {
145
151
  const h = calendar.holidays[i];
146
- if (typeof h !== 'string' || !DATE_RE.test(h)) {
152
+ if (typeof h !== 'string' || !validDate(h)) {
147
153
  errors.push({
148
- code: 'ACT-015',
154
+ code: 'ACT-008',
149
155
  message: `project.calendar.holidays[${i}] "${String(h)}" must be an ISO 8601 date (YYYY-MM-DD)`,
150
156
  path: `project.calendar.holidays[${i}]`,
151
157
  });
@@ -204,15 +210,8 @@ export function validateActivities(input: unknown): ActivityValidationResult {
204
210
  idSet.add(id);
205
211
  }
206
212
 
207
- // ACT-009: non-negative numeric fields
208
- const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'] as const;
209
- for (const field of numericFields) {
210
- const val = act[field];
211
- if (val !== undefined && val !== null) {
212
- if (typeof val !== 'number' || val < 0) {
213
- errors.push({ code: 'ACT-009', message: `Activity "${id}" field "${field}" must be a non-negative number, got ${String(val)}`, path });
214
- }
215
- }
213
+ for (const error of validateActionFields(act, options.methodologyVersion)) {
214
+ errors.push({ ...error, message: `Inline action actions[${i}].${error.path}: ${error.message}`, path: `actions[${i}].${error.path}` });
216
215
  }
217
216
 
218
217
  // ACT-011: warn if no duration (duration_days is accepted as an alias)
@@ -229,19 +228,23 @@ export function validateActivities(input: unknown): ActivityValidationResult {
229
228
  const a = doc.activities[i];
230
229
  const path = `activities[${i}]`;
231
230
 
232
- // ACT-007: self-loop
231
+ // ACTION-009: self-loop
233
232
  if (Array.isArray(a.predecessors) && a.predecessors.includes(a.id)) {
234
- errors.push({ code: 'ACT-007', message: `Activity "${a.id}" lists itself as a predecessor`, path });
233
+ errors.push({ code: 'ACTION-009', message: `Activity "${a.id}" lists itself as a predecessor`, path });
234
+ }
235
+
236
+ if (typeof a.parent === 'string' && !idSet.has(a.parent)) {
237
+ warnings.push({ code: 'ACTION-007', message: `Action "${a.id}" parent "${a.parent}" does not resolve`, path: `${path}.parent` });
235
238
  }
236
239
 
237
- // ACT-005: predecessor existence (intra-document)
240
+ // ACTION-007: predecessor existence (intra-document)
238
241
  for (const predId of (a.predecessors ?? [])) {
239
242
  if (!idSet.has(predId)) {
240
- errors.push({ code: 'ACT-005', message: `Activity "${a.id}" references unknown predecessor "${predId}"`, path });
243
+ warnings.push({ code: 'ACTION-007', message: `Activity "${a.id}" references unknown predecessor "${predId}"`, path });
241
244
  }
242
245
  }
243
246
 
244
- // ACT-008: date format validation + end_date >= start_date.
247
+ // ACTION-010: date format validation + end_date >= start_date.
245
248
  // The orchestrator's pre-release review flagged that the raw lexicographic
246
249
  // string compare is only correct for strict YYYY-MM-DD, with no format
247
250
  // check applied. Now we validate the format first; the order compare runs
@@ -249,28 +252,28 @@ export function validateActivities(input: unknown): ActivityValidationResult {
249
252
  // be misleading).
250
253
  let startValid = true;
251
254
  let endValid = true;
252
- if (a.start_date !== undefined) {
253
- if (typeof a.start_date !== 'string' || !DATE_RE.test(a.start_date)) {
254
- errors.push({ code: 'ACT-008', message: `Activity "${a.id}" start_date "${String(a.start_date)}" must be ISO 8601 YYYY-MM-DD`, path });
255
+ if (a.start_date !== undefined && a.start_date !== null) {
256
+ if (typeof a.start_date !== 'string' || !validDate(a.start_date)) {
257
+ errors.push({ code: 'ACTION-010', message: `Activity "${a.id}" start_date "${String(a.start_date)}" must be ISO 8601 YYYY-MM-DD`, path });
255
258
  startValid = false;
256
259
  }
257
260
  }
258
- if (a.end_date !== undefined) {
259
- if (typeof a.end_date !== 'string' || !DATE_RE.test(a.end_date)) {
260
- errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${String(a.end_date)}" must be ISO 8601 YYYY-MM-DD`, path });
261
+ if (a.end_date !== undefined && a.end_date !== null) {
262
+ if (typeof a.end_date !== 'string' || !validDate(a.end_date)) {
263
+ errors.push({ code: 'ACTION-010', message: `Activity "${a.id}" end_date "${String(a.end_date)}" must be ISO 8601 YYYY-MM-DD`, path });
261
264
  endValid = false;
262
265
  }
263
266
  }
264
267
  if (a.start_date && a.end_date && startValid && endValid) {
265
268
  if (a.end_date < a.start_date) {
266
- errors.push({ code: 'ACT-008', message: `Activity "${a.id}" end_date "${a.end_date}" is before start_date "${a.start_date}"`, path });
269
+ errors.push({ code: 'ACTION-010', message: `Activity "${a.id}" end_date "${a.end_date}" is before start_date "${a.start_date}"`, path });
267
270
  }
268
271
  }
269
272
  }
270
273
 
271
274
  if (errors.length > 0) return { valid: false, errors, warnings };
272
275
 
273
- // ACT-006: no cycles
276
+ // ACTION-008: no cycles
274
277
  const cycleError = detectCycle(doc.activities);
275
278
  if (cycleError) errors.push(cycleError);
276
279
 
@@ -375,7 +378,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
375
378
  }
376
379
  }
377
380
 
378
- // ACT-019: Gantt view will not render when neither project.start_date nor
381
+ // ACT-009: Gantt view will not render when neither project.start_date nor
379
382
  // any per-activity pinned date pair is present. Network view is unaffected.
380
383
  const hasProjectStart = typeof doc.project?.start_date === 'string' && doc.project.start_date.length > 0;
381
384
  const hasPinnedActivity = doc.activities.some(
@@ -383,7 +386,7 @@ export function validateActivities(input: unknown): ActivityValidationResult {
383
386
  );
384
387
  if (!hasProjectStart && !hasPinnedActivity) {
385
388
  warnings.push({
386
- code: 'ACT-019',
389
+ code: 'ACT-009',
387
390
  message: 'Gantt view will not render: project.start_date is absent and no activity has both start_date and end_date pinned. Network view is unaffected.',
388
391
  });
389
392
  }
@@ -408,7 +411,7 @@ function detectCycle(activities: Activity[]): ActivityValidationError | null {
408
411
  for (const a of activities) inDegree.set(a.id, 0);
409
412
  for (const a of activities) {
410
413
  for (const pred of (a.predecessors ?? [])) {
411
- inDegree.set(a.id, (inDegree.get(a.id) ?? 0) + 1);
414
+ if (successors.has(pred)) inDegree.set(a.id, (inDegree.get(a.id) ?? 0) + 1);
412
415
  }
413
416
  }
414
417
 
@@ -430,7 +433,7 @@ function detectCycle(activities: Activity[]): ActivityValidationError | null {
430
433
 
431
434
  if (processed < activities.length) {
432
435
  const cycleNodes = activities.filter(a => (inDegree.get(a.id) ?? 0) > 0).map(a => a.id);
433
- return { code: 'ACT-006', message: `Cycle detected in activity dependency graph involving: ${cycleNodes.join(', ')}` };
436
+ return { code: 'ACTION-008', message: `Cycle detected in activity dependency graph involving: ${cycleNodes.join(', ')}` };
434
437
  }
435
438
  return null;
436
439
  }
@@ -1,3 +1,4 @@
1
+ import { schemaFinding } from '../schema-finding.js';
1
2
  import type { ApplicationsCatalogueFile, ApplicationType, ApplicationStatus, IntegrationDirection } from './types.js';
2
3
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
4
 
@@ -27,23 +28,23 @@ export function validateApplicationsCatalogue(input: unknown): ValidationResult
27
28
  const errors: ValidationError[] = [];
28
29
  const warnings: ValidationWarning[] = [];
29
30
 
30
- if (!input || typeof input !== 'object') {
31
- return { valid: false, errors: [{ code: 'APP-001', message: 'Input must be an object' }], warnings };
31
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
32
+ return { valid: false, errors: [schemaFinding('applications inline', '$', 'mapping', input)], warnings };
32
33
  }
33
34
 
34
35
  const raw = input as Record<string, unknown>;
35
36
 
36
37
  // APP-001: notation header
37
38
  if (!('notation' in raw)) {
38
- errors.push({ code: 'APP-001', message: 'Missing required field: notation' });
39
+ errors.push({ code: 'HDR-001', message: 'notation field is missing' });
39
40
  } else if (raw['notation'] !== 'applications') {
40
- errors.push({ code: 'APP-001', message: `notation must be "applications", got "${raw['notation']}"` });
41
+ errors.push({ code: 'HDR-002', message: 'notation must be applications' });
41
42
  }
42
43
 
43
44
  if (errors.length > 0) return { valid: false, errors, warnings };
44
45
 
45
46
  // APP-002: catalogue header fields
46
- if (!raw['applications_catalogue'] || typeof raw['applications_catalogue'] !== 'object') {
47
+ if (!raw['applications_catalogue'] || typeof raw['applications_catalogue'] !== 'object' || Array.isArray(raw['applications_catalogue'])) {
47
48
  errors.push({ code: 'APP-002', message: 'Missing required field: applications_catalogue' });
48
49
  return { valid: false, errors, warnings };
49
50
  }
@@ -62,9 +63,9 @@ export function validateApplicationsCatalogue(input: unknown): ValidationResult
62
63
 
63
64
  if (errors.length > 0) return { valid: false, errors, warnings };
64
65
 
65
- // APP-007: updated_at format
66
+ // Schema: updated_at format
66
67
  if (!DATE_RE.test(cat['updated_at'] as string)) {
67
- errors.push({ code: 'APP-007', message: `applications_catalogue.updated_at must be YYYY-MM-DD, got "${cat['updated_at']}"` });
68
+ errors.push(schemaFinding('applications inline', 'applications_catalogue.updated_at', 'YYYY-MM-DD string', cat['updated_at']));
68
69
  }
69
70
 
70
71
  const applications = cat['applications'];
@@ -73,55 +74,55 @@ export function validateApplicationsCatalogue(input: unknown): ValidationResult
73
74
  return { valid: false, errors, warnings };
74
75
  }
75
76
 
76
- // APP-008: unique app_id
77
+ // Schema: unique app_id
77
78
  const seenIds = new Set<string>();
78
79
 
79
80
  for (let i = 0; i < applications.length; i++) {
80
81
  const rawApp = applications[i];
81
82
  const idx = `applications[${i}]`;
82
83
 
83
- if (!rawApp || typeof rawApp !== 'object') {
84
- errors.push({ code: 'APP-003', message: `${idx} must be an object` });
84
+ if (!rawApp || typeof rawApp !== 'object' || Array.isArray(rawApp)) {
85
+ errors.push(schemaFinding('applications inline', `${idx}`, 'mapping', rawApp));
85
86
  continue;
86
87
  }
87
88
  const a = rawApp as Record<string, unknown>;
88
89
 
89
- // APP-003: required per-application fields
90
+ // Schema: required per-application fields
90
91
  if (!a['app_id'] || typeof a['app_id'] !== 'string' || !(a['app_id'] as string).trim()) {
91
- errors.push({ code: 'APP-003', message: `${idx}: app_id is required` });
92
+ errors.push(schemaFinding('applications inline', `${idx}.app_id`, 'nonempty string', a['app_id']));
92
93
  } else {
93
94
  const aid = a['app_id'] as string;
94
95
  if (seenIds.has(aid)) {
95
- errors.push({ code: 'APP-008', message: `Duplicate app_id: "${aid}"` });
96
+ errors.push(schemaFinding('applications inline', `${idx}.app_id`, 'unique identifier', a['app_id']));
96
97
  }
97
98
  seenIds.add(aid);
98
99
  }
99
100
 
100
101
  if (!a['name'] || typeof a['name'] !== 'string' || !(a['name'] as string).trim()) {
101
- errors.push({ code: 'APP-003', message: `${idx}: name is required` });
102
+ errors.push(schemaFinding('applications inline', `${idx}.name`, 'nonempty string', a['name']));
102
103
  }
103
104
  if (!a['type']) {
104
- errors.push({ code: 'APP-003', message: `${idx}: type is required` });
105
+ errors.push(schemaFinding('applications inline', `${idx}.type`, `one of ${[...VALID_TYPES].join(", ")}`, a['type']));
105
106
  }
106
107
  if (!a['status']) {
107
- errors.push({ code: 'APP-003', message: `${idx}: status is required` });
108
+ errors.push(schemaFinding('applications inline', `${idx}.status`, `one of ${[...VALID_STATUSES].join(", ")}`, a['status']));
108
109
  }
109
110
 
110
- // APP-004: type enum
111
+ // Schema: type enum
111
112
  if (a['type'] && !VALID_TYPES.has(a['type'] as ApplicationType)) {
112
- errors.push({ code: 'APP-004', message: `${idx}: type "${a['type']}" must be one of: application, integration, platform, data_store` });
113
+ errors.push(schemaFinding('applications inline', `${idx}.type`, `one of ${[...VALID_TYPES].join(", ")}`, a['type']));
113
114
  }
114
115
 
115
- // APP-005: status enum (4 values)
116
+ // Schema: status enum (4 values)
116
117
  if (a['status'] && !VALID_STATUSES.has(a['status'] as ApplicationStatus)) {
117
- errors.push({ code: 'APP-005', message: `${idx}: status "${a['status']}" must be one of: Draft, Active, Deprecated, Decommissioning` });
118
+ errors.push(schemaFinding('applications inline', `${idx}.status`, `one of ${[...VALID_STATUSES].join(", ")}`, a['status']));
118
119
  }
119
120
 
120
- // APP-006: maturity range
121
+ // Schema: maturity range
121
122
  if (a['maturity'] !== undefined) {
122
123
  const m = a['maturity'];
123
124
  if (typeof m !== 'number' || !Number.isInteger(m) || m < 1 || m > 5) {
124
- errors.push({ code: 'APP-006', message: `${idx}: maturity must be an integer 1–5, got "${m}"` });
125
+ errors.push(schemaFinding('applications inline', `${idx}.maturity`, 'integer in 1–5', a['maturity']));
125
126
  }
126
127
  }
127
128
 
@@ -137,18 +138,18 @@ export function validateApplicationsCatalogue(input: unknown): ValidationResult
137
138
  }
138
139
  }
139
140
 
140
- // APP-009: integrations[].direction enum
141
+ // Schema: integrations[].direction enum
141
142
  if (Array.isArray(a['integrations'])) {
142
143
  const integrations = a['integrations'] as unknown[];
143
144
  for (let j = 0; j < integrations.length; j++) {
144
145
  const rawIntg = integrations[j];
145
- if (!rawIntg || typeof rawIntg !== 'object') {
146
- errors.push({ code: 'APP-009', message: `${idx}.integrations[${j}] must be an object` });
146
+ if (!rawIntg || typeof rawIntg !== 'object' || Array.isArray(rawIntg)) {
147
+ errors.push(schemaFinding('applications inline', `${idx}.integrations[${j}]`, 'mapping', rawIntg));
147
148
  continue;
148
149
  }
149
150
  const intg = rawIntg as Record<string, unknown>;
150
151
  if (intg['direction'] !== undefined && !VALID_DIRECTIONS.has(intg['direction'] as IntegrationDirection)) {
151
- errors.push({ code: 'APP-009', message: `${idx}.integrations[${j}]: direction "${intg['direction']}" must be one of: inbound, outbound, bidirectional` });
152
+ errors.push(schemaFinding('applications inline', `${idx}.integrations[${j}].direction`, 'inbound | outbound | bidirectional', intg['direction']));
152
153
  }
153
154
 
154
155
  // INT-001 — when interface_semantics: true, four fields become required.
@@ -1,3 +1,5 @@
1
+ import type { ResolvedCapabilityAttributes } from './resolve-maturity.js';
2
+ import { schemaFinding } from '../schema-finding.js';
1
3
  import type { CapabilityType } from './types.js';
2
4
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
5
 
@@ -11,31 +13,25 @@ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
11
13
  // canonical prefix do not break; migration is tracked separately.
12
14
  const CAP_ID_RE = /^(CAPABILITY-)?(V|H)\d+(\.\d+)*$/;
13
15
 
14
- export function validateCapabilityMap(input: unknown): ValidationResult {
16
+ export function validateCapabilityMap(input: unknown, options: { resolvedAttributes?: ReadonlyMap<string, ResolvedCapabilityAttributes> } = {}): ValidationResult {
15
17
  const errors: ValidationError[] = [];
16
18
  const warnings: ValidationWarning[] = [];
17
19
 
18
- if (!input || typeof input !== 'object') {
19
- return { valid: false, errors: [{ code: 'CMAP-001', message: 'Input must be an object' }], warnings };
20
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
21
+ return { valid: false, errors: [schemaFinding('capability-map inline', '$', 'mapping', input)], warnings };
20
22
  }
21
23
  const raw = input as Record<string, unknown>;
22
24
 
23
- // HDR-001/002 — DSM's own rule codes for the same header check CMAP-001
24
- // already makes (api02/internal/importer/capabilities.go ValidateCapMap),
25
- // added alongside it (not instead of it) so DSM can map a finding straight
26
- // back onto its own import-log taxonomy while CMAP-001 stays the richer,
27
- // pre-existing Studio-native code for this notation's own validator.
25
+ // Shared header rules have precedence over generic schema findings.
28
26
  if (!('notation' in raw)) {
29
- errors.push({ code: 'CMAP-001', message: 'Missing required field: notation' });
30
27
  errors.push({ code: 'HDR-001', message: 'notation field is missing' });
31
28
  } else if (raw['notation'] !== 'capability-map') {
32
- errors.push({ code: 'CMAP-001', message: `notation must be "capability-map", got "${raw['notation']}"` });
33
29
  errors.push({ code: 'HDR-002', message: `notation must be "capability-map", got "${raw['notation']}"` });
34
30
  }
35
31
  if (errors.length > 0) return { valid: false, errors, warnings };
36
32
 
37
33
  const map = raw['capability_map'];
38
- if (!map || typeof map !== 'object') {
34
+ if (!map || typeof map !== 'object' || Array.isArray(map)) {
39
35
  errors.push({ code: 'CMAP-002', message: 'Missing required field: capability_map' });
40
36
  return { valid: false, errors, warnings };
41
37
  }
@@ -51,7 +47,7 @@ export function validateCapabilityMap(input: unknown): ValidationResult {
51
47
  if (errors.length > 0) return { valid: false, errors, warnings };
52
48
 
53
49
  if (!DATE_RE.test(m['assessment_date'] as string))
54
- errors.push({ code: 'CMAP-007', message: `capability_map.assessment_date must be YYYY-MM-DD, got "${m['assessment_date']}"` });
50
+ errors.push(schemaFinding('capability-map inline', 'capability_map.assessment_date', 'YYYY-MM-DD string', m['assessment_date']));
55
51
 
56
52
  const caps = m['capabilities'];
57
53
  if (!Array.isArray(caps)) {
@@ -60,7 +56,7 @@ export function validateCapabilityMap(input: unknown): ValidationResult {
60
56
  }
61
57
 
62
58
  const seenIds = new Set<string>();
63
- validateCapabilityTree(caps, 'capabilities', errors, seenIds);
59
+ validateCapabilityTree(caps, 'capabilities', errors, seenIds, warnings, options.resolvedAttributes);
64
60
 
65
61
  return { valid: errors.length === 0, errors, warnings };
66
62
  }
@@ -70,57 +66,65 @@ function validateCapabilityTree(
70
66
  pathPrefix: string,
71
67
  errors: ValidationError[],
72
68
  seenIds: Set<string>,
69
+ warnings: ValidationWarning[],
70
+ resolved?: ReadonlyMap<string, ResolvedCapabilityAttributes>,
73
71
  ): void {
74
72
  for (let i = 0; i < nodes.length; i++) {
75
73
  const rawNode = nodes[i];
76
74
  const nodePath = `${pathPrefix}[${i}]`;
77
75
 
78
- if (!rawNode || typeof rawNode !== 'object') {
79
- errors.push({ code: 'CMAP-003', message: `${nodePath} must be an object` });
76
+ if (!rawNode || typeof rawNode !== 'object' || Array.isArray(rawNode)) {
77
+ errors.push(schemaFinding('capability-map inline', `${nodePath}`, 'mapping', rawNode));
80
78
  continue;
81
79
  }
82
80
  const node = rawNode as Record<string, unknown>;
83
81
 
84
82
  if (!node['id'] || typeof node['id'] !== 'string' || !(node['id'] as string).trim()) {
85
- errors.push({ code: 'CMAP-003', message: `${nodePath}: id is required` });
83
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.id`, 'nonempty string', node['id']));
86
84
  } else {
87
85
  const id = node['id'] as string;
88
86
  if (seenIds.has(id)) {
89
- errors.push({ code: 'CMAP-008', message: `Duplicate capability id: "${id}"` });
87
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.id`, 'unique identifier', node['id']));
90
88
  }
91
89
  seenIds.add(id);
92
90
  if (!CAP_ID_RE.test(id)) {
93
- errors.push({ code: 'CMAP-009', message: `${nodePath}: id "${id}" must match the canonical pattern CAPABILITY-V[n] or CAPABILITY-H[n] with optional .n segments (e.g. CAPABILITY-V1, CAPABILITY-V1.2, CAPABILITY-H1.3); the bare V[n] / H[n] form is also accepted for legacy compatibility` });
91
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.id`, 'CAPABILITY-V[n] / CAPABILITY-H[n] (legacy V[n] / H[n]) with optional .n segments', node['id']));
94
92
  }
95
93
  }
96
94
 
97
95
  if (!node['name'] || typeof node['name'] !== 'string' || !(node['name'] as string).trim())
98
- errors.push({ code: 'CMAP-003', message: `${nodePath}: name is required` });
96
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.name`, 'nonempty string', node['name']));
99
97
 
100
- if (node['current_maturity'] === undefined) {
101
- errors.push({ code: 'CMAP-003', message: `${nodePath}: current_maturity is required` });
98
+ for (const field of ['current_maturity', 'target_maturity', 'target_date', 'owner_role']) {
99
+ if (node[field] !== undefined) errors.push({code: 'VERSIONED-004', path: `${nodePath}.${field}`,
100
+ message: `${nodePath}.${field} belongs in ${String(node['id'])}.history.yaml, not inline.`});
101
+ }
102
+ const currentMaturity = node['current_maturity'] ?? resolved?.get(String(node['id']))?.current_maturity;
103
+ if (currentMaturity === undefined && resolved === undefined) {
104
+ warnings.push({code: 'NOTATION-SKIP-001', path: `${nodePath}.current_maturity`,
105
+ message: `${nodePath}.current_maturity cannot be checked without the capability sidecar catalogue; this file remains unvalidated.`});
102
106
  } else {
103
- const cm = node['current_maturity'];
107
+ const cm = currentMaturity;
104
108
  if (typeof cm !== 'number' || !Number.isInteger(cm) || cm < 1 || cm > 5)
105
- errors.push({ code: 'CMAP-005', message: `${nodePath}: current_maturity must be an integer 1–5, got "${cm}"` });
109
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.current_maturity`, 'integer in 1–5 from the sidecar at the assessment date', currentMaturity));
106
110
  }
107
111
 
108
112
  if (node['target_maturity'] !== undefined) {
109
113
  const tm = node['target_maturity'];
110
114
  if (typeof tm !== 'number' || !Number.isInteger(tm) || tm < 1 || tm > 5)
111
- errors.push({ code: 'CMAP-006', message: `${nodePath}: target_maturity must be an integer 1–5, got "${tm}"` });
115
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.target_maturity`, 'integer in 1–5', node['target_maturity']));
112
116
  }
113
117
 
114
118
  if (node['type'] !== undefined && !VALID_TYPES.has(node['type'] as CapabilityType))
115
- errors.push({ code: 'CMAP-004', message: `${nodePath}: type "${node['type']}" must be one of: domain, supporting` });
119
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.type`, `one of ${[...VALID_TYPES].join(", ")}`, node['type']));
116
120
 
117
121
  if (node['target_date'] !== undefined) {
118
122
  if (typeof node['target_date'] !== 'string' || !DATE_RE.test(node['target_date'] as string))
119
- errors.push({ code: 'CMAP-007', message: `${nodePath}: target_date must be YYYY-MM-DD, got "${node['target_date']}"` });
123
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.target_date`, 'YYYY-MM-DD string', node['target_date']));
120
124
  }
121
125
 
122
126
  if (node['applications'] !== undefined && !Array.isArray(node['applications']))
123
- errors.push({ code: 'CMAP-003', message: `${nodePath}: applications must be an array` });
127
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.applications`, 'array', node['applications']));
124
128
 
125
129
  // LIFECYCLE-001/004 — DSM's own rule codes (capabilities.go
126
130
  // ValidateCapMap/validateCapMapNodes) for `valid_from`/`valid_to`, the
@@ -157,9 +161,9 @@ function validateCapabilityTree(
157
161
 
158
162
  if (node['children'] !== undefined) {
159
163
  if (!Array.isArray(node['children'])) {
160
- errors.push({ code: 'CMAP-003', message: `${nodePath}: children must be an array` });
164
+ errors.push(schemaFinding('capability-map inline', `${nodePath}.children`, 'array', node['children']));
161
165
  } else {
162
- validateCapabilityTree(node['children'] as unknown[], `${nodePath}.children`, errors, seenIds);
166
+ validateCapabilityTree(node['children'] as unknown[], `${nodePath}.children`, errors, seenIds, warnings, resolved);
163
167
  }
164
168
  }
165
169
  }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // CODEX-001 — shape, zone, id grammar, admission envelope.
4
4
  // CODEX-002 — `type` field matches the id TYPE prefix when present.
5
- // CODEX-003 — external artefact frontmatter (jurisdiction, effective_date).
5
+ // CODEX-002 — external artefact frontmatter (jurisdiction, effective_date).
6
6
  // CODEX-004 — internal artefact frontmatter (issuing_authority, effective_date for
7
7
  // POLICY/INTERNAL_STANDARD; statement, rationale for PRINCIPLE — §4.1).
8
8
  // CODEX-005 — jurisdiction must match the parent folder under codex/external/
@@ -92,7 +92,7 @@ export function validateCodex(input: unknown, options: CodexValidateOptions = {}
92
92
  if (artefactType && (EXTERNAL_CODEX_TYPES as readonly string[]).includes(artefactType)) {
93
93
  const jurisdiction = typeof c.jurisdiction === 'string' ? c.jurisdiction.trim() : '';
94
94
  if (!jurisdiction) {
95
- errors.push({ code: 'CODEX-003', message: 'jurisdiction is required for external codex artefacts.', path: 'jurisdiction' });
95
+ errors.push({ code: 'CODEX-002', message: 'jurisdiction is required for external codex artefacts.', path: 'jurisdiction' });
96
96
  } else if (options.folderJurisdiction && jurisdiction.toLowerCase() !== options.folderJurisdiction.toLowerCase()) {
97
97
  errors.push({
98
98
  code: 'CODEX-005',
@@ -101,7 +101,7 @@ export function validateCodex(input: unknown, options: CodexValidateOptions = {}
101
101
  });
102
102
  }
103
103
  if (typeof c.effective_date !== 'string' || c.effective_date.trim() === '') {
104
- errors.push({ code: 'CODEX-003', message: 'effective_date is required for external codex artefacts.', path: 'effective_date' });
104
+ errors.push({ code: 'CODEX-002', message: 'effective_date is required for external codex artefacts.', path: 'effective_date' });
105
105
  }
106
106
  }
107
107