@transitrix/diagrams 1.0.0 → 1.0.1

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 (267) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +47 -47
  3. package/dist/__tests__/edge-path.test.js +8 -0
  4. package/dist/activity-card/__tests__/example.test.js +20 -1
  5. package/dist/activity-card/__tests__/layout.test.js +102 -11
  6. package/dist/activity-card/__tests__/resolver.test.js +139 -6
  7. package/dist/activity-card/index.d.ts +1 -1
  8. package/dist/activity-card/layout.d.ts +1 -1
  9. package/dist/activity-card/layout.js +220 -103
  10. package/dist/activity-card/resolver.js +107 -26
  11. package/dist/activity-card/types.d.ts +150 -27
  12. package/dist/activity-card/validate.js +3 -0
  13. package/dist/actor/__tests__/validate.test.js +63 -31
  14. package/dist/actor/index.d.ts +1 -1
  15. package/dist/actor/index.js +1 -1
  16. package/dist/actor/types.d.ts +7 -3
  17. package/dist/actor/types.js +17 -3
  18. package/dist/actor/validate.js +55 -26
  19. package/dist/assertion/types.d.ts +3 -1
  20. package/dist/assertion/types.js +1 -1
  21. package/dist/change/__tests__/validate.test.js +40 -34
  22. package/dist/change/index.d.ts +1 -0
  23. package/dist/change/types.d.ts +1 -1
  24. package/dist/change/types.js +2 -2
  25. package/dist/change/validate.d.ts +6 -1
  26. package/dist/change/validate.js +68 -41
  27. package/dist/compliance/__tests__/classify.test.js +34 -0
  28. package/dist/compliance/__tests__/coverage-metric.test.js +146 -46
  29. package/dist/compliance/__tests__/html.test.js +2 -2
  30. package/dist/compliance/__tests__/impact.test.js +311 -6
  31. package/dist/compliance/classify.d.ts +6 -0
  32. package/dist/compliance/classify.js +9 -1
  33. package/dist/compliance/coverage-metric.d.ts +31 -11
  34. package/dist/compliance/coverage-metric.js +149 -39
  35. package/dist/compliance/html.js +77 -74
  36. package/dist/compliance/impact.d.ts +77 -16
  37. package/dist/compliance/impact.js +193 -25
  38. package/dist/compliance/index.d.ts +2 -2
  39. package/dist/compliance/index.js +1 -1
  40. package/dist/compliance/markdown.js +1 -0
  41. package/dist/compliance/types.d.ts +25 -6
  42. package/dist/edge-path.d.ts +8 -4
  43. package/dist/edge-path.js +11 -6
  44. package/dist/factor/__tests__/validate.test.js +74 -45
  45. package/dist/factor/index.d.ts +3 -2
  46. package/dist/factor/index.js +1 -1
  47. package/dist/factor/types.d.ts +4 -7
  48. package/dist/factor/types.js +10 -5
  49. package/dist/factor/validate.d.ts +6 -1
  50. package/dist/factor/validate.js +68 -50
  51. package/dist/fgca/__tests__/resolver.test.js +185 -0
  52. package/dist/fgca/index.d.ts +2 -0
  53. package/dist/fgca/index.js +1 -0
  54. package/dist/fgca/resolver.d.ts +49 -0
  55. package/dist/fgca/resolver.js +124 -0
  56. package/dist/goals/index.d.ts +1 -1
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +5 -0
  59. package/dist/process-blueprint/__tests__/layout.test.js +37 -0
  60. package/dist/process-blueprint/layout.js +5 -1
  61. package/dist/process-blueprint/types.d.ts +22 -1
  62. package/dist/stakeholder/__tests__/validate.test.js +49 -47
  63. package/dist/stakeholder/index.d.ts +1 -0
  64. package/dist/stakeholder/types.d.ts +3 -4
  65. package/dist/stakeholder/types.js +2 -3
  66. package/dist/stakeholder/validate.d.ts +6 -1
  67. package/dist/stakeholder/validate.js +71 -50
  68. package/dist/target-state/__tests__/validate.test.js +43 -27
  69. package/dist/target-state/index.d.ts +2 -0
  70. package/dist/target-state/index.js +1 -0
  71. package/dist/target-state/types.d.ts +6 -1
  72. package/dist/target-state/types.js +8 -7
  73. package/dist/target-state/validate.d.ts +6 -1
  74. package/dist/target-state/validate.js +69 -45
  75. package/dist/theme/themes.js +69 -69
  76. package/dist/webview/__tests__/entry.test.js +11 -11
  77. package/dist/webview/render-activities.d.ts +3 -1
  78. package/dist/webview/render-activities.js +2 -2
  79. package/dist/webview/render-activity-card.js +104 -77
  80. package/dist/webview/render-fgca.d.ts +4 -0
  81. package/dist/webview/render-fgca.js +2 -5
  82. package/dist/webview/render-goals.d.ts +1 -0
  83. package/dist/webview/render-goals.js +15 -15
  84. package/dist/webview/render-process-blueprint.js +10 -10
  85. package/package.json +40 -40
  86. package/src/__tests__/edge-path.test.ts +58 -46
  87. package/src/__tests__/scope.test.ts +27 -27
  88. package/src/__tests__/yaml-normalize.test.ts +158 -158
  89. package/src/activities/__tests__/cpm.test.ts +164 -164
  90. package/src/activities/__tests__/gantt.test.ts +245 -245
  91. package/src/activities/__tests__/layout.test.ts +112 -112
  92. package/src/activities/__tests__/validate.test.ts +409 -409
  93. package/src/activities/cpm.ts +95 -95
  94. package/src/activities/gantt.ts +276 -276
  95. package/src/activities/index.ts +28 -28
  96. package/src/activities/layout.ts +112 -112
  97. package/src/activities/types.ts +148 -148
  98. package/src/activities/validate.ts +357 -357
  99. package/src/activity-card/__tests__/example.test.ts +85 -66
  100. package/src/activity-card/__tests__/layout.test.ts +169 -63
  101. package/src/activity-card/__tests__/resolver.test.ts +307 -161
  102. package/src/activity-card/__tests__/validate.test.ts +131 -131
  103. package/src/activity-card/index.ts +38 -32
  104. package/src/activity-card/layout.ts +324 -194
  105. package/src/activity-card/resolver.ts +415 -337
  106. package/src/activity-card/types.ts +376 -237
  107. package/src/activity-card/validate.ts +162 -158
  108. package/src/actor/__tests__/validate.test.ts +70 -31
  109. package/src/actor/index.ts +1 -1
  110. package/src/actor/types.ts +21 -7
  111. package/src/actor/validate.ts +59 -27
  112. package/src/applications/__tests__/validate.test.ts +256 -256
  113. package/src/applications/index.ts +11 -11
  114. package/src/applications/types.ts +43 -43
  115. package/src/applications/validate.ts +131 -131
  116. package/src/assertion/__tests__/example.test.ts +37 -37
  117. package/src/assertion/__tests__/validate.test.ts +142 -142
  118. package/src/assertion/index.ts +12 -12
  119. package/src/assertion/types.ts +60 -58
  120. package/src/assertion/validate.ts +145 -145
  121. package/src/blocks/__tests__/layout.test.ts +169 -169
  122. package/src/blocks/__tests__/validate.test.ts +260 -260
  123. package/src/blocks/index.ts +17 -17
  124. package/src/blocks/layout.ts +186 -186
  125. package/src/blocks/types.ts +62 -62
  126. package/src/blocks/validate.ts +206 -206
  127. package/src/capability-map/__tests__/validate.test.ts +238 -238
  128. package/src/capability-map/index.ts +8 -8
  129. package/src/capability-map/types.ts +29 -29
  130. package/src/capability-map/validate.ts +127 -127
  131. package/src/change/__tests__/validate.test.ts +43 -34
  132. package/src/change/index.ts +1 -0
  133. package/src/change/types.ts +5 -3
  134. package/src/change/validate.ts +75 -43
  135. package/src/compliance/__tests__/classify.test.ts +64 -27
  136. package/src/compliance/__tests__/confidence.test.ts +98 -98
  137. package/src/compliance/__tests__/coverage-metric.test.ts +300 -188
  138. package/src/compliance/__tests__/demo-scenario.test.ts +273 -273
  139. package/src/compliance/__tests__/example.test.ts +57 -57
  140. package/src/compliance/__tests__/gap-report.test.ts +128 -128
  141. package/src/compliance/__tests__/html.test.ts +141 -141
  142. package/src/compliance/__tests__/impact.test.ts +840 -481
  143. package/src/compliance/__tests__/markdown.test.ts +94 -94
  144. package/src/compliance/__tests__/views.test.ts +64 -64
  145. package/src/compliance/classify.ts +100 -84
  146. package/src/compliance/confidence.ts +69 -69
  147. package/src/compliance/coverage-metric.ts +404 -263
  148. package/src/compliance/gap-report.ts +94 -94
  149. package/src/compliance/html.ts +298 -295
  150. package/src/compliance/impact.ts +861 -677
  151. package/src/compliance/index.ts +57 -56
  152. package/src/compliance/markdown.ts +136 -135
  153. package/src/compliance/reverse-index.ts +33 -33
  154. package/src/compliance/types.ts +136 -120
  155. package/src/compliance/views.ts +46 -46
  156. package/src/compliance-matrix/__tests__/build.test.ts +200 -200
  157. package/src/compliance-matrix/__tests__/example.test.ts +57 -57
  158. package/src/compliance-matrix/index.ts +11 -11
  159. package/src/compliance-matrix/types.ts +102 -102
  160. package/src/edge-path.ts +50 -44
  161. package/src/factor/__tests__/validate.test.ts +80 -45
  162. package/src/factor/index.ts +3 -7
  163. package/src/factor/types.ts +14 -18
  164. package/src/factor/validate.ts +77 -51
  165. package/src/fgca/__tests__/chain-table.test.ts +172 -172
  166. package/src/fgca/__tests__/layout.test.ts +179 -179
  167. package/src/fgca/__tests__/parse-canonical.test.ts +248 -248
  168. package/src/fgca/__tests__/preview-layout.test.ts +124 -124
  169. package/src/fgca/__tests__/resolver.test.ts +209 -0
  170. package/src/fgca/__tests__/validate.test.ts +142 -142
  171. package/src/fgca/chain-table.ts +225 -225
  172. package/src/fgca/index.ts +47 -45
  173. package/src/fgca/layout.ts +204 -204
  174. package/src/fgca/nodeTypes.ts +11 -11
  175. package/src/fgca/nodes/FGCAActivityNode.tsx +65 -65
  176. package/src/fgca/nodes/FGCAChangeNode.tsx +64 -64
  177. package/src/fgca/nodes/FGCAFactorNode.tsx +64 -64
  178. package/src/fgca/nodes/FGCAGoalNode.tsx +72 -72
  179. package/src/fgca/parse-canonical.ts +400 -400
  180. package/src/fgca/preview-layout.ts +217 -217
  181. package/src/fgca/resolver.ts +157 -0
  182. package/src/fgca/types.ts +50 -50
  183. package/src/fgca/validate.ts +126 -126
  184. package/src/geometry.ts +9 -9
  185. package/src/goals/__tests__/layout.test.ts +175 -175
  186. package/src/goals/__tests__/mutations.test.ts +111 -111
  187. package/src/goals/__tests__/parse-canonical.test.ts +145 -145
  188. package/src/goals/__tests__/validate.test.ts +202 -202
  189. package/src/goals/index.ts +20 -21
  190. package/src/goals/layout.ts +172 -172
  191. package/src/goals/mutations.ts +123 -123
  192. package/src/goals/parse-canonical.ts +286 -286
  193. package/src/goals/types.ts +85 -85
  194. package/src/goals/validate.ts +136 -136
  195. package/src/index.ts +28 -23
  196. package/src/process-blueprint/__tests__/compliance-lane.test.ts +320 -320
  197. package/src/process-blueprint/__tests__/layout.test.ts +240 -195
  198. package/src/process-blueprint/__tests__/validate.test.ts +310 -310
  199. package/src/process-blueprint/index.ts +31 -31
  200. package/src/process-blueprint/layout.ts +535 -531
  201. package/src/process-blueprint/types.ts +263 -242
  202. package/src/process-blueprint/validate.ts +181 -181
  203. package/src/process-map/__tests__/validate.test.ts +223 -223
  204. package/src/process-map/index.ts +10 -10
  205. package/src/process-map/types.ts +36 -36
  206. package/src/process-map/validate.ts +135 -135
  207. package/src/products/__tests__/validate.test.ts +239 -239
  208. package/src/products/index.ts +9 -9
  209. package/src/products/types.ts +31 -31
  210. package/src/products/validate.ts +112 -112
  211. package/src/repo-validate/__tests__/validate-repo.test.ts +149 -149
  212. package/src/repo-validate/index.ts +2 -2
  213. package/src/repo-validate/types.ts +50 -50
  214. package/src/repo-validate/validate-repo.ts +196 -196
  215. package/src/requirement/__tests__/example.test.ts +24 -24
  216. package/src/requirement/__tests__/validate.test.ts +96 -96
  217. package/src/requirement/index.ts +4 -4
  218. package/src/requirement/types.ts +35 -35
  219. package/src/requirement/validate.ts +92 -92
  220. package/src/scenarios/__tests__/validate.test.ts +209 -209
  221. package/src/scenarios/index.ts +15 -15
  222. package/src/scenarios/types.ts +37 -37
  223. package/src/scenarios/validate.ts +122 -122
  224. package/src/schema-version.ts +16 -16
  225. package/src/scope.ts +46 -46
  226. package/src/stakeholder/__tests__/validate.test.ts +51 -47
  227. package/src/stakeholder/index.ts +1 -0
  228. package/src/stakeholder/types.ts +7 -7
  229. package/src/stakeholder/validate.ts +79 -52
  230. package/src/target-state/__tests__/validate.test.ts +48 -27
  231. package/src/target-state/index.ts +2 -0
  232. package/src/target-state/types.ts +11 -7
  233. package/src/target-state/validate.ts +76 -46
  234. package/src/theme/index.ts +4 -4
  235. package/src/theme/themes.ts +237 -237
  236. package/src/theme/tokens.ts +133 -133
  237. package/src/typed-id.ts +56 -56
  238. package/src/validation-types.ts +36 -36
  239. package/src/webview/__tests__/entry-notations.test.ts +77 -77
  240. package/src/webview/__tests__/entry.test.ts +64 -64
  241. package/src/webview/__tests__/render-goals.test.ts +72 -72
  242. package/src/webview/entry.ts +292 -292
  243. package/src/webview/render-activities.ts +5 -3
  244. package/src/webview/render-activity-card.ts +266 -234
  245. package/src/webview/render-fgca.ts +6 -5
  246. package/src/webview/render-goals.ts +111 -110
  247. package/src/webview/render-process-blueprint.ts +207 -207
  248. package/src/webview/styles.css +322 -323
  249. package/src/yaml-normalize.ts +56 -56
  250. package/dist/element-envelope.d.ts +0 -15
  251. package/dist/element-envelope.js +0 -55
  252. package/dist/issues/__tests__/layout.test.js +0 -62
  253. package/dist/issues/__tests__/validate.test.d.ts +0 -1
  254. package/dist/issues/__tests__/validate.test.js +0 -82
  255. package/dist/issues/index.d.ts +0 -4
  256. package/dist/issues/index.js +0 -2
  257. package/dist/issues/layout.d.ts +0 -10
  258. package/dist/issues/layout.js +0 -82
  259. package/dist/issues/types.d.ts +0 -61
  260. package/dist/issues/types.js +0 -6
  261. package/dist/issues/validate.d.ts +0 -3
  262. package/dist/issues/validate.js +0 -143
  263. package/dist/webview/transitrix-render.css +0 -323
  264. package/dist/webview/transitrix-render.js +0 -6452
  265. package/dist/webview/transitrix-render.js.map +0 -7
  266. package/src/element-envelope.ts +0 -76
  267. /package/dist/{issues/__tests__/layout.test.d.ts → fgca/__tests__/resolver.test.d.ts} +0 -0
@@ -1,677 +1,861 @@
1
- // Compliance-impact matrix derivation + markdown renderer
2
- // (vkgeorgia/strategy#166 — interim renderer; CV-1 view-config wiring
3
- // per strategy#84 decomposition refresh).
4
- //
5
- // Implements the render contract from
6
- // methodology/notations/views/21-compliance-impact.md §5 at the coarsest grain
7
- // (`product` columns × `obligation` rows). Stage / task grouping is planned
8
- // for CV-3a and intentionally out of scope here.
9
-
10
- import type { AssertionStatus } from '../assertion/types.js';
11
- import type { ComplianceCanon } from './classify.js';
12
- import { buildComplianceIndex } from './reverse-index.js';
13
- import type { ComplianceIndex, IndexAssertion, IndexRequirement, ObjectDetailInput, ObjectDetailDef, DeadlineStatus } from './types.js';
14
-
15
- /** Filter selecting REQUIREMENTs by codex source (jurisdiction / regime keys
16
- * are accepted for forward compatibility but not yet honoured — the canon
17
- * projection does not carry those fields). */
18
- export interface ImpactObligationFilter {
19
- derived_from_codex?: string[];
20
- }
21
-
22
- export interface ImpactSubjects {
23
- products?: string[];
24
- processes?: string[];
25
- }
26
-
27
- export interface ImpactStatusDisplay {
28
- show?: AssertionStatus[];
29
- }
30
-
31
- export interface ImpactEmptyCellLabels {
32
- /** Label used when no admitted ASSERTION binds the cell (modelling gap). */
33
- no_obligation_label?: string;
34
- /** Label used when an admitted ASSERTION exists with status `n_a`
35
- * (modelled fact: the obligation does not apply). */
36
- no_obligation_applies_label?: string;
37
- }
38
-
39
- // ── Column model (CV-3a) ────────────────────────────────────────────────────
40
-
41
- /**
42
- * A matrix column descriptor.
43
- *
44
- * At the coarsest grain (`grouping.columns: 'product'`) a column maps to a
45
- * single subject: `{ subjectId, label: subjectId }`.
46
- *
47
- * At `grouping.columns: 'product-stage'` a column represents one (product,
48
- * stage) pair: `{ subjectId: productId, stageId, label: productId + ':' + stageId }`.
49
- * A cell is populated when an ASSERTION has `subject === subjectId` and
50
- * `stageId ∈ assertion.realised_via` (or `realised_via` is absent, meaning
51
- * the assertion covers the entire subject — it then contributes to every
52
- * stage column for that subject).
53
- */
54
- export interface ImpactColumn {
55
- /** Subject (product / process / capability) the column belongs to. */
56
- subjectId: string;
57
- /** Stage ID — present only when grouping.columns is not 'product'. */
58
- stageId?: string;
59
- /** Human-readable column header. */
60
- label: string;
61
- }
62
-
63
- /**
64
- * Grouping options for matrix columns.
65
- *
66
- * `columns` controls the column grain:
67
- * - `'product'` (default) — one column per subject.
68
- * - `'product-stage'` — one column per (subject, stage) pair derived
69
- * from `objectDetails` passed to `buildImpactMatrix`.
70
- */
71
- export interface ImpactGrouping {
72
- columns: 'object' | 'object-details';
73
- }
74
-
75
- /**
76
- * Named, versioned view-config — the report *definition*.
77
- *
78
- * A saved view config is the source of truth for a deterministic report run:
79
- * given the same config + canon, `buildImpactMatrix` always produces the
80
- * same matrix. Configs are stored in YAML files (top-level `view:` key) and
81
- * referenced by `id`.
82
- *
83
- * `snapshot_at` (ISO 8601 date) records when the report was last generated.
84
- * CV-3 blueprint-lane rendering uses it to mark obligations that appeared in
85
- * the canon *after* the snapshot as "new" (dashed-border cell decoration).
86
- */
87
- export interface ImpactViewConfig {
88
- id: string;
89
- name: string;
90
- description?: string;
91
- /**
92
- * ISO 8601 date of the last report snapshot (YYYY-MM-DD).
93
- * Populated automatically by the CLI on each run and stored back to the
94
- * view-config file, giving CV-3 its "new since last run" signal.
95
- */
96
- snapshot_at?: string;
97
- subjects: ImpactSubjects;
98
- obligations: {
99
- include?: string[];
100
- filter?: ImpactObligationFilter;
101
- };
102
- status_display?: ImpactStatusDisplay;
103
- empty_cells?: ImpactEmptyCellLabels;
104
- order_rows_by?: 'id' | 'name';
105
- /**
106
- * Column grouping options. Omit (or set `columns: 'product'`) for the
107
- * default single-column-per-subject behaviour. Set `columns: 'product-stage'`
108
- * and pass `objectDetails` into `buildImpactMatrix` to expand each subject
109
- * into per-stage sub-columns.
110
- */
111
- grouping?: ImpactGrouping;
112
- }
113
-
114
- // ── Pinned defaults ─────────────────────────────────────────────────────────
115
-
116
- /**
117
- * Explicit defaults for every optional field in ImpactViewConfig.
118
- *
119
- * When a view config omits a field, COMPLIANCE_IMPACT_DEFAULTS defines the
120
- * assumed behaviour. The CLI prints these so re-runs without a full config
121
- * are auditable ("what I assumed").
122
- */
123
- export const COMPLIANCE_IMPACT_DEFAULTS = {
124
- /** Accept all four active statuses + n_a in cell aggregation. */
125
- status_display: {
126
- show: ['compliant', 'partial', 'non_compliant', 'under_review', 'n_a'] as AssertionStatus[],
127
- },
128
- /** Order rows by canonical REQUIREMENT ID (lexicographic). */
129
- order_rows_by: 'id' as const,
130
- /** §5.3 empty-cell labels. */
131
- empty_cells: {
132
- no_obligation_label: 'No mapped obligation (current model)',
133
- no_obligation_applies_label: 'No obligation applies',
134
- },
135
- /** Obligation scope: all known REQUIREMENTs in the canon (no filter). */
136
- obligations: { include: undefined as string[] | undefined, filter: undefined },
137
- /** Subjects: empty — must be supplied explicitly in the view config. */
138
- subjects: { products: [] as string[], processes: [] as string[] },
139
- /** Column grain: one column per business object (no stage decomposition). */
140
- grouping: { columns: 'object' as const },
141
- } as const;
142
-
143
- // ── View-config parser ──────────────────────────────────────────────────────
144
-
145
- export type ParseImpactViewConfigResult =
146
- | { ok: true; config: ImpactViewConfig }
147
- | { ok: false; errors: string[] };
148
-
149
- /**
150
- * Validate and normalise a raw (YAML-parsed) value into an ImpactViewConfig.
151
- *
152
- * Accepts both the bare config object and the view-config file shape
153
- * (`{ view: { id, name, … } }`) — the top-level `view:` wrapper is unwrapped
154
- * automatically.
155
- *
156
- * Every optional field is filled from COMPLIANCE_IMPACT_DEFAULTS, so callers
157
- * can rely on the returned config being complete without any `??` chains.
158
- *
159
- * Returns `{ ok: false, errors }` on schema violations rather than throwing,
160
- * so the CLI can surface a clean error message without a stack trace.
161
- */
162
- export function parseImpactViewConfig(raw: unknown): ParseImpactViewConfigResult {
163
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
164
- return { ok: false, errors: ['view config: expected an object at the document root'] };
165
- }
166
- const top = raw as Record<string, unknown>;
167
-
168
- // Unwrap optional `view:` wrapper (view-config YAML files use this key).
169
- const v: Record<string, unknown> =
170
- 'view' in top && top.view && typeof top.view === 'object' && !Array.isArray(top.view)
171
- ? (top.view as Record<string, unknown>)
172
- : top;
173
-
174
- const errors: string[] = [];
175
- if (!v.id || typeof v.id !== 'string') errors.push('view.id: required string');
176
- if (!v.name || typeof v.name !== 'string') errors.push('view.name: required string');
177
- if (v.subjects !== undefined && (typeof v.subjects !== 'object' || Array.isArray(v.subjects))) {
178
- errors.push('view.subjects: expected an object');
179
- }
180
- if (errors.length) return { ok: false, errors };
181
-
182
- const subjects =
183
- v.subjects && typeof v.subjects === 'object' && !Array.isArray(v.subjects)
184
- ? (v.subjects as Record<string, unknown>)
185
- : {};
186
- const obligations =
187
- v.obligations && typeof v.obligations === 'object' && !Array.isArray(v.obligations)
188
- ? (v.obligations as Record<string, unknown>)
189
- : {};
190
- const obFilter =
191
- obligations.filter && typeof obligations.filter === 'object' && !Array.isArray(obligations.filter)
192
- ? (obligations.filter as Record<string, unknown>)
193
- : null;
194
- const statusDisplay =
195
- v.status_display && typeof v.status_display === 'object' && !Array.isArray(v.status_display)
196
- ? (v.status_display as Record<string, unknown>)
197
- : {};
198
- const emptyCells =
199
- v.empty_cells && typeof v.empty_cells === 'object' && !Array.isArray(v.empty_cells)
200
- ? (v.empty_cells as Record<string, unknown>)
201
- : {};
202
-
203
- const config: ImpactViewConfig = {
204
- id: v.id as string,
205
- name: v.name as string,
206
- description: typeof v.description === 'string' ? v.description : undefined,
207
- snapshot_at: typeof v.snapshot_at === 'string' ? v.snapshot_at : undefined,
208
- subjects: {
209
- products: Array.isArray(subjects.products)
210
- ? (subjects.products as unknown[]).filter((x): x is string => typeof x === 'string')
211
- : [...COMPLIANCE_IMPACT_DEFAULTS.subjects.products],
212
- processes: Array.isArray(subjects.processes)
213
- ? (subjects.processes as unknown[]).filter((x): x is string => typeof x === 'string')
214
- : [...COMPLIANCE_IMPACT_DEFAULTS.subjects.processes],
215
- },
216
- obligations: {
217
- include: Array.isArray(obligations.include)
218
- ? (obligations.include as unknown[]).filter((x): x is string => typeof x === 'string')
219
- : undefined,
220
- filter: obFilter
221
- ? {
222
- derived_from_codex: Array.isArray(obFilter.derived_from_codex)
223
- ? (obFilter.derived_from_codex as unknown[]).filter((x): x is string => typeof x === 'string')
224
- : undefined,
225
- }
226
- : undefined,
227
- },
228
- status_display: {
229
- show: Array.isArray(statusDisplay.show)
230
- ? (statusDisplay.show as unknown[]).filter((x): x is AssertionStatus => typeof x === 'string')
231
- : [...COMPLIANCE_IMPACT_DEFAULTS.status_display.show],
232
- },
233
- empty_cells: {
234
- no_obligation_label:
235
- typeof emptyCells.no_obligation_label === 'string'
236
- ? emptyCells.no_obligation_label
237
- : COMPLIANCE_IMPACT_DEFAULTS.empty_cells.no_obligation_label,
238
- no_obligation_applies_label:
239
- typeof emptyCells.no_obligation_applies_label === 'string'
240
- ? emptyCells.no_obligation_applies_label
241
- : COMPLIANCE_IMPACT_DEFAULTS.empty_cells.no_obligation_applies_label,
242
- },
243
- order_rows_by: v.order_rows_by === 'name' ? 'name' : COMPLIANCE_IMPACT_DEFAULTS.order_rows_by,
244
- };
245
-
246
- return { ok: true, config };
247
- }
248
-
249
- /** A single cell of the rendered matrix. */
250
- export interface ImpactCell {
251
- /** Resolved status when an ASSERTION binds the cell, else null. */
252
- status: AssertionStatus | null;
253
- /** Empty-cell condition per §5.3 — one of:
254
- * - `bound` : at least one admitted ASSERTION binds the cell; `status` is set.
255
- * - `n_a_only`: every binding ASSERTION has status `n_a` (modelled fact).
256
- * - `gap` : no admitted ASSERTION binds the cell (modelling gap). */
257
- kind: 'bound' | 'n_a_only' | 'gap';
258
- /** The assertions that contributed to this cell, id-sorted. */
259
- assertions: IndexAssertion[];
260
- /**
261
- * CV-3 blueprint-lane decorations.
262
- *
263
- * `isNew` — the row's REQUIREMENT was admitted after the report
264
- * `snapshot_at` date (dashed-border decoration).
265
- * `isUrgent` — the cell is a gap AND the requirement has a deadline
266
- * whose temporal status is `past_due` or `in_force`.
267
- * `deadlineStatus` — temporal distance to the deadline (or `'none'`).
268
- */
269
- decoration: {
270
- isNew: boolean;
271
- isUrgent: boolean;
272
- deadlineStatus: DeadlineStatus;
273
- };
274
- }
275
-
276
- export interface ImpactMatrix {
277
- viewId: string;
278
- viewName: string;
279
- description?: string;
280
- /**
281
- * ISO 8601 date of the last report snapshot, copied from the view config.
282
- * CV-3 blueprint-lane rendering uses this to flag obligations that appeared
283
- * after the snapshot as "new" (dashed-border cell decoration).
284
- */
285
- snapshotAt?: string;
286
- /** Row dimension — REQUIREMENT projections, in the configured order. */
287
- rows: IndexRequirement[];
288
- /**
289
- * Column dimension.
290
- *
291
- * At `grouping.columns: 'product'` (default) each entry has only `subjectId`
292
- * and `label`. At `'product-stage'` entries additionally carry `stageId`.
293
- */
294
- columns: ImpactColumn[];
295
- /** Cells, indexed as `cell[rowIdx][colIdx]`. */
296
- cells: ImpactCell[][];
297
- /** Canonical empty-cell labels actually used (after defaults applied). */
298
- emptyLabels: Required<ImpactEmptyCellLabels>;
299
- /**
300
- * CV-3 derived obligations lane.
301
- *
302
- * For each column, the set of codex source IDs (`REQUIREMENT.derived_from`)
303
- * that appear in at least one non-gap cell in that column. Renderers use
304
- * this to display a "laws lane" header above or below the matrix columns.
305
- * Empty array when no requirement in the column has a `derived_from`.
306
- */
307
- obligationsLane: string[][];
308
- }
309
-
310
- const DEFAULT_NO_OBLIGATION_LABEL = 'No mapped obligation (current model)';
311
- const DEFAULT_NO_OBLIGATION_APPLIES_LABEL = 'No obligation applies';
312
-
313
- // ── Deadline temporal status (CV-3) ─────────────────────────────────────────
314
-
315
- /** Number of days within which a deadline is considered "in force" (imminent). */
316
- const IN_FORCE_HORIZON_DAYS = 30;
317
-
318
- /**
319
- * Classify a REQUIREMENT deadline relative to a reference date.
320
- *
321
- * @param deadline ISO 8601 date string (YYYY-MM-DD), or undefined.
322
- * @param today Reference date string (YYYY-MM-DD); defaults to today.
323
- */
324
- export function computeDeadlineStatus(
325
- deadline: string | undefined,
326
- today: string = new Date().toISOString().slice(0, 10),
327
- ): DeadlineStatus {
328
- if (!deadline) return 'none';
329
- if (deadline < today) return 'past_due';
330
- const daysAway = Math.round(
331
- (new Date(deadline).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24),
332
- );
333
- return daysAway <= IN_FORCE_HORIZON_DAYS ? 'in_force' : 'upcoming';
334
- }
335
-
336
- function byId<T extends { id: string }>(a: T, b: T): number {
337
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
338
- }
339
-
340
- function resolveObligations(
341
- config: ImpactViewConfig,
342
- index: ComplianceIndex,
343
- allRequirements: IndexRequirement[],
344
- ): IndexRequirement[] {
345
- // `include` wins over `filter` per §4 (and COMPIMP-007).
346
- if (config.obligations.include?.length) {
347
- const out: IndexRequirement[] = [];
348
- for (const id of config.obligations.include) {
349
- const r = index.requirementById.get(id);
350
- if (r) out.push(r);
351
- }
352
- return out;
353
- }
354
- const codices = config.obligations.filter?.derived_from_codex ?? [];
355
- if (codices.length === 0) return [...allRequirements].sort(byId);
356
- const seen = new Set<string>();
357
- const out: IndexRequirement[] = [];
358
- for (const codex of codices) {
359
- for (const r of index.requirementsByLaw.get(codex) ?? []) {
360
- if (!seen.has(r.id)) {
361
- seen.add(r.id);
362
- out.push(r);
363
- }
364
- }
365
- }
366
- return out;
367
- }
368
-
369
- function orderRows(rows: IndexRequirement[], key: 'id' | 'name' | undefined): IndexRequirement[] {
370
- const sorted = [...rows];
371
- if (key === 'name') {
372
- sorted.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
373
- } else {
374
- sorted.sort(byId);
375
- }
376
- return sorted;
377
- }
378
-
379
- /** Aggregate multiple matching assertions into one cell value, per §5.2 step 4.
380
- * Assumes at least one assertion matches an allowed status; caller filters. */
381
- function aggregateStatus(assertions: IndexAssertion[]): AssertionStatus {
382
- let sawPartial = false;
383
- let sawUnderReview = false;
384
- let sawCompliant = false;
385
- for (const a of assertions) {
386
- if (a.status === 'non_compliant') return 'non_compliant';
387
- if (a.status === 'partial') sawPartial = true;
388
- else if (a.status === 'under_review') sawUnderReview = true;
389
- else if (a.status === 'compliant') sawCompliant = true;
390
- }
391
- if (sawPartial) return 'partial';
392
- if (sawUnderReview) return 'under_review';
393
- if (sawCompliant) return 'compliant';
394
- return 'n_a';
395
- }
396
-
397
- // ── Column builder helpers (CV-3a) ──────────────────────────────────────────
398
-
399
- function buildProductColumns(config: ImpactViewConfig): ImpactColumn[] {
400
- const subjects = [...(config.subjects.products ?? []), ...(config.subjects.processes ?? [])];
401
- return subjects.map(id => ({ subjectId: id, label: id }));
402
- }
403
-
404
- function buildObjectDetailColumns(config: ImpactViewConfig, objectDetails: ObjectDetailInput[]): ImpactColumn[] {
405
- const detailMap = new Map<string, ObjectDetailDef[]>(objectDetails.map(d => [d.objectId, d.details]));
406
- const subjects = [...(config.subjects.products ?? []), ...(config.subjects.processes ?? [])];
407
- const cols: ImpactColumn[] = [];
408
- for (const subjectId of subjects) {
409
- const details = detailMap.get(subjectId);
410
- if (!details || details.length === 0) {
411
- cols.push({ subjectId, label: subjectId });
412
- } else {
413
- for (const detail of details) {
414
- cols.push({ subjectId, stageId: detail.id, label: `${subjectId}:${detail.id}` });
415
- }
416
- }
417
- }
418
- return cols;
419
- }
420
-
421
- /**
422
- * Returns true when assertion `a` contributes to column `col`.
423
- *
424
- * At `'product'` grain: `a.subject` must match `col.subjectId`.
425
- * At `'product-stage'` grain: `a.subject` must match AND either
426
- * `a.realised_via` is absent (claim covers the whole subject, so every stage
427
- * inherits it) OR `col.stageId` ∈ `a.realised_via`.
428
- */
429
- function assertionMatchesColumn(a: IndexAssertion, col: ImpactColumn): boolean {
430
- if (a.subject !== col.subjectId) return false;
431
- if (!col.stageId) return true;
432
- if (!a.realised_via || a.realised_via.length === 0) return true;
433
- return a.realised_via.includes(col.stageId);
434
- }
435
-
436
- /**
437
- * Build the matrix per §5.2.
438
- *
439
- * At the default `grouping.columns: 'product'` grain, one column per subject.
440
- * At `grouping.columns: 'product-stage'`, pass `objectDetails` to expand each
441
- * subject into per-stage sub-columns (typically extracted from a
442
- * process-blueprint document via `extractObjectDetails`). Subjects with no
443
- * stage mapping fall back to a single product-grain column automatically.
444
- *
445
- * Subjects are taken from `config.subjects.products` and
446
- * `config.subjects.processes` (in that order). Subjects with no binding
447
- * obligation still appear as columns so the gap is visible.
448
- */
449
- export function buildImpactMatrix(
450
- canon: ComplianceCanon,
451
- config: ImpactViewConfig,
452
- objectDetails?: ObjectDetailInput[],
453
- ): ImpactMatrix {
454
- const index = buildComplianceIndex({
455
- requirements: canon.requirements,
456
- assertions: canon.assertions,
457
- });
458
-
459
- const obligations = orderRows(resolveObligations(config, index, canon.requirements), config.order_rows_by);
460
-
461
- const useStageGrain =
462
- config.grouping?.columns === 'object-details' && objectDetails && objectDetails.length > 0;
463
- const columns: ImpactColumn[] = useStageGrain
464
- ? buildObjectDetailColumns(config, objectDetails!)
465
- : buildProductColumns(config);
466
-
467
- const allowedStatuses = new Set<AssertionStatus>(
468
- config.status_display?.show ?? ['compliant', 'partial', 'non_compliant', 'under_review', 'n_a'],
469
- );
470
-
471
- const rowIndex = new Set(obligations.map(r => r.id));
472
- const cells: ImpactCell[][] = obligations.map(() => columns.map(() => emptyCell()));
473
-
474
- for (let c = 0; c < columns.length; c++) {
475
- const col = columns[c];
476
- const subjectAssertions = index.assertionsBySubject.get(col.subjectId) ?? [];
477
- for (const a of subjectAssertions) {
478
- if (!rowIndex.has(a.about)) continue;
479
- if (!allowedStatuses.has(a.status)) continue;
480
- if (!assertionMatchesColumn(a, col)) continue;
481
- const rowIdx = obligations.findIndex(r => r.id === a.about);
482
- if (rowIdx < 0) continue;
483
- cells[rowIdx][c].assertions.push(a);
484
- }
485
- }
486
-
487
- // Resolve each cell to a status / kind.
488
- for (let r = 0; r < cells.length; r++) {
489
- for (let c = 0; c < cells[r].length; c++) {
490
- const cell = cells[r][c];
491
- cell.assertions.sort(byId);
492
- if (cell.assertions.length === 0) {
493
- cell.kind = 'gap';
494
- cell.status = null;
495
- continue;
496
- }
497
- if (cell.assertions.every(a => a.status === 'n_a')) {
498
- cell.kind = 'n_a_only';
499
- cell.status = 'n_a';
500
- continue;
501
- }
502
- cell.kind = 'bound';
503
- cell.status = aggregateStatus(cell.assertions);
504
- }
505
- }
506
-
507
- // ── CV-3 decorations ────────────────────────────────────────────────────
508
- const today = new Date().toISOString().slice(0, 10);
509
- for (let r = 0; r < cells.length; r++) {
510
- const req = obligations[r];
511
- const deadlineStatus = computeDeadlineStatus(req.deadline, today);
512
- // "new" = requirement admitted after the snapshot date.
513
- const isNew =
514
- !!config.snapshot_at &&
515
- !!req.admitted_at &&
516
- req.admitted_at > config.snapshot_at;
517
- for (let c = 0; c < cells[r].length; c++) {
518
- const cell = cells[r][c];
519
- const isUrgent =
520
- cell.kind === 'gap' &&
521
- (deadlineStatus === 'past_due' || deadlineStatus === 'in_force');
522
- cell.decoration = { isNew, isUrgent, deadlineStatus };
523
- }
524
- }
525
-
526
- // ── CV-3 derived obligations lane ───────────────────────────────────────
527
- // For each column: collect codex source IDs from non-gap rows.
528
- const obligationsLane: string[][] = columns.map((_, c) => {
529
- const codexIds = new Set<string>();
530
- for (let r = 0; r < cells.length; r++) {
531
- if (cells[r][c].kind !== 'gap') {
532
- for (const src of obligations[r].derived_from ?? []) {
533
- codexIds.add(src);
534
- }
535
- }
536
- }
537
- return [...codexIds].sort();
538
- });
539
-
540
- return {
541
- viewId: config.id,
542
- viewName: config.name,
543
- description: config.description,
544
- snapshotAt: config.snapshot_at,
545
- rows: obligations,
546
- columns,
547
- cells,
548
- emptyLabels: {
549
- no_obligation_label: config.empty_cells?.no_obligation_label ?? DEFAULT_NO_OBLIGATION_LABEL,
550
- no_obligation_applies_label:
551
- config.empty_cells?.no_obligation_applies_label ?? DEFAULT_NO_OBLIGATION_APPLIES_LABEL,
552
- },
553
- obligationsLane,
554
- };
555
- }
556
-
557
- function emptyCell(): ImpactCell {
558
- return {
559
- status: null,
560
- kind: 'gap',
561
- assertions: [],
562
- decoration: { isNew: false, isUrgent: false, deadlineStatus: 'none' },
563
- };
564
- }
565
-
566
- // ── Process-blueprint stage extractor (CV-3a) ───────────────────────────────
567
-
568
- /**
569
- * Extract `ObjectDetailInput` from a raw (YAML-parsed) process-blueprint document.
570
- *
571
- * Accepts both the bare document object and the wrapped form
572
- * (`{ process_blueprint: { id, stages: […] } }`).
573
- * Returns `null` when the document is not a recognisable process-blueprint or
574
- * has no `id` / `stages` array so callers can skip unrecognised files safely.
575
- *
576
- * Pass the returned value into `buildImpactMatrix` as part of the
577
- * `objectDetails` array when `grouping.columns: 'product-stage'` is configured.
578
- */
579
- export function extractObjectDetails(doc: unknown): ObjectDetailInput | null {
580
- if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
581
- const top = doc as Record<string, unknown>;
582
-
583
- const bp: Record<string, unknown> | null =
584
- top.notation === 'process_blueprint'
585
- ? top
586
- : top.process_blueprint && typeof top.process_blueprint === 'object' && !Array.isArray(top.process_blueprint)
587
- ? (top.process_blueprint as Record<string, unknown>)
588
- : null;
589
-
590
- if (!bp) return null;
591
- const id = typeof bp.id === 'string' ? bp.id : null;
592
- if (!id) return null;
593
- if (!Array.isArray(bp.stages)) return null;
594
-
595
- const details: ObjectDetailDef[] = [];
596
- for (const s of bp.stages) {
597
- if (!s || typeof s !== 'object' || Array.isArray(s)) continue;
598
- const sid = typeof s.id === 'string' ? s.id : null;
599
- if (!sid) continue;
600
- details.push({ id: sid, name: typeof s.name === 'string' ? s.name : sid });
601
- }
602
-
603
- return { objectId: id, details };
604
- }
605
-
606
- // ── Markdown rendering ──────────────────────────────────────────────────────
607
-
608
- const STATUS_GLYPH: Record<AssertionStatus, string> = {
609
- compliant: 'OK',
610
- partial: 'PARTIAL',
611
- non_compliant: 'FAIL',
612
- under_review: 'REVIEW',
613
- n_a: 'N/A',
614
- };
615
-
616
- function escMd(s: string): string {
617
- return s.replace(/\|/g, '\\|');
618
- }
619
-
620
- /**
621
- * Render the matrix as a GitHub-flavoured markdown table.
622
- *
623
- * Cell display:
624
- * - `bound` → the §5.2 deterministic status label.
625
- * - `n_a_only` → the "No obligation applies" label (or `view.empty_cells`
626
- * override).
627
- * - `gap` → the "No mapped obligation (current model)" label (or
628
- * override).
629
- */
630
- export function renderImpactMarkdown(matrix: ImpactMatrix): string {
631
- const lines: string[] = [];
632
- lines.push(`# ${matrix.viewName}`);
633
- lines.push('');
634
- lines.push(`View ID: \`${matrix.viewId}\``);
635
- if (matrix.snapshotAt) lines.push(`Report snapshot: ${matrix.snapshotAt}`);
636
- if (matrix.description) {
637
- lines.push('');
638
- lines.push(matrix.description);
639
- }
640
- lines.push('');
641
-
642
- if (matrix.rows.length === 0) {
643
- lines.push('_No obligations in scope — the configured filter / include selected zero REQUIREMENTs._');
644
- return lines.join('\n') + '\n';
645
- }
646
- if (matrix.columns.length === 0) {
647
- lines.push('_No subjects in scope — `view.subjects` is empty._');
648
- return lines.join('\n') + '\n';
649
- }
650
-
651
- const header = ['Obligation', ...matrix.columns.map(col => escMd(col.label))];
652
- lines.push('| ' + header.join(' | ') + ' |');
653
- lines.push('|' + header.map(() => '---').join('|') + '|');
654
-
655
- for (let r = 0; r < matrix.rows.length; r++) {
656
- const row = matrix.rows[r];
657
- const rowLabel = `${row.id}${row.name && row.name !== row.id ? ` — ${row.name}` : ''}`;
658
- const cells = matrix.cells[r].map(cell => renderCell(cell, matrix.emptyLabels));
659
- lines.push('| ' + [escMd(rowLabel), ...cells].join(' | ') + ' |');
660
- }
661
-
662
- // Legend — the §5.3 distinction must be visible in the rendered output.
663
- lines.push('');
664
- lines.push('## Legend');
665
- lines.push('');
666
- lines.push('- **OK / PARTIAL / FAIL / REVIEW / N/A** — aggregated `ASSERTION.status` per §5.2.');
667
- lines.push(`- **${escMd(matrix.emptyLabels.no_obligation_label)}** — modelling gap; no admitted ASSERTION binds this (obligation, subject) pair.`);
668
- lines.push(`- **${escMd(matrix.emptyLabels.no_obligation_applies_label)}** — modelled fact; an admitted ASSERTION with status \`n_a\` excludes this pair.`);
669
-
670
- return lines.join('\n') + '\n';
671
- }
672
-
673
- function renderCell(cell: ImpactCell, labels: Required<ImpactEmptyCellLabels>): string {
674
- if (cell.kind === 'gap') return escMd(labels.no_obligation_label);
675
- if (cell.kind === 'n_a_only') return escMd(labels.no_obligation_applies_label);
676
- return STATUS_GLYPH[cell.status as AssertionStatus];
677
- }
1
+ // Compliance-impact matrix derivation + markdown renderer
2
+ // (vkgeorgia/strategy#166 — interim renderer; CV-1 view-config wiring
3
+ // per strategy#84 decomposition refresh).
4
+ //
5
+ // Implements the render contract from
6
+ // methodology/notations/views/21-compliance-impact.md §5 at the coarsest grain
7
+ // (`product` columns × `obligation` rows). Stage / task grouping is planned
8
+ // for CV-3a and intentionally out of scope here.
9
+
10
+ import type { AssertionStatus } from '../assertion/types.js';
11
+ import type { ComplianceCanon } from './classify.js';
12
+ import { buildComplianceIndex } from './reverse-index.js';
13
+ import type { ComplianceIndex, IndexAssertion, IndexRequirement, ObjectDetailInput, ObjectDetailDef, DeadlineStatus } from './types.js';
14
+
15
+ /** Filter selecting REQUIREMENTs by codex source (jurisdiction / regime keys
16
+ * are accepted for forward compatibility but not yet honoured — the canon
17
+ * projection does not carry those fields). */
18
+ export interface ImpactObligationFilter {
19
+ derived_from_codex?: string[];
20
+ }
21
+
22
+ export interface ImpactSubjects {
23
+ products?: string[];
24
+ processes?: string[];
25
+ capabilities?: string[];
26
+ }
27
+
28
+ export interface ImpactStatusDisplay {
29
+ show?: AssertionStatus[];
30
+ }
31
+
32
+ export interface ImpactEmptyCellLabels {
33
+ /** Label used when no admitted ASSERTION binds the cell (modelling gap). */
34
+ no_obligation_label?: string;
35
+ /** Label used when an admitted ASSERTION exists with status `n_a`
36
+ * (modelled fact: the obligation does not apply). */
37
+ no_obligation_applies_label?: string;
38
+ }
39
+
40
+ // ── Column model (CV-3a) ────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * A matrix column descriptor.
44
+ *
45
+ * At the coarsest grain (`grouping.columns: 'product'`) a column maps to a
46
+ * single subject: `{ subjectId, label: subjectId }`.
47
+ *
48
+ * At `grouping.columns: 'product-stage'` a column represents one (product,
49
+ * stage) pair: `{ subjectId, stageId, label: subjectId + ':' + stageId }`.
50
+ * A cell is populated when an ASSERTION has `subject === subjectId` and
51
+ * `stageId ∈ assertion.realised_via` (or `realised_via` is absent, meaning
52
+ * the assertion covers the entire subject — it then contributes to every
53
+ * stage column for that subject).
54
+ *
55
+ * At `grouping.columns: 'product-stage-task'` a column represents one
56
+ * (product, stage, task) triple: `{ subjectId, stageId, taskId,
57
+ * label: subjectId + ':' + stageId + ':' + taskId }`. A cell is populated
58
+ * when an ASSERTION's `realised_via` contains the `taskId` directly, OR
59
+ * contains the `stageId` (stage-level claim applies to all tasks in that stage),
60
+ * OR `realised_via` is absent (claim covers the entire subject).
61
+ */
62
+ export interface ImpactColumn {
63
+ /** Subject (product / process / capability) the column belongs to. */
64
+ subjectId: string;
65
+ /** Stage ID — present at product-stage and product-stage-task grain. */
66
+ stageId?: string;
67
+ /** Task ID — present only at product-stage-task grain. */
68
+ taskId?: string;
69
+ /** Human-readable column header. */
70
+ label: string;
71
+ /** Display name for the subject (product/process name, not the id). */
72
+ subjectName?: string;
73
+ }
74
+
75
+ /**
76
+ * Grouping options for matrix columns.
77
+ *
78
+ * `columns` controls the column grain (COMPIMP-004 validates the value set):
79
+ * - `'product'` (default) — one column per subject.
80
+ * - `'product-stage'` — one column per (subject, stage) pair; pass
81
+ * `objectDetails` (from `extractObjectDetails`) into `buildImpactMatrix`.
82
+ * - `'product-stage-task'` — one column per (subject, stage, task) triple;
83
+ * pass `objectDetails` with stages that carry `tasks[]` (built via
84
+ * `extractObjectDetails` + `extractProcessFlowTasks` + `mergeStageTaskDetails`).
85
+ */
86
+ export interface ImpactGrouping {
87
+ columns: 'product' | 'product-stage' | 'product-stage-task';
88
+ }
89
+
90
+ /**
91
+ * Named, versioned view-config — the report *definition*.
92
+ *
93
+ * A saved view config is the source of truth for a deterministic report run:
94
+ * given the same config + canon, `buildImpactMatrix` always produces the
95
+ * same matrix. Configs are stored in YAML files (top-level `view:` key) and
96
+ * referenced by `id`.
97
+ *
98
+ * `snapshot_at` (ISO 8601 date) records when the report was last generated.
99
+ * CV-3 blueprint-lane rendering uses it to mark obligations that appeared in
100
+ * the canon *after* the snapshot as "new" (dashed-border cell decoration).
101
+ */
102
+ export interface ImpactViewConfig {
103
+ id: string;
104
+ name: string;
105
+ description?: string;
106
+ /**
107
+ * ISO 8601 date of the last report snapshot (YYYY-MM-DD).
108
+ * Populated automatically by the CLI on each run and stored back to the
109
+ * view-config file, giving CV-3 its "new since last run" signal.
110
+ */
111
+ snapshot_at?: string;
112
+ subjects: ImpactSubjects;
113
+ obligations: {
114
+ include?: string[];
115
+ filter?: ImpactObligationFilter;
116
+ };
117
+ status_display?: ImpactStatusDisplay;
118
+ empty_cells?: ImpactEmptyCellLabels;
119
+ order_rows_by?: 'id' | 'name';
120
+ /**
121
+ * Column grouping options. Omit (or set `columns: 'product'`) for the
122
+ * default single-column-per-subject behaviour. Set `columns: 'product-stage'`
123
+ * and pass `objectDetails` into `buildImpactMatrix` to expand each subject
124
+ * into per-stage sub-columns.
125
+ */
126
+ grouping?: ImpactGrouping;
127
+ }
128
+
129
+ // ── Pinned defaults ─────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Explicit defaults for every optional field in ImpactViewConfig.
133
+ *
134
+ * When a view config omits a field, COMPLIANCE_IMPACT_DEFAULTS defines the
135
+ * assumed behaviour. The CLI prints these so re-runs without a full config
136
+ * are auditable ("what I assumed").
137
+ */
138
+ export const COMPLIANCE_IMPACT_DEFAULTS = {
139
+ /** Accept all active statuses + n_a in cell aggregation. */
140
+ status_display: {
141
+ show: ['compliant', 'partial', 'non_compliant', 'under_review', 'pending_owner', 'n_a'] as AssertionStatus[],
142
+ },
143
+ /** Order rows by canonical REQUIREMENT ID (lexicographic). */
144
+ order_rows_by: 'id' as const,
145
+ /** §5.3 empty-cell labels. */
146
+ empty_cells: {
147
+ no_obligation_label: 'No mapped obligation (current model)',
148
+ no_obligation_applies_label: 'No obligation applies',
149
+ },
150
+ /** Obligation scope: all known REQUIREMENTs in the canon (no filter). */
151
+ obligations: { include: undefined as string[] | undefined, filter: undefined },
152
+ /** Subjects: empty — must be supplied explicitly in the view config. */
153
+ subjects: { products: [] as string[], processes: [] as string[], capabilities: [] as string[] },
154
+ /** Column grain: one column per subject (no stage/task decomposition). */
155
+ grouping: { columns: 'product' as const },
156
+ } as const;
157
+
158
+ // ── View-config parser ──────────────────────────────────────────────────────
159
+
160
+ export type ParseImpactViewConfigResult =
161
+ | { ok: true; config: ImpactViewConfig }
162
+ | { ok: false; errors: string[] };
163
+
164
+ /**
165
+ * Validate and normalise a raw (YAML-parsed) value into an ImpactViewConfig.
166
+ *
167
+ * Accepts both the bare config object and the view-config file shape
168
+ * (`{ view: { id, name, … } }`) — the top-level `view:` wrapper is unwrapped
169
+ * automatically.
170
+ *
171
+ * Every optional field is filled from COMPLIANCE_IMPACT_DEFAULTS, so callers
172
+ * can rely on the returned config being complete without any `??` chains.
173
+ *
174
+ * Returns `{ ok: false, errors }` on schema violations rather than throwing,
175
+ * so the CLI can surface a clean error message without a stack trace.
176
+ */
177
+ export function parseImpactViewConfig(raw: unknown): ParseImpactViewConfigResult {
178
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
179
+ return { ok: false, errors: ['view config: expected an object at the document root'] };
180
+ }
181
+ const top = raw as Record<string, unknown>;
182
+
183
+ // Unwrap optional `view:` wrapper (view-config YAML files use this key).
184
+ const v: Record<string, unknown> =
185
+ 'view' in top && top.view && typeof top.view === 'object' && !Array.isArray(top.view)
186
+ ? (top.view as Record<string, unknown>)
187
+ : top;
188
+
189
+ const errors: string[] = [];
190
+ if (!v.id || typeof v.id !== 'string') errors.push('view.id: required string');
191
+ if (!v.name || typeof v.name !== 'string') errors.push('view.name: required string');
192
+ if (v.subjects !== undefined && (typeof v.subjects !== 'object' || Array.isArray(v.subjects))) {
193
+ errors.push('view.subjects: expected an object');
194
+ }
195
+ if (errors.length) return { ok: false, errors };
196
+
197
+ const subjects =
198
+ v.subjects && typeof v.subjects === 'object' && !Array.isArray(v.subjects)
199
+ ? (v.subjects as Record<string, unknown>)
200
+ : {};
201
+ const obligations =
202
+ v.obligations && typeof v.obligations === 'object' && !Array.isArray(v.obligations)
203
+ ? (v.obligations as Record<string, unknown>)
204
+ : {};
205
+ const obFilter =
206
+ obligations.filter && typeof obligations.filter === 'object' && !Array.isArray(obligations.filter)
207
+ ? (obligations.filter as Record<string, unknown>)
208
+ : null;
209
+ const statusDisplay =
210
+ v.status_display && typeof v.status_display === 'object' && !Array.isArray(v.status_display)
211
+ ? (v.status_display as Record<string, unknown>)
212
+ : {};
213
+ const emptyCells =
214
+ v.empty_cells && typeof v.empty_cells === 'object' && !Array.isArray(v.empty_cells)
215
+ ? (v.empty_cells as Record<string, unknown>)
216
+ : {};
217
+
218
+ // Parse grouping.columns — accept spec-canonical values plus backward-compat aliases.
219
+ const rawGrouping =
220
+ v.grouping && typeof v.grouping === 'object' && !Array.isArray(v.grouping)
221
+ ? (v.grouping as Record<string, unknown>)
222
+ : null;
223
+ const rawColumns = typeof rawGrouping?.columns === 'string' ? rawGrouping.columns : undefined;
224
+ const VALID_COLUMNS = new Set<string>(['product', 'product-stage', 'product-stage-task']);
225
+ const normalizedColumns: ImpactGrouping['columns'] | undefined =
226
+ rawColumns === 'object-details' ? 'product-stage' // backward-compat alias
227
+ : rawColumns === 'object' ? 'product' // backward-compat alias
228
+ : VALID_COLUMNS.has(rawColumns ?? '') ? (rawColumns as ImpactGrouping['columns'])
229
+ : undefined;
230
+
231
+ const config: ImpactViewConfig = {
232
+ id: v.id as string,
233
+ name: v.name as string,
234
+ description: typeof v.description === 'string' ? v.description : undefined,
235
+ snapshot_at: typeof v.snapshot_at === 'string' ? v.snapshot_at : undefined,
236
+ subjects: {
237
+ products: Array.isArray(subjects.products)
238
+ ? (subjects.products as unknown[]).filter((x): x is string => typeof x === 'string')
239
+ : [...COMPLIANCE_IMPACT_DEFAULTS.subjects.products],
240
+ processes: Array.isArray(subjects.processes)
241
+ ? (subjects.processes as unknown[]).filter((x): x is string => typeof x === 'string')
242
+ : [...COMPLIANCE_IMPACT_DEFAULTS.subjects.processes],
243
+ capabilities: Array.isArray(subjects.capabilities)
244
+ ? (subjects.capabilities as unknown[]).filter((x): x is string => typeof x === 'string')
245
+ : [...COMPLIANCE_IMPACT_DEFAULTS.subjects.capabilities],
246
+ },
247
+ obligations: {
248
+ include: Array.isArray(obligations.include)
249
+ ? (obligations.include as unknown[]).filter((x): x is string => typeof x === 'string')
250
+ : undefined,
251
+ filter: obFilter
252
+ ? {
253
+ derived_from_codex: Array.isArray(obFilter.derived_from_codex)
254
+ ? (obFilter.derived_from_codex as unknown[]).filter((x): x is string => typeof x === 'string')
255
+ : undefined,
256
+ }
257
+ : undefined,
258
+ },
259
+ status_display: {
260
+ show: Array.isArray(statusDisplay.show)
261
+ ? (statusDisplay.show as unknown[]).filter((x): x is AssertionStatus => typeof x === 'string')
262
+ : [...COMPLIANCE_IMPACT_DEFAULTS.status_display.show],
263
+ },
264
+ empty_cells: {
265
+ no_obligation_label:
266
+ typeof emptyCells.no_obligation_label === 'string'
267
+ ? emptyCells.no_obligation_label
268
+ : COMPLIANCE_IMPACT_DEFAULTS.empty_cells.no_obligation_label,
269
+ no_obligation_applies_label:
270
+ typeof emptyCells.no_obligation_applies_label === 'string'
271
+ ? emptyCells.no_obligation_applies_label
272
+ : COMPLIANCE_IMPACT_DEFAULTS.empty_cells.no_obligation_applies_label,
273
+ },
274
+ order_rows_by: v.order_rows_by === 'name' ? 'name' : COMPLIANCE_IMPACT_DEFAULTS.order_rows_by,
275
+ grouping: normalizedColumns !== undefined ? { columns: normalizedColumns } : undefined,
276
+ };
277
+
278
+ return { ok: true, config };
279
+ }
280
+
281
+ /** A single cell of the rendered matrix. */
282
+ export interface ImpactCell {
283
+ /** Resolved status when an ASSERTION binds the cell, else null. */
284
+ status: AssertionStatus | null;
285
+ /** Empty-cell condition per §5.3 — one of:
286
+ * - `bound` : at least one admitted ASSERTION binds the cell; `status` is set.
287
+ * - `n_a_only`: every binding ASSERTION has status `n_a` (modelled fact).
288
+ * - `gap` : no admitted ASSERTION binds the cell (modelling gap). */
289
+ kind: 'bound' | 'n_a_only' | 'gap';
290
+ /** The assertions that contributed to this cell, id-sorted. */
291
+ assertions: IndexAssertion[];
292
+ /**
293
+ * CV-3 blueprint-lane decorations.
294
+ *
295
+ * `isNew` — the row's REQUIREMENT was admitted after the report
296
+ * `snapshot_at` date (dashed-border decoration).
297
+ * `isUrgent` — the cell is a gap AND the requirement has a deadline
298
+ * whose temporal status is `past_due` or `in_force`.
299
+ * `deadlineStatus` — temporal distance to the deadline (or `'none'`).
300
+ */
301
+ decoration: {
302
+ isNew: boolean;
303
+ isUrgent: boolean;
304
+ deadlineStatus: DeadlineStatus;
305
+ };
306
+ }
307
+
308
+ export interface ImpactMatrix {
309
+ viewId: string;
310
+ viewName: string;
311
+ description?: string;
312
+ /**
313
+ * ISO 8601 date of the last report snapshot, copied from the view config.
314
+ * CV-3 blueprint-lane rendering uses this to flag obligations that appeared
315
+ * after the snapshot as "new" (dashed-border cell decoration).
316
+ */
317
+ snapshotAt?: string;
318
+ /** Row dimension — REQUIREMENT projections, in the configured order. */
319
+ rows: IndexRequirement[];
320
+ /**
321
+ * Column dimension.
322
+ *
323
+ * At `grouping.columns: 'product'` (default) each entry has only `subjectId`
324
+ * and `label`. At `'product-stage'` entries additionally carry `stageId`.
325
+ */
326
+ columns: ImpactColumn[];
327
+ /** Cells, indexed as `cell[rowIdx][colIdx]`. */
328
+ cells: ImpactCell[][];
329
+ /** Canonical empty-cell labels actually used (after defaults applied). */
330
+ emptyLabels: Required<ImpactEmptyCellLabels>;
331
+ /**
332
+ * CV-3 derived obligations lane.
333
+ *
334
+ * For each column, the set of codex source IDs (`REQUIREMENT.derived_from`)
335
+ * that appear in at least one non-gap cell in that column. Renderers use
336
+ * this to display a "laws lane" header above or below the matrix columns.
337
+ * Empty array when no requirement in the column has a `derived_from`.
338
+ */
339
+ obligationsLane: string[][];
340
+ }
341
+
342
+ const DEFAULT_NO_OBLIGATION_LABEL = 'No mapped obligation (current model)';
343
+ const DEFAULT_NO_OBLIGATION_APPLIES_LABEL = 'No obligation applies';
344
+
345
+ // ── Deadline temporal status (CV-3) ─────────────────────────────────────────
346
+
347
+ /** Number of days within which a deadline is considered "in force" (imminent). */
348
+ const IN_FORCE_HORIZON_DAYS = 30;
349
+
350
+ /**
351
+ * Classify a REQUIREMENT deadline relative to a reference date.
352
+ *
353
+ * @param deadline ISO 8601 date string (YYYY-MM-DD), or undefined.
354
+ * @param today Reference date string (YYYY-MM-DD); defaults to today.
355
+ */
356
+ export function computeDeadlineStatus(
357
+ deadline: string | undefined,
358
+ today: string = new Date().toISOString().slice(0, 10),
359
+ ): DeadlineStatus {
360
+ if (!deadline) return 'none';
361
+ if (deadline < today) return 'past_due';
362
+ const daysAway = Math.round(
363
+ (new Date(deadline).getTime() - new Date(today).getTime()) / (1000 * 60 * 60 * 24),
364
+ );
365
+ return daysAway <= IN_FORCE_HORIZON_DAYS ? 'in_force' : 'upcoming';
366
+ }
367
+
368
+ function byId<T extends { id: string }>(a: T, b: T): number {
369
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
370
+ }
371
+
372
+ function resolveObligations(
373
+ config: ImpactViewConfig,
374
+ index: ComplianceIndex,
375
+ allRequirements: IndexRequirement[],
376
+ ): IndexRequirement[] {
377
+ // `include` wins over `filter` per §4 (and COMPIMP-007).
378
+ if (config.obligations.include?.length) {
379
+ const out: IndexRequirement[] = [];
380
+ for (const id of config.obligations.include) {
381
+ const r = index.requirementById.get(id);
382
+ if (r) out.push(r);
383
+ }
384
+ return out;
385
+ }
386
+ const codices = config.obligations.filter?.derived_from_codex ?? [];
387
+ if (codices.length === 0) return [...allRequirements].sort(byId);
388
+ const seen = new Set<string>();
389
+ const out: IndexRequirement[] = [];
390
+ for (const codex of codices) {
391
+ for (const r of index.requirementsByLaw.get(codex) ?? []) {
392
+ if (!seen.has(r.id)) {
393
+ seen.add(r.id);
394
+ out.push(r);
395
+ }
396
+ }
397
+ }
398
+ return out;
399
+ }
400
+
401
+ function orderRows(rows: IndexRequirement[], key: 'id' | 'name' | undefined): IndexRequirement[] {
402
+ const sorted = [...rows];
403
+ if (key === 'name') {
404
+ sorted.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
405
+ } else {
406
+ sorted.sort(byId);
407
+ }
408
+ return sorted;
409
+ }
410
+
411
+ /** Aggregate multiple matching assertions into one cell value, per §5.2 step 4.
412
+ * Precedence: non_compliant > partial > pending_owner > under_review > compliant > n_a.
413
+ * Assumes at least one assertion matches an allowed status; caller filters. */
414
+ function aggregateStatus(assertions: IndexAssertion[]): AssertionStatus {
415
+ let sawPartial = false;
416
+ let sawPendingOwner = false;
417
+ let sawUnderReview = false;
418
+ let sawCompliant = false;
419
+ for (const a of assertions) {
420
+ if (a.status === 'non_compliant') return 'non_compliant';
421
+ if (a.status === 'partial') sawPartial = true;
422
+ else if (a.status === 'pending_owner') sawPendingOwner = true;
423
+ else if (a.status === 'under_review') sawUnderReview = true;
424
+ else if (a.status === 'compliant') sawCompliant = true;
425
+ }
426
+ if (sawPartial) return 'partial';
427
+ if (sawPendingOwner) return 'pending_owner';
428
+ if (sawUnderReview) return 'under_review';
429
+ if (sawCompliant) return 'compliant';
430
+ return 'n_a';
431
+ }
432
+
433
+ // ── Column builder helpers (CV-3a) ──────────────────────────────────────────
434
+
435
+ function buildProductColumns(config: ImpactViewConfig, namedItems: { id: string; name: string }[]): ImpactColumn[] {
436
+ const nameMap = new Map(namedItems.map(p => [p.id, p.name]));
437
+ const subjects = [
438
+ ...(config.subjects.products ?? []),
439
+ ...(config.subjects.processes ?? []),
440
+ ...(config.subjects.capabilities ?? []),
441
+ ];
442
+ return subjects.map(id => ({ subjectId: id, label: id, subjectName: nameMap.get(id) }));
443
+ }
444
+
445
+ function buildObjectDetailColumns(config: ImpactViewConfig, objectDetails: ObjectDetailInput[]): ImpactColumn[] {
446
+ const detailMap = new Map<string, ObjectDetailDef[]>(objectDetails.map(d => [d.objectId, d.details]));
447
+ const subjects = [
448
+ ...(config.subjects.products ?? []),
449
+ ...(config.subjects.processes ?? []),
450
+ ...(config.subjects.capabilities ?? []),
451
+ ];
452
+ const cols: ImpactColumn[] = [];
453
+ for (const subjectId of subjects) {
454
+ const details = detailMap.get(subjectId);
455
+ if (!details || details.length === 0) {
456
+ cols.push({ subjectId, label: subjectId });
457
+ } else {
458
+ for (const detail of details) {
459
+ cols.push({ subjectId, stageId: detail.id, label: `${subjectId}:${detail.id}` });
460
+ }
461
+ }
462
+ }
463
+ return cols;
464
+ }
465
+
466
+ /**
467
+ * Build `product-stage-task` columns: one column per (subject, stage, task) triple.
468
+ * Each stage in `objectDetails` that carries a `tasks[]` array is expanded to
469
+ * per-task columns `{ subjectId, stageId, taskId, label: subjectId:stageId:taskId }`.
470
+ * Stages with no tasks fall back to a single stage-grain column.
471
+ */
472
+ function buildTaskColumns(config: ImpactViewConfig, objectDetails: ObjectDetailInput[]): ImpactColumn[] {
473
+ const detailMap = new Map<string, ObjectDetailDef[]>(objectDetails.map(d => [d.objectId, d.details]));
474
+ const subjects = [
475
+ ...(config.subjects.products ?? []),
476
+ ...(config.subjects.processes ?? []),
477
+ ...(config.subjects.capabilities ?? []),
478
+ ];
479
+ const cols: ImpactColumn[] = [];
480
+ for (const subjectId of subjects) {
481
+ const stages = detailMap.get(subjectId);
482
+ if (!stages || stages.length === 0) {
483
+ cols.push({ subjectId, label: subjectId });
484
+ } else {
485
+ for (const stage of stages) {
486
+ if (!stage.tasks || stage.tasks.length === 0) {
487
+ // Stage with no tasks: fall back to stage-grain column.
488
+ cols.push({ subjectId, stageId: stage.id, label: `${subjectId}:${stage.id}` });
489
+ } else {
490
+ for (const task of stage.tasks) {
491
+ cols.push({
492
+ subjectId,
493
+ stageId: stage.id,
494
+ taskId: task.id,
495
+ label: `${subjectId}:${stage.id}:${task.id}`,
496
+ });
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+ return cols;
503
+ }
504
+
505
+ /**
506
+ * Returns true when assertion `a` contributes to column `col`.
507
+ *
508
+ * At `'product'` grain: `a.subject` must match `col.subjectId`.
509
+ * At `'product-stage'` grain: `a.subject` must match AND either
510
+ * `a.realised_via` is absent (claim covers the whole subject) OR
511
+ * `col.stageId` ∈ `a.realised_via`.
512
+ * At `'product-stage-task'` grain: as above for stage, plus `col.taskId` ∈
513
+ * `a.realised_via` counts as a match (task-level claim covers this task column).
514
+ * A stage-level claim (`col.stageId` ∈ `a.realised_via`) covers all task columns
515
+ * in that stage.
516
+ */
517
+ function assertionMatchesColumn(a: IndexAssertion, col: ImpactColumn): boolean {
518
+ if (a.subject !== col.subjectId) return false;
519
+ if (!col.stageId) return true;
520
+ if (!a.realised_via || a.realised_via.length === 0) return true;
521
+ if (col.taskId) {
522
+ // Task grain: match on taskId OR on stageId (stage claim covers all tasks).
523
+ return a.realised_via.includes(col.taskId) || a.realised_via.includes(col.stageId);
524
+ }
525
+ return a.realised_via.includes(col.stageId);
526
+ }
527
+
528
+ /**
529
+ * Build the matrix per §5.2.
530
+ *
531
+ * At the default `grouping.columns: 'product'` grain, one column per subject.
532
+ *
533
+ * At `grouping.columns: 'product-stage'`, pass `objectDetails` (from
534
+ * `extractObjectDetails` on a process-blueprint) to expand each subject into
535
+ * per-stage sub-columns. Subjects with no stage mapping fall back to a single
536
+ * product-grain column.
537
+ *
538
+ * At `grouping.columns: 'product-stage-task'`, pass `objectDetails` where each
539
+ * stage carries a `tasks[]` array (combine blueprint stages and process-flow
540
+ * tasks via `mergeStageTaskDetails`). Each task becomes a
541
+ * `(subject, stage, task)` column. Stages with no tasks fall back to
542
+ * stage-grain columns.
543
+ *
544
+ * Subjects are taken from `config.subjects.products`, `config.subjects.processes`,
545
+ * and `config.subjects.capabilities` (in that order). Subjects with no binding
546
+ * obligation still appear as columns so the gap is visible.
547
+ */
548
+ export function buildImpactMatrix(
549
+ canon: ComplianceCanon,
550
+ config: ImpactViewConfig,
551
+ objectDetails?: ObjectDetailInput[],
552
+ ): ImpactMatrix {
553
+ const index = buildComplianceIndex({
554
+ requirements: canon.requirements,
555
+ assertions: canon.assertions,
556
+ });
557
+
558
+ const obligations = orderRows(resolveObligations(config, index, canon.requirements), config.order_rows_by);
559
+
560
+ const grain = config.grouping?.columns ?? 'product';
561
+ const hasDetails = objectDetails !== undefined && objectDetails.length > 0;
562
+ const columns: ImpactColumn[] =
563
+ grain === 'product-stage-task' && hasDetails
564
+ ? buildTaskColumns(config, objectDetails!)
565
+ : (grain === 'product-stage' || (grain as string) === 'object-details') && hasDetails
566
+ ? buildObjectDetailColumns(config, objectDetails!)
567
+ : buildProductColumns(config, [...(canon.products ?? []), ...(canon.subjects ?? [])]);
568
+
569
+ const allowedStatuses = new Set<AssertionStatus>(
570
+ config.status_display?.show ?? ['compliant', 'partial', 'non_compliant', 'under_review', 'pending_owner', 'n_a'],
571
+ );
572
+
573
+ const rowIndex = new Set(obligations.map(r => r.id));
574
+ const cells: ImpactCell[][] = obligations.map(() => columns.map(() => emptyCell()));
575
+
576
+ for (let c = 0; c < columns.length; c++) {
577
+ const col = columns[c];
578
+ const subjectAssertions = index.assertionsBySubject.get(col.subjectId) ?? [];
579
+ for (const a of subjectAssertions) {
580
+ if (!rowIndex.has(a.about)) continue;
581
+ if (!allowedStatuses.has(a.status)) continue;
582
+ if (!assertionMatchesColumn(a, col)) continue;
583
+ const rowIdx = obligations.findIndex(r => r.id === a.about);
584
+ if (rowIdx < 0) continue;
585
+ cells[rowIdx][c].assertions.push(a);
586
+ }
587
+ }
588
+
589
+ // Resolve each cell to a status / kind.
590
+ for (let r = 0; r < cells.length; r++) {
591
+ for (let c = 0; c < cells[r].length; c++) {
592
+ const cell = cells[r][c];
593
+ cell.assertions.sort(byId);
594
+ if (cell.assertions.length === 0) {
595
+ cell.kind = 'gap';
596
+ cell.status = null;
597
+ continue;
598
+ }
599
+ if (cell.assertions.every(a => a.status === 'n_a')) {
600
+ cell.kind = 'n_a_only';
601
+ cell.status = 'n_a';
602
+ continue;
603
+ }
604
+ cell.kind = 'bound';
605
+ cell.status = aggregateStatus(cell.assertions);
606
+ }
607
+ }
608
+
609
+ // ── CV-3 decorations ────────────────────────────────────────────────────
610
+ const today = new Date().toISOString().slice(0, 10);
611
+ for (let r = 0; r < cells.length; r++) {
612
+ const req = obligations[r];
613
+ const deadlineStatus = computeDeadlineStatus(req.deadline, today);
614
+ // "new" = requirement admitted after the snapshot date.
615
+ const isNew =
616
+ !!config.snapshot_at &&
617
+ !!req.admitted_at &&
618
+ req.admitted_at > config.snapshot_at;
619
+ for (let c = 0; c < cells[r].length; c++) {
620
+ const cell = cells[r][c];
621
+ const isUrgent =
622
+ cell.kind === 'gap' &&
623
+ (deadlineStatus === 'past_due' || deadlineStatus === 'in_force');
624
+ cell.decoration = { isNew, isUrgent, deadlineStatus };
625
+ }
626
+ }
627
+
628
+ // ── CV-3 derived obligations lane ───────────────────────────────────────
629
+ // For each column: collect codex source IDs from non-gap rows.
630
+ const obligationsLane: string[][] = columns.map((_, c) => {
631
+ const codexIds = new Set<string>();
632
+ for (let r = 0; r < cells.length; r++) {
633
+ if (cells[r][c].kind !== 'gap') {
634
+ for (const src of obligations[r].derived_from ?? []) {
635
+ codexIds.add(src);
636
+ }
637
+ }
638
+ }
639
+ return [...codexIds].sort();
640
+ });
641
+
642
+ return {
643
+ viewId: config.id,
644
+ viewName: config.name,
645
+ description: config.description,
646
+ snapshotAt: config.snapshot_at,
647
+ rows: obligations,
648
+ columns,
649
+ cells,
650
+ emptyLabels: {
651
+ no_obligation_label: config.empty_cells?.no_obligation_label ?? DEFAULT_NO_OBLIGATION_LABEL,
652
+ no_obligation_applies_label:
653
+ config.empty_cells?.no_obligation_applies_label ?? DEFAULT_NO_OBLIGATION_APPLIES_LABEL,
654
+ },
655
+ obligationsLane,
656
+ };
657
+ }
658
+
659
+ function emptyCell(): ImpactCell {
660
+ return {
661
+ status: null,
662
+ kind: 'gap',
663
+ assertions: [],
664
+ decoration: { isNew: false, isUrgent: false, deadlineStatus: 'none' },
665
+ };
666
+ }
667
+
668
+ // ── Process-blueprint / process-flow extractors (CV-3a) ─────────────────────
669
+
670
+ /**
671
+ * Extract stage-level `ObjectDetailInput` from a raw (YAML-parsed)
672
+ * process-blueprint document.
673
+ *
674
+ * Accepts both the bare document object and the wrapped form
675
+ * (`{ process_blueprint: { id, stages: […] } }`).
676
+ * Returns `null` when the document is not a recognisable process-blueprint or
677
+ * has no `id` / `stages` array so callers can skip unrecognised files safely.
678
+ *
679
+ * Pass the returned value into `buildImpactMatrix` as part of the
680
+ * `objectDetails` array when `grouping.columns: 'product-stage'` is configured.
681
+ * For `grouping.columns: 'product-stage-task'`, combine with
682
+ * `extractProcessFlowTasks` via `mergeStageTaskDetails`.
683
+ */
684
+ export function extractObjectDetails(doc: unknown): ObjectDetailInput | null {
685
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
686
+ const top = doc as Record<string, unknown>;
687
+
688
+ const bp: Record<string, unknown> | null =
689
+ top.notation === 'process_blueprint'
690
+ ? top
691
+ : top.process_blueprint && typeof top.process_blueprint === 'object' && !Array.isArray(top.process_blueprint)
692
+ ? (top.process_blueprint as Record<string, unknown>)
693
+ : null;
694
+
695
+ if (!bp) return null;
696
+ const id = typeof bp.id === 'string' ? bp.id : null;
697
+ if (!id) return null;
698
+ if (!Array.isArray(bp.stages)) return null;
699
+
700
+ const details: ObjectDetailDef[] = [];
701
+ for (const s of bp.stages) {
702
+ if (!s || typeof s !== 'object' || Array.isArray(s)) continue;
703
+ const sid = typeof s.id === 'string' ? s.id : null;
704
+ if (!sid) continue;
705
+ details.push({ id: sid, name: typeof s.name === 'string' ? s.name : sid });
706
+ }
707
+
708
+ return { objectId: id, details };
709
+ }
710
+
711
+ /**
712
+ * Extract task-type steps from a raw (YAML-parsed) PROCESS element document.
713
+ *
714
+ * Reads `notation: process` documents with an inline `flow.steps[]` array.
715
+ * Filters for task-type nodes (`task`, `userTask`, `serviceTask`) — start/end
716
+ * events and gateways are excluded. Returns a flat `ObjectDetailInput` where
717
+ * `details` are the extracted task steps in declaration order.
718
+ *
719
+ * Returns `null` when the document is not a recognisable process element or
720
+ * has no `id` / `flow.steps` array.
721
+ *
722
+ * Use the returned value with `mergeStageTaskDetails` to embed tasks into
723
+ * blueprint stages for `grouping.columns: product-stage-task`.
724
+ */
725
+ export function extractProcessFlowTasks(doc: unknown): ObjectDetailInput | null {
726
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
727
+ const top = doc as Record<string, unknown>;
728
+ if (top.notation !== 'process') return null;
729
+ const id = typeof top.id === 'string' ? top.id : null;
730
+ if (!id) return null;
731
+ const flow =
732
+ top.flow && typeof top.flow === 'object' && !Array.isArray(top.flow)
733
+ ? (top.flow as Record<string, unknown>)
734
+ : null;
735
+ if (!flow || !Array.isArray(flow.steps)) return null;
736
+
737
+ const TASK_TYPES = new Set(['task', 'userTask', 'serviceTask', 'scriptTask',
738
+ 'receiveTask', 'sendTask', 'manualTask', 'businessRuleTask', 'callActivity', 'subProcess']);
739
+ const details: ObjectDetailDef[] = [];
740
+ for (const step of flow.steps) {
741
+ if (!step || typeof step !== 'object' || Array.isArray(step)) continue;
742
+ const s = step as Record<string, unknown>;
743
+ const stepId = typeof s.id === 'string' ? s.id : null;
744
+ if (!stepId) continue;
745
+ if (!TASK_TYPES.has(s.type as string)) continue;
746
+ details.push({ id: stepId, name: typeof s.name === 'string' ? s.name : stepId });
747
+ }
748
+
749
+ return details.length > 0 ? { objectId: id, details } : null;
750
+ }
751
+
752
+ /**
753
+ * Combine blueprint stage details (from `extractObjectDetails`) with
754
+ * process-flow task details (from `extractProcessFlowTasks`) into a
755
+ * stage-with-tasks hierarchy for `grouping.columns: product-stage-task`.
756
+ *
757
+ * All tasks from `taskInput.details` are distributed into their parent stage
758
+ * by matching the `stageOwner` map: `stageId → taskId[]`. If `stageOwner` is
759
+ * omitted (no stage-to-task mapping is available), all tasks are placed into
760
+ * every stage (`undefined` key `→` all tasks).
761
+ *
762
+ * When `taskInput` is `null` or its `objectId` does not match `stageInput`,
763
+ * returns `stageInput` unchanged (callers can safely chain the result into
764
+ * `buildImpactMatrix` — it will degrade to stage-grain columns).
765
+ *
766
+ * @param stageInput Stage-level detail (from `extractObjectDetails`).
767
+ * @param taskInput Task-level detail (from `extractProcessFlowTasks`).
768
+ * @param stageOwner Optional mapping `stageId → taskId[]`. When provided,
769
+ * only tasks listed for a stage are embedded in it.
770
+ */
771
+ export function mergeStageTaskDetails(
772
+ stageInput: ObjectDetailInput,
773
+ taskInput: ObjectDetailInput | null,
774
+ stageOwner?: Record<string, string[]>,
775
+ ): ObjectDetailInput {
776
+ if (!taskInput || taskInput.objectId !== stageInput.objectId) return stageInput;
777
+ const allTasks = taskInput.details;
778
+ return {
779
+ objectId: stageInput.objectId,
780
+ details: stageInput.details.map(stage => {
781
+ const stageTasks = stageOwner
782
+ ? allTasks.filter(t => (stageOwner[stage.id] ?? []).includes(t.id))
783
+ : allTasks;
784
+ return stageTasks.length > 0 ? { ...stage, tasks: stageTasks } : stage;
785
+ }),
786
+ };
787
+ }
788
+
789
+ // ── Markdown rendering ──────────────────────────────────────────────────────
790
+
791
+ const STATUS_GLYPH: Record<AssertionStatus, string> = {
792
+ compliant: 'OK',
793
+ partial: 'PARTIAL',
794
+ non_compliant: 'FAIL',
795
+ under_review: 'REVIEW',
796
+ pending_owner: 'PENDING',
797
+ n_a: 'N/A',
798
+ };
799
+
800
+ function escMd(s: string): string {
801
+ return s.replace(/\|/g, '\\|');
802
+ }
803
+
804
+ /**
805
+ * Render the matrix as a GitHub-flavoured markdown table.
806
+ *
807
+ * Cell display:
808
+ * - `bound` → the §5.2 deterministic status label.
809
+ * - `n_a_only` → the "No obligation applies" label (or `view.empty_cells`
810
+ * override).
811
+ * - `gap` → the "No mapped obligation (current model)" label (or
812
+ * override).
813
+ */
814
+ export function renderImpactMarkdown(matrix: ImpactMatrix): string {
815
+ const lines: string[] = [];
816
+ lines.push(`# ${matrix.viewName}`);
817
+ lines.push('');
818
+ lines.push(`View ID: \`${matrix.viewId}\``);
819
+ if (matrix.snapshotAt) lines.push(`Report snapshot: ${matrix.snapshotAt}`);
820
+ if (matrix.description) {
821
+ lines.push('');
822
+ lines.push(matrix.description);
823
+ }
824
+ lines.push('');
825
+
826
+ if (matrix.rows.length === 0) {
827
+ lines.push('_No obligations in scope — the configured filter / include selected zero REQUIREMENTs._');
828
+ return lines.join('\n') + '\n';
829
+ }
830
+ if (matrix.columns.length === 0) {
831
+ lines.push('_No subjects in scope — `view.subjects` is empty._');
832
+ return lines.join('\n') + '\n';
833
+ }
834
+
835
+ const header = ['Obligation', ...matrix.columns.map(col => escMd(col.label))];
836
+ lines.push('| ' + header.join(' | ') + ' |');
837
+ lines.push('|' + header.map(() => '---').join('|') + '|');
838
+
839
+ for (let r = 0; r < matrix.rows.length; r++) {
840
+ const row = matrix.rows[r];
841
+ const rowLabel = `${row.id}${row.name && row.name !== row.id ? ` — ${row.name}` : ''}`;
842
+ const cells = matrix.cells[r].map(cell => renderCell(cell, matrix.emptyLabels));
843
+ lines.push('| ' + [escMd(rowLabel), ...cells].join(' | ') + ' |');
844
+ }
845
+
846
+ // Legend — the §5.3 distinction must be visible in the rendered output.
847
+ lines.push('');
848
+ lines.push('## Legend');
849
+ lines.push('');
850
+ lines.push('- **OK / PARTIAL / FAIL / REVIEW / PENDING / N/A** — aggregated `ASSERTION.status` per §5.2.');
851
+ lines.push(`- **${escMd(matrix.emptyLabels.no_obligation_label)}** — modelling gap; no admitted ASSERTION binds this (obligation, subject) pair.`);
852
+ lines.push(`- **${escMd(matrix.emptyLabels.no_obligation_applies_label)}** — modelled fact; an admitted ASSERTION with status \`n_a\` excludes this pair.`);
853
+
854
+ return lines.join('\n') + '\n';
855
+ }
856
+
857
+ function renderCell(cell: ImpactCell, labels: Required<ImpactEmptyCellLabels>): string {
858
+ if (cell.kind === 'gap') return escMd(labels.no_obligation_label);
859
+ if (cell.kind === 'n_a_only') return escMd(labels.no_obligation_applies_label);
860
+ return STATUS_GLYPH[cell.status as AssertionStatus];
861
+ }