@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
@@ -14,6 +14,28 @@
14
14
  */
15
15
  import { generateSvgEmbedCss } from '../theme/index.js';
16
16
  import { escXml } from './render-util.js';
17
+ /** Interactive-preview / default-export type sizes. */
18
+ export const DEFAULT_BPMN_TYPOGRAPHY = {
19
+ taskFontPx: 11,
20
+ headerFontPx: 11,
21
+ labelFontPx: 10,
22
+ taskChars: 14,
23
+ taskLineH: 14,
24
+ labelChars: 18,
25
+ labelLineH: 13,
26
+ headerCharW: 6.5,
27
+ };
28
+ /** Presentation-export type sizes (20 px floor, wrap tuned to default node boxes). */
29
+ export const PRESENTATION_BPMN_TYPOGRAPHY = {
30
+ taskFontPx: 20,
31
+ headerFontPx: 20,
32
+ labelFontPx: 20,
33
+ taskChars: 9,
34
+ taskLineH: 22,
35
+ labelChars: 14,
36
+ labelLineH: 22,
37
+ headerCharW: 11.5,
38
+ };
17
39
  // ---------------------------------------------------------------------------
18
40
  // Constants — match DEFAULT_LAYOUT_DIAGRAM_OPTIONS in src/layout-options.ts
19
41
  // ---------------------------------------------------------------------------
@@ -21,43 +43,49 @@ import { escXml } from './render-util.js';
21
43
  const LANE_LABEL_BAND = 44;
22
44
  /** End margin (px) along the header height axis for rotated pool/lane captions. */
23
45
  const HEADER_LABEL_AXIS_PAD = 32;
24
- /** Estimated horizontal advance (px) per character at the header label font size. */
25
- const HEADER_LABEL_CHAR_W = 6.5;
26
- /** Maximum characters per wrapped line inside a task box. */
27
- const TASK_CHARS = 14;
28
- /** Line height for wrapped task-name text (px). */
29
- const TASK_LINE_H = 14;
30
- /** Maximum characters per line for below-element labels (events, gateways). */
31
- const LABEL_CHARS = 18;
32
- /** Line height for multi-line below-element labels (px). */
33
- const LABEL_LINE_H = 13;
46
+ /** Active wrap/font metrics for the current render call (sync, restored after). */
47
+ let typo = DEFAULT_BPMN_TYPOGRAPHY;
48
+ function runWithTypography(t, fn) {
49
+ const prev = typo;
50
+ typo = t;
51
+ try {
52
+ return fn();
53
+ }
54
+ finally {
55
+ typo = prev;
56
+ }
57
+ }
34
58
  // ---------------------------------------------------------------------------
35
59
  // BPMN-specific CSS — no font-style:italic per CLAUDE.md design rule
36
60
  // ---------------------------------------------------------------------------
37
- export const BPMN_PROCESS_CSS = `
61
+ export function bpmnProcessCss(t = DEFAULT_BPMN_TYPOGRAPHY) {
62
+ return `
38
63
  .bpmn-pool { fill: var(--ts-bg-surface,#f8fafc); stroke: var(--ts-node-stroke,#004d67); stroke-width: 1.5; }
39
64
  .bpmn-pool-name { fill: var(--ts-bg-elevated,#f1f5f9); stroke: var(--ts-node-stroke,#004d67); stroke-width: 0.75; }
40
65
  .bpmn-lane { fill: var(--ts-bg,#ffffff); stroke: var(--ts-node-stroke,#004d67); stroke-width: 0.75; }
41
66
  .bpmn-lane-header { fill: var(--ts-bg-elevated,#f1f5f9); stroke: var(--ts-node-stroke,#004d67); stroke-width: 0.75; }
42
- .bpmn-pool-label { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: 11px; font-weight: 700; }
43
- .bpmn-lane-label { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: 11px; font-weight: 600; }
67
+ .bpmn-pool-label { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: ${t.headerFontPx}px; font-weight: 700; }
68
+ .bpmn-lane-label { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: ${t.headerFontPx}px; font-weight: 600; }
44
69
  .bpmn-task { fill: var(--ts-bg,#ffffff); stroke: var(--ts-node-stroke,#004d67); stroke-width: 1.5; }
45
- .bpmn-task-name { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: 11px; text-anchor: middle; }
70
+ .bpmn-task-name { fill: var(--ts-text-primary,#0d2b35); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: ${t.taskFontPx}px; text-anchor: middle; }
46
71
  .bpmn-event { fill: var(--ts-bg,#ffffff); stroke: var(--ts-node-stroke,#004d67); }
47
72
  .bpmn-event-start { stroke-width: 1.5; }
48
73
  .bpmn-event-end { stroke-width: 4; }
49
- .bpmn-event-label { fill: var(--ts-text-secondary,#516970); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: 10px; text-anchor: middle; }
74
+ .bpmn-event-label { fill: var(--ts-text-secondary,#516970); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: ${t.labelFontPx}px; text-anchor: middle; }
50
75
  .bpmn-gateway { fill: var(--ts-bg,#ffffff); stroke: var(--ts-node-stroke,#004d67); stroke-width: 1.5; }
51
76
  .bpmn-gateway-marker { stroke: var(--ts-node-stroke,#004d67); stroke-width: 2.5; stroke-linecap: round; fill: none; }
52
- .bpmn-gateway-label { fill: var(--ts-text-secondary,#516970); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: 10px; text-anchor: middle; }
77
+ .bpmn-gateway-label { fill: var(--ts-text-secondary,#516970); font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif); font-size: ${t.labelFontPx}px; text-anchor: middle; }
53
78
  .bpmn-seq-flow { fill: none; stroke: var(--ts-edge-stroke,#004d67); stroke-width: 1.5; }
54
79
  .bpmn-default-mark { stroke: var(--ts-edge-stroke,#004d67); stroke-width: 1.5; stroke-linecap: round; }
55
80
  .bpmn-assoc { fill: none; stroke: var(--ts-edge-stroke,#004d67); stroke-width: 1; stroke-dasharray: 4 2; }
56
81
  .bpmn-data-obj { fill: var(--ts-bg,#ffffff); stroke: var(--ts-node-stroke,#004d67); stroke-width: 1; }
57
- .bpmn-data-obj-label { fill: var(--ts-text-secondary,#516970); font-size: 10px; text-anchor: middle; }
82
+ .bpmn-data-obj-label { fill: var(--ts-text-secondary,#516970); font-size: ${t.labelFontPx}px; text-anchor: middle; }
58
83
  .bpmn-event-intermediate { fill: none; stroke: var(--ts-node-stroke,#004d67); stroke-width: 1; }
59
84
  .bpmn-event-icon { fill: none; stroke: var(--ts-node-stroke,#004d67); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
60
85
  `;
86
+ }
87
+ /** Default-preview CSS (11 px / 10 px). Presentation export builds its own via `bpmnProcessCss`. */
88
+ export const BPMN_PROCESS_CSS = bpmnProcessCss(DEFAULT_BPMN_TYPOGRAPHY);
61
89
  // ---------------------------------------------------------------------------
62
90
  // Internal helpers
63
91
  // ---------------------------------------------------------------------------
@@ -73,28 +101,29 @@ function wordTruncate(text, maxChars) {
73
101
  return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
74
102
  }
75
103
  function fitRotatedHeaderText(name, spanPx) {
76
- const maxChars = Math.max(4, Math.floor(spanPx / HEADER_LABEL_CHAR_W));
104
+ const maxChars = Math.max(4, Math.floor(spanPx / typo.headerCharW));
77
105
  return wordTruncate(name, maxChars);
78
106
  }
79
107
  function wrapTaskName(name) {
80
108
  if (!name)
81
109
  return [];
110
+ const limit = typo.taskChars;
82
111
  const words = name.split(/\s+/);
83
112
  const lines = [];
84
113
  let cur = '';
85
114
  for (const w of words) {
86
- const token = w.length > TASK_CHARS ? w.slice(0, TASK_CHARS - 1) + '…' : w;
115
+ const token = w.length > limit ? w.slice(0, limit - 1) + '…' : w;
87
116
  if (!cur) {
88
117
  cur = token;
89
118
  }
90
- else if (cur.length + 1 + token.length <= TASK_CHARS) {
119
+ else if (cur.length + 1 + token.length <= limit) {
91
120
  cur += ' ' + token;
92
121
  }
93
122
  else {
94
123
  lines.push(cur);
95
124
  if (lines.length === 3) {
96
- if (lines[2].length > TASK_CHARS - 1)
97
- lines[2] = lines[2].slice(0, TASK_CHARS - 1) + '…';
125
+ if (lines[2].length > limit - 1)
126
+ lines[2] = lines[2].slice(0, limit - 1) + '…';
98
127
  return lines;
99
128
  }
100
129
  cur = token;
@@ -103,28 +132,29 @@ function wrapTaskName(name) {
103
132
  if (cur && lines.length < 3)
104
133
  lines.push(cur);
105
134
  if (lines.length === 0)
106
- lines.push(name.slice(0, TASK_CHARS));
135
+ lines.push(name.slice(0, limit));
107
136
  return lines;
108
137
  }
109
138
  function taskNameSvg(name, cx, cy) {
110
139
  if (!name)
111
140
  return '';
112
141
  const lines = wrapTaskName(name);
113
- const totalH = lines.length * TASK_LINE_H;
114
- const startY = cy - totalH / 2 + TASK_LINE_H * 0.8;
142
+ const totalH = lines.length * typo.taskLineH;
143
+ const startY = cy - totalH / 2 + typo.taskLineH * 0.8;
115
144
  const tspans = lines
116
- .map((ln, i) => `<tspan x="${r(cx)}" y="${r(startY + i * TASK_LINE_H)}">${escXml(ln)}</tspan>`)
145
+ .map((ln, i) => `<tspan x="${r(cx)}" y="${r(startY + i * typo.taskLineH)}">${escXml(ln)}</tspan>`)
117
146
  .join('');
118
147
  return `<text class="bpmn-task-name">${tspans}</text>`;
119
148
  }
120
149
  function wrapBelowLabel(name) {
150
+ const limit = typo.labelChars;
121
151
  const words = name.split(/\s+/).filter(Boolean);
122
152
  const allLines = [];
123
153
  let cur = '';
124
154
  for (const word of words) {
125
- const tok = word.length > LABEL_CHARS ? word.slice(0, LABEL_CHARS - 1) + '…' : word;
155
+ const tok = word.length > limit ? word.slice(0, limit - 1) + '…' : word;
126
156
  const test = cur ? `${cur} ${tok}` : tok;
127
- if (test.length <= LABEL_CHARS) {
157
+ if (test.length <= limit) {
128
158
  cur = test;
129
159
  }
130
160
  else {
@@ -136,12 +166,12 @@ function wrapBelowLabel(name) {
136
166
  if (cur)
137
167
  allLines.push(cur);
138
168
  if (allLines.length === 0)
139
- return [name.slice(0, LABEL_CHARS)];
169
+ return [name.slice(0, limit)];
140
170
  if (allLines.length <= 3)
141
171
  return allLines;
142
172
  const lines = allLines.slice(0, 3);
143
173
  const last = lines[2];
144
- lines[2] = last.length + 1 <= LABEL_CHARS ? last + '…' : last.slice(0, LABEL_CHARS - 1) + '…';
174
+ lines[2] = last.length + 1 <= limit ? last + '…' : last.slice(0, limit - 1) + '…';
145
175
  return lines;
146
176
  }
147
177
  function belowLabelSvg(name, cls, cx, belowY, minX = 0) {
@@ -155,7 +185,7 @@ function belowLabelSvg(name, cls, cx, belowY, minX = 0) {
155
185
  return `<text class="${cls}" x="${r(textX)}" y="${startY}">${escXml(lines[0])}</text>`;
156
186
  }
157
187
  const tspans = lines
158
- .map((ln, i) => `<tspan x="${r(textX)}" dy="${i === 0 ? 0 : LABEL_LINE_H}">${escXml(ln)}</tspan>`)
188
+ .map((ln, i) => `<tspan x="${r(textX)}" dy="${i === 0 ? 0 : typo.labelLineH}">${escXml(ln)}</tspan>`)
159
189
  .join('');
160
190
  return `<text class="${cls}" x="${r(textX)}" y="${startY}">${tspans}</text>`;
161
191
  }
@@ -378,15 +408,18 @@ export function renderProcessBody(layout, ox, oy) {
378
408
  */
379
409
  export function renderProcessLayoutSvg(layout, options = {}) {
380
410
  const { title = '', topInset = title ? 32 : 0 } = options;
381
- const pb = layout.poolBounds;
382
- const PAD = 16;
383
- const svgW = r(pb.x + pb.width + PAD);
384
- const svgH = r(pb.y + pb.height + PAD + topInset);
385
- const embedCss = generateSvgEmbedCss('transitrix') + BPMN_PROCESS_CSS;
386
- const body = renderProcessBody(layout, 0, topInset);
387
- return (`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">\n` +
388
- `<style>${embedCss}</style>\n` +
389
- (title ? `${title}\n` : '') +
390
- `${body}\n` +
391
- `</svg>`);
411
+ const t = options.typography ?? DEFAULT_BPMN_TYPOGRAPHY;
412
+ return runWithTypography(t, () => {
413
+ const pb = layout.poolBounds;
414
+ const PAD = 16;
415
+ const svgW = r(pb.x + pb.width + PAD);
416
+ const svgH = r(pb.y + pb.height + PAD + topInset);
417
+ const embedCss = generateSvgEmbedCss('transitrix') + bpmnProcessCss(t);
418
+ const body = renderProcessBody(layout, 0, topInset);
419
+ return (`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">\n` +
420
+ `<style>${embedCss}</style>\n` +
421
+ (title ? `${title}\n` : '') +
422
+ `${body}\n` +
423
+ `</svg>`);
424
+ });
392
425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -55,12 +55,12 @@
55
55
  "@testing-library/dom": "^10.4.1",
56
56
  "@testing-library/react": "^16.3.2",
57
57
  "@types/react": "^19.2.17",
58
- "@types/react-dom": "^19.2.4",
58
+ "@types/react-dom": "^19.2.5",
59
59
  "jsdom": "^29.1.1",
60
60
  "react": "^19.2.8",
61
61
  "react-dom": "^19.2.8",
62
62
  "reactflow": "^11.11.4",
63
63
  "typescript": "^5.0.0",
64
- "vitest": "^4.1.10"
64
+ "vitest": "^4.1.11"
65
65
  }
66
66
  }
@@ -12,7 +12,7 @@
12
12
  * §2). Filesystem-free — callable from unit tests without vscode.
13
13
  */
14
14
 
15
- import { isObject, str, strArray, descendantsOf, stripEnvelope } from '../canon-resolver-utils.js';
15
+ import { isObject, str, strArray, stripEnvelope, reachesRootViaParent } from '../canon-resolver-utils.js';
16
16
 
17
17
  export interface ActionViewConfig {
18
18
  scope?: {
@@ -44,6 +44,10 @@ export interface ActionCanonSources {
44
44
  relations?: unknown[];
45
45
  }
46
46
 
47
+ /** In-memory bag of ACTION ids dropped by `root_action` scoping. Consumed by
48
+ * `validateActivities` as `ACT-021`. Not part of the notation. */
49
+ export const ACT_021_OMITTED_KEY = '__act021_omitted';
50
+
47
51
  /** ACTION elements by id. `notation: action` is canonical; `notation: activity`
48
52
  * is the deprecated pre-2026-06-25 alias (elements/24-action.md §"Deprecated
49
53
  * alias") — accepted the same way `collectByNotation` callers elsewhere do. */
@@ -123,32 +127,26 @@ export function resolveAction(
123
127
  return relGoals.length > 0 ? relGoals : strArray(el['goals']);
124
128
  }
125
129
 
126
- // 1. Scope by root_action (this action + its descendants), or the full
127
- // catalogue when omitted (§5.1: "Omit to include elements from the full
128
- // catalogue (or use goals/type_filter for other scoping)").
129
- const rootAction = str(scope['root_action']);
130
- let candidateIds: Set<string>;
131
- if (rootAction) {
132
- candidateIds = allActions.has(rootAction) ? descendantsOf(rootAction, allActions) : new Set();
133
- } else {
134
- candidateIds = new Set(allActions.keys());
135
- }
130
+ // 1. Other scope fields first — the set the view would otherwise select
131
+ // (07-action.md §5.1). `root_action` then omits (and warns) rather than
132
+ // silently intersecting first.
133
+ let otherwiseIds: Set<string> = new Set(allActions.keys());
136
134
 
137
- // 2. Narrow by goals — only actions linked to at least one listed GOAL
135
+ // Narrow by goals — only actions linked to at least one listed GOAL
138
136
  // (via an active action_goal relation, or inline goals[] when no relation
139
137
  // exists for that action).
140
138
  const goalsFilter = strArray(scope['goals']);
141
139
  if (goalsFilter.length > 0) {
142
- candidateIds = new Set(
143
- [...candidateIds].filter((id) => effectiveGoals(id, allActions.get(id)!).some((g) => goalsFilter.includes(g))),
140
+ otherwiseIds = new Set(
141
+ [...otherwiseIds].filter((id) => effectiveGoals(id, allActions.get(id)!).some((g) => goalsFilter.includes(g))),
144
142
  );
145
143
  }
146
144
 
147
- // 3. Narrow by type_filter — only actions of a listed type.
145
+ // Narrow by type_filter — only actions of a listed type.
148
146
  const typeFilter = strArray(scope['type_filter']);
149
147
  if (typeFilter.length > 0) {
150
- candidateIds = new Set(
151
- [...candidateIds].filter((id) => {
148
+ otherwiseIds = new Set(
149
+ [...otherwiseIds].filter((id) => {
152
150
  const el = allActions.get(id);
153
151
  const t = str(el?.['type']) ?? str(el?.['activity_type']);
154
152
  return t !== undefined && typeFilter.includes(t);
@@ -156,13 +154,13 @@ export function resolveAction(
156
154
  );
157
155
  }
158
156
 
159
- // 4. Narrow by valid_at — only actions in effect on that date. Actions with
157
+ // Narrow by valid_at — only actions in effect on that date. Actions with
160
158
  // no valid_from are not lifecycle-tracked and are kept (permissive default,
161
159
  // matching the resolver's general "missing = not excluded" stance).
162
160
  const validAt = str(scope['valid_at']);
163
161
  if (validAt) {
164
- candidateIds = new Set(
165
- [...candidateIds].filter((id) => {
162
+ otherwiseIds = new Set(
163
+ [...otherwiseIds].filter((id) => {
166
164
  const el = allActions.get(id);
167
165
  const from = str(el?.['valid_from']);
168
166
  const to = str(el?.['valid_to']);
@@ -173,6 +171,27 @@ export function resolveAction(
173
171
  );
174
172
  }
175
173
 
174
+ const rootAction = str(scope['root_action']);
175
+ let candidateIds: Set<string>;
176
+ const omittedOutsideRoot: string[] = [];
177
+ if (rootAction) {
178
+ if (!allActions.has(rootAction)) {
179
+ candidateIds = new Set();
180
+ } else {
181
+ candidateIds = new Set();
182
+ for (const id of otherwiseIds) {
183
+ if (reachesRootViaParent(id, rootAction, allActions)) {
184
+ candidateIds.add(id);
185
+ } else {
186
+ omittedOutsideRoot.push(id);
187
+ }
188
+ }
189
+ omittedOutsideRoot.sort();
190
+ }
191
+ } else {
192
+ candidateIds = otherwiseIds;
193
+ }
194
+
176
195
  // Element fields map onto Activity fields unchanged, except the canonical
177
196
  // `type` (elements/24-action.md §2) which becomes the internal `activity_type`
178
197
  // (types.ts `Activity.activity_type`), and `goals` which is resolved via
@@ -204,5 +223,6 @@ export function resolveAction(
204
223
  actions: selectedActions,
205
224
  };
206
225
  if (Object.keys(project).length > 0) out['project'] = project;
226
+ if (omittedOutsideRoot.length > 0) out[ACT_021_OMITTED_KEY] = { root: rootAction, ids: omittedOutsideRoot };
207
227
  return out;
208
228
  }
@@ -5,10 +5,62 @@ import type {
5
5
  ActivityValidationError,
6
6
  ActivityValidationWarning,
7
7
  } from './types.js';
8
+ import { isObject, str, reachesRootViaParent } from '../canon-resolver-utils.js';
9
+ import { ACT_021_OMITTED_KEY } from './resolver.js';
8
10
 
9
11
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
12
  const VALID_WEEKDAYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
11
13
 
14
+ function act021Message(actionId: string, rootId: string): string {
15
+ return `ACTION "${actionId}" is not "${rootId}" and is not reachable from it via parent`;
16
+ }
17
+
18
+ function collectAct021FromProjection(raw: Record<string, unknown>): ActivityValidationWarning[] {
19
+ const bag = raw[ACT_021_OMITTED_KEY];
20
+ delete raw[ACT_021_OMITTED_KEY];
21
+ if (!isObject(bag)) return [];
22
+ const root = str(bag['root']);
23
+ const ids = Array.isArray(bag['ids']) ? bag['ids'] : [];
24
+ if (!root) return [];
25
+ const out: ActivityValidationWarning[] = [];
26
+ for (const id of ids) {
27
+ if (typeof id !== 'string' || !id.trim()) continue;
28
+ out.push({
29
+ code: 'ACT-021',
30
+ message: act021Message(id, root),
31
+ path: 'view_config.scope.root_action',
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function collectAct021FromAuthored(
38
+ raw: Record<string, unknown>,
39
+ actions: unknown[],
40
+ ): ActivityValidationWarning[] {
41
+ const vc = isObject(raw['view_config']) ? raw['view_config'] : undefined;
42
+ const scope = vc && isObject(vc['scope']) ? vc['scope'] : undefined;
43
+ const root = scope ? str(scope['root_action']) : undefined;
44
+ if (!root) return [];
45
+ const byId = new Map<string, Record<string, unknown>>();
46
+ for (const el of actions) {
47
+ if (!isObject(el)) continue;
48
+ const id = str(el['id']);
49
+ if (id) byId.set(id, el);
50
+ }
51
+ if (!byId.has(root)) return [];
52
+ const out: ActivityValidationWarning[] = [];
53
+ for (const id of byId.keys()) {
54
+ if (reachesRootViaParent(id, root, byId)) continue;
55
+ out.push({
56
+ code: 'ACT-021',
57
+ message: act021Message(id, root),
58
+ path: 'view_config.scope.root_action',
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
12
64
  export function validateActivities(input: unknown): ActivityValidationResult {
13
65
  const errors: ActivityValidationError[] = [];
14
66
  const warnings: ActivityValidationWarning[] = [];
@@ -46,6 +98,12 @@ export function validateActivities(input: unknown): ActivityValidationResult {
46
98
  // Normalise onto raw.activities for the rest of this function (avoids touching downstream code)
47
99
  if (!Array.isArray(raw.activities)) raw.activities = raw.actions;
48
100
 
101
+ const fromProjection = collectAct021FromProjection(raw);
102
+ warnings.push(...fromProjection);
103
+ if (fromProjection.length === 0) {
104
+ warnings.push(...collectAct021FromAuthored(raw, rawArray));
105
+ }
106
+
49
107
  // ACT-014 / ACT-015: project.calendar validation (run before per-activity loop;
50
108
  // surfaces clearly even when activities also have issues).
51
109
  if (raw.project && typeof raw.project === 'object') {
@@ -158,8 +216,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
158
216
  }
159
217
 
160
218
  // ACT-011: warn if no duration (duration_days is accepted as an alias)
219
+ // Network view is unaffected; this is only about CPM/Gantt rendering.
161
220
  if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
162
- warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
221
+ warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — Gantt view and CPM analysis will exclude it. Network view is unaffected.`, path });
163
222
  }
164
223
  }
165
224
 
@@ -234,7 +293,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
234
293
  }
235
294
  }
236
295
 
237
- // ACT-013: orphan activities (warning only)
296
+ // ACT-013: orphan activities (warning only). An activity is structurally orphan
297
+ // if it has no connections to the rest of the graph. Valid connections are:
298
+ // predecessors, successors, parent, or goals (direct or via delivers_changes).
238
299
  const successorIds = new Set<string>();
239
300
  for (const a of doc.activities) {
240
301
  for (const pred of (a.predecessors ?? [])) successorIds.add(pred);
@@ -244,10 +305,11 @@ export function validateActivities(input: unknown): ActivityValidationResult {
244
305
  const hasSuccessors = successorIds.has(a.id);
245
306
  const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
246
307
  const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
247
- if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors) {
308
+ const hasParent = typeof a.parent === 'string' && a.parent.length > 0;
309
+ if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors && !hasParent) {
248
310
  warnings.push({
249
311
  code: 'ACT-013',
250
- message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
312
+ message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, parent, or goals)`,
251
313
  path: `activities[${i}]`,
252
314
  });
253
315
  }
@@ -48,6 +48,25 @@ export function descendantsOf(
48
48
  return result;
49
49
  }
50
50
 
51
+ /** Walk `parent` from `id` toward its ancestors until `rootId` or the chain
52
+ * ends. Cycle-safe: a cycle is not a path to the root. */
53
+ export function reachesRootViaParent(
54
+ id: string,
55
+ rootId: string,
56
+ all: Map<string, Record<string, unknown>>,
57
+ ): boolean {
58
+ const seen = new Set<string>();
59
+ let current: string | undefined = id;
60
+ while (current) {
61
+ if (current === rootId) return true;
62
+ if (seen.has(current)) return false;
63
+ seen.add(current);
64
+ const el = all.get(current);
65
+ current = el ? str(el['parent']) : undefined;
66
+ }
67
+ return false;
68
+ }
69
+
51
70
  /** Depth of `id` in its `parent` chain within `all` (root = 0, each step up
52
71
  * adds 1). Cycle-safe (a cycle resolves the entering node to depth 0) and
53
72
  * memoized across calls via the shared `memo` map. */
@@ -166,6 +166,7 @@ export function ingestComplianceDoc(canon: ComplianceCanon, doc: unknown): strin
166
166
  if (!verifies || !method || !outcome) return null;
167
167
  const ok = pushUnique(canon, canon.verifications, {
168
168
  id, verifies, method, outcome,
169
+ protocol: str(d.protocol),
169
170
  performed_at: str(d.performed_at),
170
171
  evidenceCount: Array.isArray(d.evidence) ? d.evidence.length : 0,
171
172
  admitted_at: str(d.admitted_at),
@@ -12,6 +12,7 @@ import type { ComplianceCanon } from './classify.js';
12
12
  import { buildComplianceIndex } from './reverse-index.js';
13
13
  import type { CanonCatalog } from '../typed-id.js';
14
14
  import type { ComplianceIndex, IndexAssertion, IndexRequirement, ObjectDetailInput, ObjectDetailDef, DeadlineStatus } from './types.js';
15
+ import { isStageColumnId } from '../process-blueprint/resolve-columns.js';
15
16
 
16
17
  /** Filter selecting REQUIREMENTs by codex source (jurisdiction / regime keys
17
18
  * are accepted for forward compatibility but not yet honoured — the canon
@@ -649,22 +650,48 @@ function buildTaskColumns(
649
650
  *
650
651
  * At `'product'` grain: `a.subject` must match `col.subjectId`.
651
652
  * At `'product-stage'` grain: `a.subject` must match AND either
652
- * `a.realised_via` is absent (claim covers the whole subject) OR
653
- * `col.stageId` ∈ `a.realised_via`.
654
- * At `'product-stage-task'` grain: as above for stage, plus `col.taskId` ∈
655
- * `a.realised_via` counts as a match (task-level claim covers this task column).
656
- * A stage-level claim (`col.stageId` ∈ `a.realised_via`) covers all task columns
657
- * in that stage.
653
+ * `a.realised_via` is absent (claim covers the whole subject) OR the token
654
+ * pins this column: a `PROCESS-…` id matching `col.stageId`, or a STEP whose
655
+ * home process is that column. Sketch `STAGE-…` ids are never a join key.
656
+ * At `'product-stage-task'` grain: a task-id hit covers that task column; a
657
+ * process-level claim covers every task column of that process.
658
658
  */
659
- function assertionMatchesColumn(a: IndexAssertion, col: ImpactColumn): boolean {
659
+ function assertionMatchesColumn(
660
+ a: IndexAssertion,
661
+ col: ImpactColumn,
662
+ stepHomeProcess?: ReadonlyMap<string, string>,
663
+ ): boolean {
660
664
  if (a.subject !== col.subjectId) return false;
661
665
  if (!col.stageId) return true;
662
666
  if (!a.realised_via || a.realised_via.length === 0) return true;
663
- if (col.taskId) {
664
- // Task grain: match on taskId OR on stageId (stage claim covers all tasks).
665
- return a.realised_via.includes(col.taskId) || a.realised_via.includes(col.stageId);
667
+
668
+ const stageId = col.stageId;
669
+ if (isStageColumnId(stageId)) {
670
+ // Sketch column: only a task-id hit counts — never the STAGE- key.
671
+ return col.taskId != null && a.realised_via.includes(col.taskId);
672
+ }
673
+
674
+ for (const ref of a.realised_via) {
675
+ if (typeof ref !== 'string') continue;
676
+ if (isStageColumnId(ref)) continue;
677
+ if (col.taskId && ref === col.taskId) return true;
678
+ if (ref === stageId) return true;
679
+ if (!col.taskId && stepHomeProcess?.get(ref) === stageId) return true;
666
680
  }
667
- return a.realised_via.includes(col.stageId);
681
+ return false;
682
+ }
683
+
684
+ function stepHomeFromObjectDetails(objectDetails?: ObjectDetailInput[]): Map<string, string> {
685
+ const out = new Map<string, string>();
686
+ if (!objectDetails) return out;
687
+ for (const od of objectDetails) {
688
+ for (const d of od.details ?? []) {
689
+ for (const t of d.tasks ?? []) {
690
+ if (typeof t?.id === 'string' && t.id.length > 0) out.set(t.id, d.id);
691
+ }
692
+ }
693
+ }
694
+ return out;
668
695
  }
669
696
 
670
697
  /**
@@ -691,6 +718,7 @@ export function buildImpactMatrix(
691
718
  canon: ComplianceCanon,
692
719
  config: ImpactViewConfig,
693
720
  objectDetails?: ObjectDetailInput[],
721
+ join?: { stepHomeProcess?: ReadonlyMap<string, string> },
694
722
  ): ImpactMatrix {
695
723
  const index = buildComplianceIndex({
696
724
  requirements: canon.requirements,
@@ -755,6 +783,7 @@ export function buildImpactMatrix(
755
783
 
756
784
  const rowIndex = new Set(obligations.map(r => r.id));
757
785
  const cells: ImpactCell[][] = obligations.map(() => columns.map(() => emptyCell()));
786
+ const stepHome = join?.stepHomeProcess ?? stepHomeFromObjectDetails(objectDetails);
758
787
 
759
788
  for (let c = 0; c < columns.length; c++) {
760
789
  const col = columns[c];
@@ -762,7 +791,7 @@ export function buildImpactMatrix(
762
791
  for (const a of subjectAssertions) {
763
792
  if (!rowIndex.has(a.about)) continue;
764
793
  if (!allowedStatuses.has(a.status)) continue;
765
- if (!assertionMatchesColumn(a, col)) continue;
794
+ if (!assertionMatchesColumn(a, col, stepHome)) continue;
766
795
  const rowIdx = obligations.findIndex(r => r.id === a.about);
767
796
  if (rowIdx < 0) continue;
768
797
  cells[rowIdx][c].assertions.push(a);
@@ -128,6 +128,12 @@ export interface IndexVerification {
128
128
  verifies: string;
129
129
  method: VerificationMethod;
130
130
  outcome: VerificationOutcome;
131
+ /**
132
+ * The verification procedure (27-verification.md §2). VERIFICATION carries
133
+ * no `name` field of its own; `protocol` is the closest human-readable
134
+ * description and is what a matrix/list view shows as the row's label.
135
+ */
136
+ protocol?: string;
131
137
  performed_at?: string;
132
138
  /** Number of evidence entries — feeds the VERIF-006 gap. */
133
139
  evidenceCount?: number;