@transitrix/diagrams 1.10.0 → 1.12.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 (71) hide show
  1. package/dist/activities/resolver.d.ts +3 -0
  2. package/dist/activities/resolver.js +39 -18
  3. package/dist/activities/validate.js +67 -4
  4. package/dist/canon-resolver-utils.d.ts +3 -0
  5. package/dist/canon-resolver-utils.js +16 -0
  6. package/dist/compliance/classify.js +1 -0
  7. package/dist/compliance/impact.d.ts +3 -1
  8. package/dist/compliance/impact.js +41 -13
  9. package/dist/compliance/types.d.ts +6 -0
  10. package/dist/compliance-verification-matrix/build.d.ts +17 -0
  11. package/dist/compliance-verification-matrix/build.js +65 -0
  12. package/dist/compliance-verification-matrix/csv.d.ts +6 -0
  13. package/dist/compliance-verification-matrix/csv.js +41 -0
  14. package/dist/compliance-verification-matrix/index.d.ts +3 -0
  15. package/dist/compliance-verification-matrix/index.js +2 -0
  16. package/dist/compliance-verification-matrix/types.d.ts +75 -0
  17. package/dist/compliance-verification-matrix/types.js +4 -0
  18. package/dist/fgca/action-change-ids.d.ts +7 -0
  19. package/dist/fgca/action-change-ids.js +11 -0
  20. package/dist/fgca/parse-canonical.d.ts +17 -3
  21. package/dist/fgca/parse-canonical.js +44 -16
  22. package/dist/fgca/resolver.d.ts +6 -2
  23. package/dist/fgca/resolver.js +28 -6
  24. package/dist/fgca/validate.d.ts +2 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/process-blueprint/index.d.ts +3 -1
  28. package/dist/process-blueprint/index.js +1 -0
  29. package/dist/process-blueprint/layout.js +33 -29
  30. package/dist/process-blueprint/resolve-columns.d.ts +52 -0
  31. package/dist/process-blueprint/resolve-columns.js +138 -0
  32. package/dist/process-blueprint/types.d.ts +23 -5
  33. package/dist/process-blueprint/validate.d.ts +19 -1
  34. package/dist/process-blueprint/validate.js +73 -21
  35. package/dist/repo-validate/check-process-parent.d.ts +8 -0
  36. package/dist/repo-validate/check-process-parent.js +172 -0
  37. package/dist/repo-validate/index.d.ts +1 -0
  38. package/dist/repo-validate/index.js +1 -0
  39. package/dist/repo-validate/validate-repo.js +3 -0
  40. package/dist/webview/entry.js +26 -4
  41. package/dist/webview/render-fgca.d.ts +2 -0
  42. package/dist/webview/render-fgca.js +9 -3
  43. package/dist/webview/render-process.d.ts +19 -1
  44. package/dist/webview/render-process.js +75 -42
  45. package/package.json +3 -3
  46. package/src/activities/resolver.ts +40 -20
  47. package/src/activities/validate.ts +66 -4
  48. package/src/canon-resolver-utils.ts +19 -0
  49. package/src/compliance/classify.ts +1 -0
  50. package/src/compliance/impact.ts +41 -12
  51. package/src/compliance/types.ts +6 -0
  52. package/src/compliance-verification-matrix/build.ts +84 -0
  53. package/src/compliance-verification-matrix/csv.ts +47 -0
  54. package/src/compliance-verification-matrix/index.ts +7 -0
  55. package/src/compliance-verification-matrix/types.ts +85 -0
  56. package/src/fgca/action-change-ids.ts +13 -0
  57. package/src/fgca/parse-canonical.ts +56 -15
  58. package/src/fgca/resolver.ts +39 -6
  59. package/src/fgca/validate.ts +2 -0
  60. package/src/index.ts +1 -0
  61. package/src/process-blueprint/index.ts +11 -0
  62. package/src/process-blueprint/layout.ts +48 -29
  63. package/src/process-blueprint/resolve-columns.ts +147 -0
  64. package/src/process-blueprint/types.ts +19 -5
  65. package/src/process-blueprint/validate.ts +107 -21
  66. package/src/repo-validate/check-process-parent.ts +184 -0
  67. package/src/repo-validate/index.ts +5 -0
  68. package/src/repo-validate/validate-repo.ts +3 -0
  69. package/src/webview/entry.ts +26 -4
  70. package/src/webview/render-fgca.ts +13 -2
  71. package/src/webview/render-process.ts +94 -49
@@ -19,6 +19,7 @@ import type {
19
19
  StageTextCell,
20
20
  } from './types.js';
21
21
  import { wrapTextLines } from '../webview/entity-text-layout.js';
22
+ import { columnIndexesForRealisedVia, resolveColumnDisplay } from './resolve-columns.js';
22
23
 
23
24
  const ASPECT_CATEGORIES: AspectCategory[] = ['systems', 'actors', 'equipment', 'information_entities'];
24
25
 
@@ -33,7 +34,13 @@ const ASPECT_LABEL: Record<AspectCategory, string> = {
33
34
  // (absent = feature disabled) and must stay optional in the resolved type.
34
35
  type SizingOptionKeys = Exclude<
35
36
  keyof ProcessBlueprintLayoutOptions,
36
- 'complianceLane' | 'complianceInput' | 'visibleAspects' | 'visibleRows' | 'visibleStages'
37
+ | 'complianceLane'
38
+ | 'complianceInput'
39
+ | 'visibleAspects'
40
+ | 'visibleRows'
41
+ | 'visibleStages'
42
+ | 'processCatalog'
43
+ | 'stepHomeProcess'
37
44
  >;
38
45
 
39
46
  /**
@@ -41,7 +48,16 @@ type SizingOptionKeys = Exclude<
41
48
  * the opt-in feature fields stay optional (undefined = feature disabled).
42
49
  */
43
50
  type ResolvedLayoutOptions = Required<Pick<ProcessBlueprintLayoutOptions, SizingOptionKeys>> &
44
- Pick<ProcessBlueprintLayoutOptions, 'complianceLane' | 'complianceInput' | 'visibleAspects' | 'visibleRows' | 'visibleStages'>;
51
+ Pick<
52
+ ProcessBlueprintLayoutOptions,
53
+ | 'complianceLane'
54
+ | 'complianceInput'
55
+ | 'visibleAspects'
56
+ | 'visibleRows'
57
+ | 'visibleStages'
58
+ | 'processCatalog'
59
+ | 'stepHomeProcess'
60
+ >;
45
61
 
46
62
  const DEFAULTS = {
47
63
  legendColumnWidth: 140,
@@ -258,27 +274,29 @@ function deriveComplianceRow(
258
274
 
259
275
  for (const stageRef of realisedVia) {
260
276
  if (typeof stageRef !== 'string') continue;
261
- const idx = stageIndexById.get(stageRef.trim());
262
- if (idx === undefined) continue;
263
-
264
- for (const lawId of lawIds) {
265
- // Apply jurisdiction filter.
266
- if (filterJurisdictions) {
267
- const jur = input.codexJurisdictions?.[lawId];
268
- if (!jur || !filterJurisdictions.has(jur)) continue;
269
- }
277
+ const idxs = columnIndexesForRealisedVia(stageRef, stageIndexById, opts.stepHomeProcess);
278
+ if (idxs.length === 0) continue;
279
+
280
+ for (const idx of idxs) {
281
+ for (const lawId of lawIds) {
282
+ // Apply jurisdiction filter.
283
+ if (filterJurisdictions) {
284
+ const jur = input.codexJurisdictions?.[lawId];
285
+ if (!jur || !filterJurisdictions.has(jur)) continue;
286
+ }
270
287
 
271
- if (!stageAccum.has(idx)) stageAccum.set(idx, new Map());
272
- const lawMap = stageAccum.get(idx)!;
273
- const existing = lawMap.get(lawId);
274
- const isGap = a.status === 'non_compliant' || a.status === 'partial';
275
- if (!existing) {
276
- lawMap.set(lawId, { gap: isGap, deadline: req.deadline });
277
- } else {
278
- existing.gap = existing.gap || isGap;
279
- // Keep the earlier deadline (more urgent).
280
- if (req.deadline && (!existing.deadline || req.deadline < existing.deadline)) {
281
- existing.deadline = req.deadline;
288
+ if (!stageAccum.has(idx)) stageAccum.set(idx, new Map());
289
+ const lawMap = stageAccum.get(idx)!;
290
+ const existing = lawMap.get(lawId);
291
+ const isGap = a.status === 'non_compliant' || a.status === 'partial';
292
+ if (!existing) {
293
+ lawMap.set(lawId, { gap: isGap, deadline: req.deadline });
294
+ } else {
295
+ existing.gap = existing.gap || isGap;
296
+ // Keep the earlier deadline (more urgent).
297
+ if (req.deadline && (!existing.deadline || req.deadline < existing.deadline)) {
298
+ existing.deadline = req.deadline;
299
+ }
282
300
  }
283
301
  }
284
302
  }
@@ -338,6 +356,7 @@ export function layoutProcessBlueprint(
338
356
  ? allStages.filter(s => s?.id != null && opts.visibleStages!.includes(s.id))
339
357
  : allStages;
340
358
  const stageIndexById = buildStageIndex(stages);
359
+ const display = stages.map(s => resolveColumnDisplay(s, opts.processCatalog));
341
360
 
342
361
  // Row visibility from visibleRows
343
362
  const visRows = opts.visibleRows;
@@ -354,7 +373,7 @@ export function layoutProcessBlueprint(
354
373
  const stageHeaders: StageHeaderCell[] = stages.map((s, i) => ({
355
374
  stageIndex: i,
356
375
  id: s?.id ?? '',
357
- name: s?.name ?? '',
376
+ name: display[i].name,
358
377
  x: opts.legendColumnWidth + i * opts.stageColumnWidth,
359
378
  y: 0,
360
379
  width: opts.stageColumnWidth,
@@ -373,8 +392,8 @@ export function layoutProcessBlueprint(
373
392
  return Math.max(minHeight, maxLines * opts.textLineHeight + 2 * opts.cellTextPadY);
374
393
  };
375
394
 
376
- const goalLines = stages.map(s => wrap(s?.goal ?? ''));
377
- const resultLines = stages.map(s => wrap(s?.result ?? ''));
395
+ const goalLines = display.map(d => wrap(d.goal));
396
+ const resultLines = display.map(d => wrap(d.result));
378
397
 
379
398
  let cursorY = opts.stageHeaderHeight;
380
399
  let goalRowY = 0, goalRowHeight = 0;
@@ -385,8 +404,8 @@ export function layoutProcessBlueprint(
385
404
  if (showGoal) {
386
405
  goalRowY = cursorY;
387
406
  goalRowHeight = rowHeightFor(goalLines.map(l => l.length), opts.goalRowHeight);
388
- goalCells.push(...stages.map((s, i) => ({
389
- stageIndex: i, text: s?.goal ?? '', lines: goalLines[i],
407
+ goalCells.push(...display.map((d, i) => ({
408
+ stageIndex: i, text: d.goal, lines: goalLines[i],
390
409
  x: opts.legendColumnWidth + i * opts.stageColumnWidth, y: goalRowY,
391
410
  width: opts.stageColumnWidth, height: goalRowHeight,
392
411
  })));
@@ -395,8 +414,8 @@ export function layoutProcessBlueprint(
395
414
  if (showResult) {
396
415
  resultRowY = cursorY;
397
416
  resultRowHeight = rowHeightFor(resultLines.map(l => l.length), opts.resultRowHeight);
398
- resultCells.push(...stages.map((s, i) => ({
399
- stageIndex: i, text: s?.result ?? '', lines: resultLines[i],
417
+ resultCells.push(...display.map((d, i) => ({
418
+ stageIndex: i, text: d.result, lines: resultLines[i],
400
419
  x: opts.legendColumnWidth + i * opts.stageColumnWidth, y: resultRowY,
401
420
  width: opts.stageColumnWidth, height: resultRowHeight,
402
421
  })));
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Render-time resolution of Process Blueprint column headers and the
3
+ * compliance-lane join key.
4
+ *
5
+ * A `PROCESS-…` column is the process: `name` / `goal` / `result` come from
6
+ * the child PROCESS element (ELEMENT_PRIMITIVES §7.5), never from restated
7
+ * view fields. A `STAGE-…` column keeps the authored sketch copy.
8
+ *
9
+ * Pure: no I/O. The host (VS Code preview, JCEF, CLI) already owns loading
10
+ * canon/elements/** — this module only knows how to pick PROCESS / STEP
11
+ * records out of that pool.
12
+ *
13
+ * This is a read model, never storage. Callers render an ephemeral display
14
+ * copy and must never serialise it back onto the view.
15
+ */
16
+
17
+ import type { Stage } from './types.js';
18
+
19
+ const PROCESS_ID_RE = /^PROCESS(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
20
+ const STAGE_ID_RE = /^STAGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
21
+
22
+ export function isProcessColumnId(id: string): boolean {
23
+ return PROCESS_ID_RE.test(id);
24
+ }
25
+
26
+ export function isStageColumnId(id: string): boolean {
27
+ return STAGE_ID_RE.test(id);
28
+ }
29
+
30
+ /** Display fields taken from a `notation: process` element. */
31
+ export interface ProcessColumnRecord {
32
+ name: string;
33
+ goal?: string;
34
+ result?: string;
35
+ }
36
+
37
+ function isRecord(v: unknown): v is Record<string, unknown> {
38
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
39
+ }
40
+
41
+ function optionalString(v: unknown): string | undefined {
42
+ return typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
43
+ }
44
+
45
+ /**
46
+ * Collect admitted PROCESS elements from a raw canon/elements/** doc pool,
47
+ * keyed by id. Malformed documents are skipped.
48
+ */
49
+ export function collectProcessColumnRecords(docs: readonly unknown[]): Map<string, ProcessColumnRecord> {
50
+ const out = new Map<string, ProcessColumnRecord>();
51
+ for (const doc of docs) {
52
+ if (!isRecord(doc)) continue;
53
+ if (doc['notation'] !== 'process') continue;
54
+ const id = optionalString(doc['id']);
55
+ if (!id || !isProcessColumnId(id)) continue;
56
+ const name = optionalString(doc['name']);
57
+ if (!name) continue;
58
+ const rec: ProcessColumnRecord = { name };
59
+ const goal = optionalString(doc['goal']);
60
+ const result = optionalString(doc['result']);
61
+ if (goal) rec.goal = goal;
62
+ if (result) rec.result = result;
63
+ out.set(id, rec);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Map each STEP id onto the PROCESS that contains it — either inline in
70
+ * `PROCESS.flow.steps[]` or a promoted `{ notation: step, process }` element.
71
+ * First writer wins; a later duplicate is ignored.
72
+ */
73
+ export function collectStepHomeProcess(docs: readonly unknown[]): Map<string, string> {
74
+ const out = new Map<string, string>();
75
+ for (const doc of docs) {
76
+ if (!isRecord(doc)) continue;
77
+ if (doc['notation'] === 'process') {
78
+ const processId = optionalString(doc['id']);
79
+ if (!processId || !isProcessColumnId(processId)) continue;
80
+ const flow = isRecord(doc['flow']) ? doc['flow'] : undefined;
81
+ const steps = flow && Array.isArray(flow['steps']) ? flow['steps'] : [];
82
+ for (const step of steps) {
83
+ if (!isRecord(step)) continue;
84
+ const stepId = optionalString(step['id']);
85
+ if (!stepId || out.has(stepId)) continue;
86
+ out.set(stepId, processId);
87
+ }
88
+ continue;
89
+ }
90
+ if (doc['notation'] === 'step') {
91
+ const stepId = optionalString(doc['id']);
92
+ const processId = optionalString(doc['process']);
93
+ if (!stepId || !processId || !isProcessColumnId(processId)) continue;
94
+ if (!out.has(stepId)) out.set(stepId, processId);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Display copy for one column. PROCESS- columns ignore any restated view
102
+ * fields; missing catalogue falls back to the id (header) and empty
103
+ * goal/result. STAGE- columns keep authored name/goal/result.
104
+ */
105
+ export function resolveColumnDisplay(
106
+ stage: Stage,
107
+ catalog?: ReadonlyMap<string, ProcessColumnRecord>,
108
+ ): { name: string; goal: string; result: string } {
109
+ const id = typeof stage.id === 'string' ? stage.id.trim() : '';
110
+ if (isProcessColumnId(id)) {
111
+ const rec = catalog?.get(id);
112
+ return {
113
+ name: rec?.name ?? id,
114
+ goal: rec?.goal ?? '',
115
+ result: rec?.result ?? '',
116
+ };
117
+ }
118
+ return {
119
+ name: typeof stage.name === 'string' ? stage.name : '',
120
+ goal: typeof stage.goal === 'string' ? stage.goal : '',
121
+ result: typeof stage.result === 'string' ? stage.result : '',
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Column indexes a `realised_via` token pins on this blueprint.
127
+ *
128
+ * `STAGE-…` is never a join key (ASSERT-004). A `PROCESS-…` token pins that
129
+ * column. A STEP whose home process is a column pins that column.
130
+ */
131
+ export function columnIndexesForRealisedVia(
132
+ ref: string,
133
+ stageIndexById: ReadonlyMap<string, number>,
134
+ stepHomeProcess?: ReadonlyMap<string, string>,
135
+ ): number[] {
136
+ const token = ref.trim();
137
+ if (token.length === 0) return [];
138
+ if (isStageColumnId(token)) return [];
139
+ const direct = stageIndexById.get(token);
140
+ if (direct !== undefined && isProcessColumnId(token)) return [direct];
141
+ const home = stepHomeProcess?.get(token);
142
+ if (home && isProcessColumnId(home)) {
143
+ const idx = stageIndexById.get(home);
144
+ if (idx !== undefined) return [idx];
145
+ }
146
+ return [];
147
+ }
@@ -33,8 +33,9 @@ export interface ComplianceLaneAssertion {
33
33
  /** Compliance realisation status. */
34
34
  status: 'compliant' | 'partial' | 'non_compliant' | 'under_review' | 'pending_owner' | 'n_a';
35
35
  /**
36
- * Typed IDs of stages / process steps where the requirement is realised.
37
- * When empty/absent, the assertion covers the entire subject, not a specific stage.
36
+ * Typed IDs of processes or steps where the requirement is realised.
37
+ * Sketch `STAGE-…` ids are not a join key. When empty/absent, the assertion
38
+ * covers the entire subject, not a specific column.
38
39
  */
39
40
  realised_via?: string[];
40
41
  }
@@ -100,9 +101,12 @@ export interface ComplianceRow {
100
101
 
101
102
  export interface Stage {
102
103
  id: string;
103
- name: string;
104
- goal: string;
105
- result: string;
104
+ /** Required on `STAGE-…` sketch columns (`BP-005`); omitted on `PROCESS-…`. */
105
+ name?: string;
106
+ /** Required on `STAGE-…` sketch columns (`BP-005`); omitted on `PROCESS-…`. */
107
+ goal?: string;
108
+ /** Required on `STAGE-…` sketch columns (`BP-005`); omitted on `PROCESS-…`. */
109
+ result?: string;
106
110
  description?: string;
107
111
  }
108
112
 
@@ -204,6 +208,16 @@ export interface ProcessBlueprintLayoutOptions {
204
208
  * When absent, all stages are shown.
205
209
  */
206
210
  visibleStages?: string[];
211
+ /**
212
+ * Catalogue of `PROCESS-…` column records (id → name / optional goal / result).
213
+ * Required to header catalogued columns; ignored for `STAGE-…` sketches.
214
+ */
215
+ processCatalog?: ReadonlyMap<string, { name: string; goal?: string; result?: string }>;
216
+ /**
217
+ * STEP id → home PROCESS id (contained in `PROCESS.flow` or promoted).
218
+ * Used by the compliance-lane join; omit to skip STEP-level pins.
219
+ */
220
+ stepHomeProcess?: ReadonlyMap<string, string>;
207
221
  }
208
222
 
209
223
  import type { LayoutBounds } from '../geometry.js';
@@ -1,21 +1,63 @@
1
1
  import type { AspectCategory } from './types.js';
2
2
  import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
3
+ import type { CanonCatalog } from '../typed-id.js';
3
4
 
4
5
  export type { ValidationError, ValidationWarning, ValidationResult };
5
6
 
6
7
  const ID_GRAMMAR_RE = /^[A-Z][A-Z_]*(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
7
8
  const PROCESS_BLUEPRINT_ID_RE = /^PROCESS_BLUEPRINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
8
9
  const STAGE_ID_RE = /^STAGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
10
+ const PROCESS_ID_RE = /^PROCESS(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
9
11
  const APPLICATION_ID_RE = /^APPLICATION(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
10
12
  const ROLE_ID_RE = /^ROLE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
11
13
 
12
14
  const ASPECT_CATEGORIES: AspectCategory[] = ['systems', 'actors', 'equipment', 'information_entities'];
13
15
 
16
+ const RESTATED_COLUMN_FIELDS = ['name', 'goal', 'result'] as const;
17
+
18
+ export interface ProcessParentEdge {
19
+ /** Child process id (`from`). */
20
+ from: string;
21
+ /** Parent process id (`to`). */
22
+ to: string;
23
+ }
24
+
25
+ export interface ProcessBlueprintValidateOptions {
26
+ /** When provided, BP-012 resolves `PROCESS-…` column ids against admitted canon. */
27
+ catalog?: CanonCatalog;
28
+ /**
29
+ * In-effect `process_parent` edges (child → parent). When provided, BP-014
30
+ * warns if `process_blueprint.process` is set and a `PROCESS-…` column has
31
+ * no such edge to that parent. Omit to skip BP-014 (file-scope without the
32
+ * relation catalogue).
33
+ */
34
+ processParentEdges?: readonly ProcessParentEdge[];
35
+ }
36
+
14
37
  function isNonEmptyString(v: unknown): v is string {
15
38
  return typeof v === 'string' && v.trim().length > 0;
16
39
  }
17
40
 
18
- export function validateProcessBlueprint(input: unknown): ValidationResult {
41
+ function isProcessColumnId(id: string): boolean {
42
+ return PROCESS_ID_RE.test(id);
43
+ }
44
+
45
+ function isStageColumnId(id: string): boolean {
46
+ return STAGE_ID_RE.test(id);
47
+ }
48
+
49
+ function isColumnId(id: string): boolean {
50
+ return isStageColumnId(id) || isProcessColumnId(id);
51
+ }
52
+
53
+ function fieldIsPresent(record: Record<string, unknown>, key: string): boolean {
54
+ return Object.prototype.hasOwnProperty.call(record, key);
55
+ }
56
+
57
+ export function validateProcessBlueprint(
58
+ input: unknown,
59
+ options: ProcessBlueprintValidateOptions = {},
60
+ ): ValidationResult {
19
61
  const errors: ValidationError[] = [];
20
62
  const warnings: ValidationWarning[] = [];
21
63
 
@@ -63,6 +105,7 @@ export function validateProcessBlueprint(input: unknown): ValidationResult {
63
105
  }
64
106
 
65
107
  const stageIds = new Set<string>();
108
+ const processColumnIds: string[] = [];
66
109
  for (let i = 0; i < stagesRaw.length; i++) {
67
110
  const s = stagesRaw[i] as Record<string, unknown> | undefined;
68
111
  const path = `stages[${i}]`;
@@ -71,29 +114,72 @@ export function validateProcessBlueprint(input: unknown): ValidationResult {
71
114
  continue;
72
115
  }
73
116
  if (!isNonEmptyString(s['id'])) {
74
- errors.push({ code: 'BP-005', message: `${path}.id is required` });
117
+ errors.push({ code: 'BP-006', message: `${path}.id is required` });
118
+ continue;
119
+ }
120
+
121
+ const sid = s['id'].trim();
122
+ if (stageIds.has(sid)) {
123
+ errors.push({ code: 'BP-006', message: `Duplicate stage id: "${sid}"` });
75
124
  } else {
76
- const sid = s['id'].trim();
77
- if (stageIds.has(sid)) {
78
- errors.push({ code: 'BP-006', message: `Duplicate stage id: "${sid}"` });
79
- } else {
80
- stageIds.add(sid);
81
- }
82
- if (!STAGE_ID_RE.test(sid)) {
83
- errors.push({
84
- code: 'BP-006',
85
- message: `${path}.id "${sid}" must match STAGE-[<middle>-]<INTEGER>`,
86
- });
87
- }
125
+ stageIds.add(sid);
88
126
  }
89
- if (!isNonEmptyString(s['name'])) {
90
- errors.push({ code: 'BP-005', message: `${path}.name is required` });
127
+ if (!isColumnId(sid)) {
128
+ errors.push({
129
+ code: 'BP-006',
130
+ message: `${path}.id "${sid}" must match STAGE-[<middle>-]<INTEGER> or PROCESS-[<middle>-]<INTEGER>`,
131
+ });
132
+ continue;
91
133
  }
92
- if (!isNonEmptyString(s['goal'])) {
93
- errors.push({ code: 'BP-005', message: `${path}.goal is required` });
134
+
135
+ if (isProcessColumnId(sid)) {
136
+ processColumnIds.push(sid);
137
+ for (const field of RESTATED_COLUMN_FIELDS) {
138
+ if (fieldIsPresent(s, field)) {
139
+ errors.push({
140
+ code: 'BP-013',
141
+ message: `${path}.${field} must not be restated on a PROCESS-… column — derive it from the process element`,
142
+ });
143
+ }
144
+ }
145
+ if (options.catalog) {
146
+ const resolved = options.catalog.typeOf(sid);
147
+ if (resolved !== 'PROCESS') {
148
+ errors.push({
149
+ code: 'BP-012',
150
+ message: `${path}.id "${sid}" must resolve to an admitted PROCESS element`,
151
+ });
152
+ }
153
+ }
154
+ } else {
155
+ if (!isNonEmptyString(s['name'])) {
156
+ errors.push({ code: 'BP-005', message: `${path}.name is required` });
157
+ }
158
+ if (!isNonEmptyString(s['goal'])) {
159
+ errors.push({ code: 'BP-005', message: `${path}.goal is required` });
160
+ }
161
+ if (!isNonEmptyString(s['result'])) {
162
+ errors.push({ code: 'BP-005', message: `${path}.result is required` });
163
+ }
94
164
  }
95
- if (!isNonEmptyString(s['result'])) {
96
- errors.push({ code: 'BP-005', message: `${path}.result is required` });
165
+ }
166
+
167
+ const parentProcess = isNonEmptyString(pb['process']) ? pb['process'].trim() : undefined;
168
+ if (parentProcess && options.processParentEdges && processColumnIds.length > 0) {
169
+ const linked = new Set(
170
+ options.processParentEdges
171
+ .filter((e) => e.to === parentProcess)
172
+ .map((e) => e.from),
173
+ );
174
+ for (const child of processColumnIds) {
175
+ if (!linked.has(child)) {
176
+ warnings.push({
177
+ code: 'BP-014',
178
+ message:
179
+ `Column "${child}" is a PROCESS-… id and process_blueprint.process is "${parentProcess}", ` +
180
+ `but no in-effect process_parent REL links that column to that parent`,
181
+ });
182
+ }
97
183
  }
98
184
  }
99
185
 
@@ -123,7 +209,7 @@ export function validateProcessBlueprint(input: unknown): ValidationResult {
123
209
  if (!Array.isArray(entryStages) || entryStages.length === 0) {
124
210
  errors.push({
125
211
  code: 'BP-007',
126
- message: `${path}.stages must be a non-empty array of STAGE-… ids`,
212
+ message: `${path}.stages must be a non-empty array of declared column ids`,
127
213
  });
128
214
  } else {
129
215
  for (let j = 0; j < entryStages.length; j++) {
@@ -0,0 +1,184 @@
1
+ // process_parent relation checks — 17-relations.md §3 / §5 (REL-007, REL-008)
2
+ // and endpoint TYPE (PROCESS → PROCESS), plus the in-effect edge collector
3
+ // that process-blueprint BP-014 consumes.
4
+ //
5
+ // REL-007 is single-file (from === to). REL-008 is cross-cutting over the
6
+ // admitted process_parent graph and is a warning, parallel to REL-006.
7
+
8
+ import { docId, endpointId } from './validate-repo.js';
9
+ import type { ProcessParentEdge } from '../process-blueprint/validate.js';
10
+ import type { RepoFinding, RepoModelInput } from './types.js';
11
+
12
+ const PScope: RepoFinding['scope'] = 'repo';
13
+
14
+ function isProcessNotation(n: unknown): boolean {
15
+ return n === 'process';
16
+ }
17
+
18
+ function isoDate(value: unknown): string | undefined {
19
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : undefined;
20
+ }
21
+
22
+ /** Inclusive window: in effect at `asOf` iff valid_from ≤ asOf and (valid_to is null or asOf ≤ valid_to). */
23
+ export function relationInEffectAt(data: Record<string, unknown>, asOf: string): boolean {
24
+ const from = isoDate(data['valid_from']);
25
+ if (from && from > asOf) return false;
26
+ const to = data['valid_to'];
27
+ if (to === null || to === undefined) return true;
28
+ const toDate = isoDate(to);
29
+ if (!toDate) return true;
30
+ return asOf <= toDate;
31
+ }
32
+
33
+ export function todayIsoDate(now: Date = new Date()): string {
34
+ return now.toISOString().slice(0, 10);
35
+ }
36
+
37
+ /** In-effect `process_parent` edges (child → parent) among loaded REL files. */
38
+ export function collectInEffectProcessParentEdges(
39
+ input: RepoModelInput,
40
+ asOf: string = todayIsoDate(),
41
+ ): ProcessParentEdge[] {
42
+ const edges: ProcessParentEdge[] = [];
43
+ for (const doc of input.relations) {
44
+ if (!doc.data) continue;
45
+ if (doc.data['type'] !== 'process_parent') continue;
46
+ if (!relationInEffectAt(doc.data, asOf)) continue;
47
+ const fromId = endpointId(doc.data['from']) ?? endpointId(doc.data['source']);
48
+ const toId = endpointId(doc.data['to']) ?? endpointId(doc.data['target']);
49
+ if (!fromId || !toId) continue;
50
+ edges.push({ from: fromId, to: toId });
51
+ }
52
+ return edges;
53
+ }
54
+
55
+ interface ParentRel {
56
+ relId: string;
57
+ fromId: string;
58
+ toId: string;
59
+ }
60
+
61
+ function processParentRels(input: RepoModelInput): ParentRel[] {
62
+ const out: ParentRel[] = [];
63
+ for (const doc of input.relations) {
64
+ if (!doc.data) continue;
65
+ if (doc.data['type'] !== 'process_parent') continue;
66
+ const fromId = endpointId(doc.data['from']) ?? endpointId(doc.data['source']);
67
+ const toId = endpointId(doc.data['to']) ?? endpointId(doc.data['target']);
68
+ if (!fromId || !toId) continue;
69
+ out.push({ relId: docId(doc) ?? '', fromId, toId });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** REL-007 — process_parent self-reference. */
75
+ function checkSelfReference(rels: ParentRel[], findings: RepoFinding[]): void {
76
+ for (const rel of rels) {
77
+ if (rel.fromId === rel.toId) {
78
+ findings.push({
79
+ scope: PScope,
80
+ id: rel.relId,
81
+ ruleId: 'REL-007',
82
+ message: `REL-007: process_parent relation '${rel.relId}' has from equal to to ('${rel.fromId}').`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ /** REL-008 — cycle in the process_parent graph (warning). */
89
+ function checkCycle(rels: ParentRel[], findings: RepoFinding[]): void {
90
+ const childrenOf = new Map<string, string[]>();
91
+ const relByChildParent = new Map<string, string>();
92
+ const nodes = new Set<string>();
93
+ for (const rel of rels) {
94
+ if (rel.fromId === rel.toId) continue;
95
+ nodes.add(rel.fromId);
96
+ nodes.add(rel.toId);
97
+ const list = childrenOf.get(rel.fromId);
98
+ if (list) list.push(rel.toId);
99
+ else childrenOf.set(rel.fromId, [rel.toId]);
100
+ relByChildParent.set(`${rel.fromId}\0${rel.toId}`, rel.relId);
101
+ }
102
+
103
+ const UNVISITED = 0;
104
+ const VISITING = 1;
105
+ const DONE = 2;
106
+ const state = new Map<string, number>();
107
+ let hit: string | undefined;
108
+
109
+ function walk(id: string): boolean {
110
+ const s = state.get(id) ?? UNVISITED;
111
+ if (s === DONE) return false;
112
+ if (s === VISITING) {
113
+ hit = id;
114
+ return true;
115
+ }
116
+ state.set(id, VISITING);
117
+ for (const parent of childrenOf.get(id) ?? []) {
118
+ if (nodes.has(parent) && walk(parent)) return true;
119
+ }
120
+ state.set(id, DONE);
121
+ return false;
122
+ }
123
+
124
+ for (const id of nodes) {
125
+ if (walk(id)) break;
126
+ }
127
+ if (!hit) return;
128
+
129
+ const relId =
130
+ [...relByChildParent.entries()].find(([key]) => key.startsWith(`${hit}\0`))?.[1] ?? hit;
131
+ findings.push({
132
+ scope: PScope,
133
+ id: relId,
134
+ ruleId: 'REL-008',
135
+ severity: 'warning',
136
+ message: `REL-008: process_parent graph contains a cycle involving '${hit}'.`,
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Endpoint TYPE for process_parent: both ends must be PROCESS when they
142
+ * resolve. Unknown endpoints stay with referential integrity; this pass only
143
+ * fires when the element exists and is the wrong notation.
144
+ */
145
+ function checkEndpointTypes(input: RepoModelInput, rels: ParentRel[], findings: RepoFinding[]): void {
146
+ const elementById = new Map<string, Record<string, unknown>>();
147
+ for (const doc of input.elements) {
148
+ const id = docId(doc);
149
+ if (id && doc.data) elementById.set(id, doc.data);
150
+ }
151
+
152
+ for (const rel of rels) {
153
+ const from = elementById.get(rel.fromId);
154
+ if (from && !isProcessNotation(from['notation'])) {
155
+ findings.push({
156
+ scope: PScope,
157
+ id: rel.relId,
158
+ ruleId: 'REL-002',
159
+ message:
160
+ `Layer-semantics: '${rel.relId}' type 'process_parent' requires from to be ` +
161
+ `PROCESS; got notation='${from['notation']}'.`,
162
+ });
163
+ }
164
+ const to = elementById.get(rel.toId);
165
+ if (to && !isProcessNotation(to['notation'])) {
166
+ findings.push({
167
+ scope: PScope,
168
+ id: rel.relId,
169
+ ruleId: 'REL-002',
170
+ message:
171
+ `Layer-semantics: '${rel.relId}' type 'process_parent' requires to to be ` +
172
+ `PROCESS; got notation='${to['notation']}'.`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ export function checkProcessParentSemantics(input: RepoModelInput, findings: RepoFinding[]): void {
179
+ const rels = processParentRels(input);
180
+ if (rels.length === 0) return;
181
+ checkSelfReference(rels, findings);
182
+ checkCycle(rels, findings);
183
+ checkEndpointTypes(input, rels, findings);
184
+ }