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