@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,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { emptyCanon } from '../classify.js';
3
- import { buildImpactMatrix, renderImpactMarkdown, parseImpactViewConfig, COMPLIANCE_IMPACT_DEFAULTS, extractObjectDetails, computeDeadlineStatus, } from '../impact.js';
3
+ import { buildImpactMatrix, renderImpactMarkdown, parseImpactViewConfig, COMPLIANCE_IMPACT_DEFAULTS, extractObjectDetails, extractProcessFlowTasks, mergeStageTaskDetails, computeDeadlineStatus, } from '../impact.js';
4
4
  // Industry- and regime-agnostic synthetic fixture: opaque ids only, no real
5
5
  // regulator / product names, per #166 acceptance ("Output is aggregates only —
6
6
  // no subject/obligation names tied to any real corpus").
@@ -160,6 +160,94 @@ describe('renderImpactMarkdown', () => {
160
160
  const md = renderImpactMarkdown(matrix);
161
161
  expect(md).toContain('Report snapshot: 2026-06-09');
162
162
  });
163
+ it('resolves capability subject names from canon.subjects', () => {
164
+ const canon = emptyCanon();
165
+ canon.subjects.push({ id: 'CAPABILITY-CRM-1', name: 'CRM Platform' });
166
+ canon.requirements.push({ id: 'REQUIREMENT-FOO-1', name: 'Foo' });
167
+ canon.assertions.push({
168
+ id: 'ASSERTION-1',
169
+ about: 'REQUIREMENT-FOO-1',
170
+ subject: 'CAPABILITY-CRM-1',
171
+ status: 'compliant',
172
+ });
173
+ const matrix = buildImpactMatrix(canon, {
174
+ id: 'V-1', name: 'Test',
175
+ subjects: { capabilities: ['CAPABILITY-CRM-1'] },
176
+ obligations: {},
177
+ });
178
+ expect(matrix.columns[0].subjectId).toBe('CAPABILITY-CRM-1');
179
+ expect(matrix.columns[0].subjectName).toBe('CRM Platform');
180
+ expect(matrix.cells[0][0].status).toBe('compliant');
181
+ });
182
+ it('resolves process subject names from canon.subjects', () => {
183
+ const canon = emptyCanon();
184
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
185
+ canon.requirements.push({ id: 'REQUIREMENT-BAR-1', name: 'Bar' });
186
+ canon.assertions.push({
187
+ id: 'ASSERTION-P1',
188
+ about: 'REQUIREMENT-BAR-1',
189
+ subject: 'PROCESS-CS-1',
190
+ status: 'partial',
191
+ });
192
+ const matrix = buildImpactMatrix(canon, {
193
+ id: 'V-2', name: 'Test',
194
+ subjects: { processes: ['PROCESS-CS-1'] },
195
+ obligations: {},
196
+ });
197
+ expect(matrix.columns[0].subjectName).toBe('Customer Support');
198
+ expect(matrix.cells[0][0].status).toBe('partial');
199
+ });
200
+ it('mixed product + capability columns — names resolved from respective buckets', () => {
201
+ const canon = buildCanon();
202
+ canon.subjects.push({ id: 'CAPABILITY-CRM-1', name: 'CRM' });
203
+ canon.requirements.push({ id: 'REQUIREMENT-CRM-1', name: 'CRM req' });
204
+ canon.assertions.push({
205
+ id: 'ASSERTION-CRM-1',
206
+ about: 'REQUIREMENT-CRM-1',
207
+ subject: 'CAPABILITY-CRM-1',
208
+ status: 'under_review',
209
+ });
210
+ const matrix = buildImpactMatrix(canon, {
211
+ id: 'V-3', name: 'Mixed',
212
+ subjects: { products: ['PRODUCT-A-1'], capabilities: ['CAPABILITY-CRM-1'] },
213
+ obligations: {},
214
+ });
215
+ expect(matrix.columns.map(c => c.subjectId)).toEqual(['PRODUCT-A-1', 'CAPABILITY-CRM-1']);
216
+ expect(matrix.columns[0].subjectName).toBe('Product A');
217
+ expect(matrix.columns[1].subjectName).toBe('CRM');
218
+ });
219
+ it('pending_owner cell renders as bound with PENDING status', () => {
220
+ const canon = emptyCanon();
221
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
222
+ canon.requirements.push({ id: 'REQUIREMENT-PO-1', name: 'PO req' });
223
+ canon.assertions.push({
224
+ id: 'ASSERTION-PO-1',
225
+ about: 'REQUIREMENT-PO-1',
226
+ subject: 'PRODUCT-A-1',
227
+ status: 'pending_owner',
228
+ owner_to_confirm: 'alice@example.com',
229
+ });
230
+ const matrix = buildImpactMatrix(canon, {
231
+ id: 'V-PO', name: 'PO Test',
232
+ subjects: { products: ['PRODUCT-A-1'] },
233
+ obligations: {},
234
+ });
235
+ expect(matrix.cells[0][0].kind).toBe('bound');
236
+ expect(matrix.cells[0][0].status).toBe('pending_owner');
237
+ expect(matrix.cells[0][0].assertions[0].owner_to_confirm).toBe('alice@example.com');
238
+ });
239
+ it('pending_owner loses to partial in aggregation', () => {
240
+ const canon = emptyCanon();
241
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
242
+ canon.requirements.push({ id: 'REQUIREMENT-PO-2', name: 'PO req 2' });
243
+ canon.assertions.push({ id: 'ASSERTION-PO-2a', about: 'REQUIREMENT-PO-2', subject: 'PRODUCT-A-1', status: 'partial' }, { id: 'ASSERTION-PO-2b', about: 'REQUIREMENT-PO-2', subject: 'PRODUCT-A-1', status: 'pending_owner' });
244
+ const matrix = buildImpactMatrix(canon, {
245
+ id: 'V-PO2', name: 'PO Agg',
246
+ subjects: { products: ['PRODUCT-A-1'] },
247
+ obligations: {},
248
+ });
249
+ expect(matrix.cells[0][0].status).toBe('partial');
250
+ });
163
251
  });
164
252
  // ── parseImpactViewConfig ───────────────────────────────────────────────────
165
253
  describe('parseImpactViewConfig', () => {
@@ -193,6 +281,7 @@ describe('parseImpactViewConfig', () => {
193
281
  const c = r.config;
194
282
  expect(c.subjects.products).toEqual([]);
195
283
  expect(c.subjects.processes).toEqual([]);
284
+ expect(c.subjects.capabilities).toEqual([]);
196
285
  expect(c.status_display?.show).toEqual(COMPLIANCE_IMPACT_DEFAULTS.status_display.show);
197
286
  expect(c.order_rows_by).toBe('id');
198
287
  expect(c.empty_cells?.no_obligation_label).toBe(COMPLIANCE_IMPACT_DEFAULTS.empty_cells.no_obligation_label);
@@ -239,7 +328,7 @@ describe('CV-3a — stage grouping (ImpactColumn, extractObjectDetails, buildImp
239
328
  });
240
329
  it('product-stage grain: expands columns by stage, label is subjectId:stageId', () => {
241
330
  const canon = buildCanon();
242
- const matrix = buildImpactMatrix(canon, { ...baseConfig, subjects: { products: ['PRODUCT-A-1'] }, grouping: { columns: 'object-details' } }, [{ objectId: 'PRODUCT-A-1', details: [{ id: 'STAGE-1', name: 'Stage One' }, { id: 'STAGE-2', name: 'Stage Two' }] }]);
331
+ const matrix = buildImpactMatrix(canon, { ...baseConfig, subjects: { products: ['PRODUCT-A-1'] }, grouping: { columns: 'product-stage' } }, [{ objectId: 'PRODUCT-A-1', details: [{ id: 'STAGE-1', name: 'Stage One' }, { id: 'STAGE-2', name: 'Stage Two' }] }]);
243
332
  expect(matrix.columns.map(c => c.label)).toEqual(['PRODUCT-A-1:STAGE-1', 'PRODUCT-A-1:STAGE-2']);
244
333
  expect(matrix.columns.map(c => c.stageId)).toEqual(['STAGE-1', 'STAGE-2']);
245
334
  });
@@ -252,7 +341,7 @@ describe('CV-3a — stage grouping (ImpactColumn, extractObjectDetails, buildImp
252
341
  id: 'V', name: 'V',
253
342
  subjects: { products: ['PROD-1'] },
254
343
  obligations: {},
255
- grouping: { columns: 'object-details' },
344
+ grouping: { columns: 'product-stage' },
256
345
  }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 's1' }, { id: 'S2', name: 's2' }] }]);
257
346
  // Both stage columns should be bound (no realised_via means all stages inherit).
258
347
  expect(matrix.cells[0][0].kind).toBe('bound');
@@ -270,7 +359,7 @@ describe('CV-3a — stage grouping (ImpactColumn, extractObjectDetails, buildImp
270
359
  id: 'V', name: 'V',
271
360
  subjects: { products: ['PROD-1'] },
272
361
  obligations: {},
273
- grouping: { columns: 'object-details' },
362
+ grouping: { columns: 'product-stage' },
274
363
  }, [{ objectId: 'PROD-1', details: [{ id: 'STAGE-A', name: 'a' }, { id: 'STAGE-B', name: 'b' }] }]);
275
364
  expect(matrix.cells[0][0].kind).toBe('bound'); // STAGE-A: covered
276
365
  expect(matrix.cells[0][1].kind).toBe('gap'); // STAGE-B: gap
@@ -280,7 +369,7 @@ describe('CV-3a — stage grouping (ImpactColumn, extractObjectDetails, buildImp
280
369
  const matrix = buildImpactMatrix(canon, {
281
370
  ...baseConfig,
282
371
  subjects: { products: ['PRODUCT-A-1', 'PRODUCT-B-1'] },
283
- grouping: { columns: 'object-details' },
372
+ grouping: { columns: 'product-stage' },
284
373
  },
285
374
  // Only provide stages for A — B falls back to a single column.
286
375
  [{ objectId: 'PRODUCT-A-1', details: [{ id: 'S1', name: 's1' }] }]);
@@ -308,7 +397,7 @@ describe('CV-3a — stage grouping (ImpactColumn, extractObjectDetails, buildImp
308
397
  id: 'V', name: 'Stage Report',
309
398
  subjects: { products: ['PROD-1'] },
310
399
  obligations: {},
311
- grouping: { columns: 'object-details' },
400
+ grouping: { columns: 'product-stage' },
312
401
  }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 'Stage One' }] }]);
313
402
  const md = renderImpactMarkdown(matrix);
314
403
  expect(md).toContain('PROD-1:S1');
@@ -408,3 +497,417 @@ describe('computeDeadlineStatus', () => {
408
497
  expect(computeDeadlineStatus('2026-12-31', '2026-06-01')).toBe('upcoming');
409
498
  });
410
499
  });
500
+ // ── parseImpactViewConfig — grouping field ────────────────────────────────────
501
+ describe('parseImpactViewConfig — grouping field', () => {
502
+ it('parses grouping.columns: product-stage', () => {
503
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'product-stage' } });
504
+ expect(r.ok).toBe(true);
505
+ if (r.ok)
506
+ expect(r.config.grouping?.columns).toBe('product-stage');
507
+ });
508
+ it('parses grouping.columns: product-stage-task', () => {
509
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'product-stage-task' } });
510
+ expect(r.ok).toBe(true);
511
+ if (r.ok)
512
+ expect(r.config.grouping?.columns).toBe('product-stage-task');
513
+ });
514
+ it('parses grouping.columns: product (coarsest grain)', () => {
515
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'product' } });
516
+ expect(r.ok).toBe(true);
517
+ if (r.ok)
518
+ expect(r.config.grouping?.columns).toBe('product');
519
+ });
520
+ it('normalises backward-compat alias object-details → product-stage', () => {
521
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'object-details' } });
522
+ expect(r.ok).toBe(true);
523
+ if (r.ok)
524
+ expect(r.config.grouping?.columns).toBe('product-stage');
525
+ });
526
+ it('normalises backward-compat alias object → product', () => {
527
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'object' } });
528
+ expect(r.ok).toBe(true);
529
+ if (r.ok)
530
+ expect(r.config.grouping?.columns).toBe('product');
531
+ });
532
+ it('leaves grouping undefined when the field is absent', () => {
533
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {} });
534
+ expect(r.ok).toBe(true);
535
+ if (r.ok)
536
+ expect(r.config.grouping).toBeUndefined();
537
+ });
538
+ it('leaves grouping undefined for an unrecognised columns value', () => {
539
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: 'unknown-grain' } });
540
+ expect(r.ok).toBe(true);
541
+ if (r.ok)
542
+ expect(r.config.grouping).toBeUndefined();
543
+ });
544
+ });
545
+ // ── CV-3a — product-stage-task grain ─────────────────────────────────────────
546
+ describe('CV-3a — product-stage-task grain (buildImpactMatrix, buildTaskColumns)', () => {
547
+ it('expands columns to (subject, stage, task) triples; label is subjectId:stageId:taskId', () => {
548
+ const canon = emptyCanon();
549
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
550
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROD-1', status: 'compliant' });
551
+ const matrix = buildImpactMatrix(canon, {
552
+ id: 'V', name: 'V',
553
+ subjects: { products: ['PROD-1'] },
554
+ obligations: {},
555
+ grouping: { columns: 'product-stage-task' },
556
+ }, [{
557
+ objectId: 'PROD-1',
558
+ details: [
559
+ { id: 'STAGE-A', name: 'Stage A', tasks: [{ id: 'TASK-1', name: 'Task 1' }, { id: 'TASK-2', name: 'Task 2' }] },
560
+ ],
561
+ }]);
562
+ expect(matrix.columns.map(c => c.label)).toEqual(['PROD-1:STAGE-A:TASK-1', 'PROD-1:STAGE-A:TASK-2']);
563
+ expect(matrix.columns[0]).toMatchObject({ subjectId: 'PROD-1', stageId: 'STAGE-A', taskId: 'TASK-1' });
564
+ expect(matrix.columns[1]).toMatchObject({ subjectId: 'PROD-1', stageId: 'STAGE-A', taskId: 'TASK-2' });
565
+ });
566
+ it('assertion without realised_via fills every task column for its subject', () => {
567
+ const canon = emptyCanon();
568
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
569
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROD-1', status: 'compliant' });
570
+ const matrix = buildImpactMatrix(canon, {
571
+ id: 'V', name: 'V',
572
+ subjects: { products: ['PROD-1'] },
573
+ obligations: {},
574
+ grouping: { columns: 'product-stage-task' },
575
+ }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 's1', tasks: [{ id: 'T1', name: 't1' }, { id: 'T2', name: 't2' }] }] }]);
576
+ expect(matrix.cells[0][0].kind).toBe('bound');
577
+ expect(matrix.cells[0][1].kind).toBe('bound');
578
+ });
579
+ it('assertion with realised_via: [taskId] fills only the matching task column', () => {
580
+ const canon = emptyCanon();
581
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
582
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROD-1', status: 'compliant', realised_via: ['T1'] });
583
+ const matrix = buildImpactMatrix(canon, {
584
+ id: 'V', name: 'V',
585
+ subjects: { products: ['PROD-1'] },
586
+ obligations: {},
587
+ grouping: { columns: 'product-stage-task' },
588
+ }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 's1', tasks: [{ id: 'T1', name: 't1' }, { id: 'T2', name: 't2' }] }] }]);
589
+ expect(matrix.cells[0][0].kind).toBe('bound'); // T1: covered
590
+ expect(matrix.cells[0][1].kind).toBe('gap'); // T2: not covered
591
+ });
592
+ it('assertion with realised_via: [stageId] fills all task columns in that stage', () => {
593
+ const canon = emptyCanon();
594
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
595
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROD-1', status: 'compliant', realised_via: ['S1'] });
596
+ const matrix = buildImpactMatrix(canon, {
597
+ id: 'V', name: 'V',
598
+ subjects: { products: ['PROD-1'] },
599
+ obligations: {},
600
+ grouping: { columns: 'product-stage-task' },
601
+ }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 's1', tasks: [{ id: 'T1', name: 't1' }, { id: 'T2', name: 't2' }] }] }]);
602
+ expect(matrix.cells[0][0].kind).toBe('bound');
603
+ expect(matrix.cells[0][1].kind).toBe('bound');
604
+ });
605
+ it('stage with no tasks falls back to stage-grain column', () => {
606
+ const canon = emptyCanon();
607
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
608
+ const matrix = buildImpactMatrix(canon, {
609
+ id: 'V', name: 'V',
610
+ subjects: { products: ['PROD-1'] },
611
+ obligations: {},
612
+ grouping: { columns: 'product-stage-task' },
613
+ }, [{ objectId: 'PROD-1', details: [{ id: 'S1', name: 's1' }] }]);
614
+ expect(matrix.columns.map(c => c.label)).toEqual(['PROD-1:S1']);
615
+ expect(matrix.columns[0].taskId).toBeUndefined();
616
+ expect(matrix.columns[0].stageId).toBe('S1');
617
+ });
618
+ it('subject with no objectDetails falls back to product-grain column', () => {
619
+ const canon = emptyCanon();
620
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
621
+ const matrix = buildImpactMatrix(canon, {
622
+ id: 'V', name: 'V',
623
+ subjects: { products: ['PROD-1'] },
624
+ obligations: {},
625
+ grouping: { columns: 'product-stage-task' },
626
+ });
627
+ expect(matrix.columns.map(c => c.label)).toEqual(['PROD-1']);
628
+ expect(matrix.columns[0].stageId).toBeUndefined();
629
+ });
630
+ });
631
+ // ── extractProcessFlowTasks ───────────────────────────────────────────────────
632
+ describe('extractProcessFlowTasks', () => {
633
+ it('returns null for non-process documents', () => {
634
+ expect(extractProcessFlowTasks(null)).toBeNull();
635
+ expect(extractProcessFlowTasks({ notation: 'requirement', id: 'R1' })).toBeNull();
636
+ expect(extractProcessFlowTasks({ notation: 'process_blueprint', id: 'BP-1' })).toBeNull();
637
+ });
638
+ it('returns null when process has no flow.steps', () => {
639
+ expect(extractProcessFlowTasks({ notation: 'process', id: 'PROC-1' })).toBeNull();
640
+ expect(extractProcessFlowTasks({ notation: 'process', id: 'PROC-1', flow: { sequence: [] } })).toBeNull();
641
+ });
642
+ it('returns null when no task-type steps exist', () => {
643
+ const doc = {
644
+ notation: 'process', id: 'PROC-1',
645
+ flow: { steps: [
646
+ { id: 'S-1', type: 'startEvent' },
647
+ { id: 'G-1', type: 'exclusiveGateway' },
648
+ { id: 'E-1', type: 'endEvent' },
649
+ ] },
650
+ };
651
+ expect(extractProcessFlowTasks(doc)).toBeNull();
652
+ });
653
+ it('extracts task-type steps and skips events/gateways', () => {
654
+ const doc = {
655
+ notation: 'process', id: 'PROC-1',
656
+ flow: { steps: [
657
+ { id: 'S-1', type: 'startEvent' },
658
+ { id: 'STEP-T1', type: 'task', name: 'Task One' },
659
+ { id: 'G-1', type: 'exclusiveGateway', name: 'Branch?' },
660
+ { id: 'STEP-T2', type: 'userTask', name: 'User Task' },
661
+ { id: 'E-1', type: 'endEvent' },
662
+ ] },
663
+ };
664
+ const result = extractProcessFlowTasks(doc);
665
+ expect(result).not.toBeNull();
666
+ expect(result.objectId).toBe('PROC-1');
667
+ expect(result.details.map(d => d.id)).toEqual(['STEP-T1', 'STEP-T2']);
668
+ expect(result.details[0].name).toBe('Task One');
669
+ });
670
+ it('falls back to step id as name when name is absent', () => {
671
+ const doc = {
672
+ notation: 'process', id: 'PROC-1',
673
+ flow: { steps: [{ id: 'STEP-T1', type: 'serviceTask' }] },
674
+ };
675
+ const result = extractProcessFlowTasks(doc);
676
+ expect(result.details[0].name).toBe('STEP-T1');
677
+ });
678
+ });
679
+ // ── mergeStageTaskDetails ─────────────────────────────────────────────────────
680
+ describe('mergeStageTaskDetails', () => {
681
+ const stages = {
682
+ objectId: 'PROC-1',
683
+ details: [
684
+ { id: 'S1', name: 'Stage 1' },
685
+ { id: 'S2', name: 'Stage 2' },
686
+ ],
687
+ };
688
+ it('returns stageInput unchanged when taskInput is null', () => {
689
+ expect(mergeStageTaskDetails(stages, null)).toBe(stages);
690
+ });
691
+ it('returns stageInput unchanged when objectIds do not match', () => {
692
+ const other = { objectId: 'PROC-99', details: [{ id: 'T1', name: 't1' }] };
693
+ expect(mergeStageTaskDetails(stages, other)).toBe(stages);
694
+ });
695
+ it('embeds all tasks into every stage when no stageOwner map is provided', () => {
696
+ const tasks = { objectId: 'PROC-1', details: [{ id: 'T1', name: 't1' }, { id: 'T2', name: 't2' }] };
697
+ const merged = mergeStageTaskDetails(stages, tasks);
698
+ expect(merged.objectId).toBe('PROC-1');
699
+ expect(merged.details[0].tasks?.map(t => t.id)).toEqual(['T1', 'T2']);
700
+ expect(merged.details[1].tasks?.map(t => t.id)).toEqual(['T1', 'T2']);
701
+ });
702
+ it('distributes tasks by stageOwner map', () => {
703
+ const tasks = { objectId: 'PROC-1', details: [{ id: 'T1', name: 't1' }, { id: 'T2', name: 't2' }] };
704
+ const merged = mergeStageTaskDetails(stages, tasks, { S1: ['T1'], S2: ['T2'] });
705
+ expect(merged.details[0].tasks?.map(t => t.id)).toEqual(['T1']);
706
+ expect(merged.details[1].tasks?.map(t => t.id)).toEqual(['T2']);
707
+ });
708
+ it('leaves stages without matching stageOwner entries task-free', () => {
709
+ const tasks = { objectId: 'PROC-1', details: [{ id: 'T1', name: 't1' }] };
710
+ const merged = mergeStageTaskDetails(stages, tasks, { S1: ['T1'], S2: [] });
711
+ expect(merged.details[0].tasks?.map(t => t.id)).toEqual(['T1']);
712
+ // S2 has no tasks in stageOwner, filter returns [] → no tasks embedded
713
+ expect(merged.details[1].tasks).toBeUndefined();
714
+ });
715
+ });
716
+ // ── COMPIMP-010 — subject type label invariant (#345) ────────────────────────
717
+ describe('COMPIMP-010 — subject type label invariant (#345)', () => {
718
+ it('process-only view: columns carry subjectType=process and plain ID labels (regression)', () => {
719
+ const canon = emptyCanon();
720
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
721
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
722
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PROCESS-CS-1', status: 'compliant' });
723
+ const matrix = buildImpactMatrix(canon, {
724
+ id: 'V', name: 'Process-only',
725
+ subjects: { processes: ['PROCESS-CS-1'] },
726
+ obligations: {},
727
+ });
728
+ expect(matrix.columns).toHaveLength(1);
729
+ expect(matrix.columns[0].subjectType).toBe('process');
730
+ // Process-only: label is just the ID — no [PRODUCT] prefix (regression)
731
+ expect(matrix.columns[0].label).toBe('PROCESS-CS-1');
732
+ expect(matrix.columns[0].label).not.toContain('[PRODUCT]');
733
+ expect(matrix.cells[0][0].status).toBe('compliant');
734
+ });
735
+ it('product-only view: columns carry subjectType=product and plain ID labels', () => {
736
+ const canon = emptyCanon();
737
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
738
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
739
+ canon.assertions.push({ id: 'ASS-1', about: 'REQ-1', subject: 'PRODUCT-A-1', status: 'compliant' });
740
+ const matrix = buildImpactMatrix(canon, {
741
+ id: 'V', name: 'Product-only',
742
+ subjects: { products: ['PRODUCT-A-1'] },
743
+ obligations: {},
744
+ });
745
+ expect(matrix.columns[0].subjectType).toBe('product');
746
+ expect(matrix.columns[0].label).toBe('PRODUCT-A-1');
747
+ expect(matrix.columns[0].label).not.toContain('[PROCESS]');
748
+ expect(matrix.findings).toHaveLength(0);
749
+ });
750
+ it('combined view: product columns carry [PRODUCT] badge; process columns carry [PROCESS] badge', () => {
751
+ const canon = emptyCanon();
752
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
753
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
754
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
755
+ const matrix = buildImpactMatrix(canon, {
756
+ id: 'V', name: 'Combined',
757
+ subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
758
+ obligations: {},
759
+ });
760
+ expect(matrix.columns).toHaveLength(2);
761
+ const prodCol = matrix.columns.find(c => c.subjectId === 'PRODUCT-A-1');
762
+ const procCol = matrix.columns.find(c => c.subjectId === 'PROCESS-CS-1');
763
+ expect(prodCol.subjectType).toBe('product');
764
+ expect(prodCol.label).toBe('[PRODUCT] PRODUCT-A-1');
765
+ expect(procCol.subjectType).toBe('process');
766
+ expect(procCol.label).toBe('[PROCESS] PROCESS-CS-1');
767
+ });
768
+ it('combined view: markdown renders [PRODUCT] / [PROCESS] column headers', () => {
769
+ const canon = emptyCanon();
770
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
771
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
772
+ canon.requirements.push({ id: 'REQ-1', name: 'R1' });
773
+ const matrix = buildImpactMatrix(canon, {
774
+ id: 'V', name: 'Combined',
775
+ subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
776
+ obligations: {},
777
+ });
778
+ const md = renderImpactMarkdown(matrix);
779
+ expect(md).toContain('[PRODUCT] PRODUCT-A-1');
780
+ expect(md).toContain('[PROCESS] PROCESS-CS-1');
781
+ });
782
+ it('capability column always has subjectType=capability', () => {
783
+ const canon = emptyCanon();
784
+ canon.subjects.push({ id: 'CAPABILITY-CRM-1', name: 'CRM' });
785
+ const matrix = buildImpactMatrix(canon, {
786
+ id: 'V', name: 'Cap',
787
+ subjects: { capabilities: ['CAPABILITY-CRM-1'] },
788
+ obligations: {},
789
+ });
790
+ expect(matrix.columns[0].subjectType).toBe('capability');
791
+ });
792
+ it('COMPIMP-009 warning: product-* grouping with process-only subjects', () => {
793
+ const canon = emptyCanon();
794
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
795
+ const matrix = buildImpactMatrix(canon, {
796
+ id: 'V', name: 'V',
797
+ subjects: { processes: ['PROCESS-CS-1'] },
798
+ obligations: {},
799
+ grouping: { columns: 'product' },
800
+ });
801
+ const c009 = matrix.findings.find(f => f.code === 'COMPIMP-009');
802
+ expect(c009).toBeDefined();
803
+ expect(c009.severity).toBe('warning');
804
+ expect(c009.message).toContain('process');
805
+ });
806
+ it('COMPIMP-010 error: product-stage grouping with process subjects', () => {
807
+ const canon = emptyCanon();
808
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
809
+ const matrix = buildImpactMatrix(canon, {
810
+ id: 'V', name: 'V',
811
+ subjects: { processes: ['PROCESS-CS-1'] },
812
+ obligations: {},
813
+ grouping: { columns: 'product-stage' },
814
+ }, [{ objectId: 'PROCESS-CS-1', details: [{ id: 'STAGE-A', name: 'Stage A' }] }]);
815
+ const c010 = matrix.findings.find(f => f.code === 'COMPIMP-010');
816
+ expect(c010).toBeDefined();
817
+ expect(c010.severity).toBe('error');
818
+ expect(c010.message).toContain('process-stage');
819
+ });
820
+ it('no COMPIMP-010 when process-stage grouping is used for process subjects', () => {
821
+ const canon = emptyCanon();
822
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'CS' });
823
+ const matrix = buildImpactMatrix(canon, {
824
+ id: 'V', name: 'V',
825
+ subjects: { processes: ['PROCESS-CS-1'] },
826
+ obligations: {},
827
+ grouping: { columns: 'process-stage' },
828
+ }, [{ objectId: 'PROCESS-CS-1', details: [{ id: 'STAGE-A', name: 'Stage A' }] }]);
829
+ expect(matrix.findings.some(f => f.code === 'COMPIMP-010')).toBe(false);
830
+ });
831
+ it('parseImpactViewConfig accepts process-centric column values', () => {
832
+ for (const col of ['process', 'process-stage', 'process-stage-task']) {
833
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', subjects: {}, grouping: { columns: col } });
834
+ expect(r.ok).toBe(true);
835
+ if (r.ok)
836
+ expect(r.config.grouping?.columns).toBe(col);
837
+ }
838
+ });
839
+ it('matrix.findings is empty for a conformant product-only view', () => {
840
+ const canon = emptyCanon();
841
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
842
+ const matrix = buildImpactMatrix(canon, {
843
+ id: 'V', name: 'V',
844
+ subjects: { products: ['PRODUCT-A-1'] },
845
+ obligations: {},
846
+ });
847
+ expect(matrix.findings).toEqual([]);
848
+ });
849
+ it('COMPIMP-011 error: report_type=product with subjects.processes strips process columns', () => {
850
+ const canon = emptyCanon();
851
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
852
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
853
+ const matrix = buildImpactMatrix(canon, {
854
+ id: 'V', name: 'V',
855
+ report_type: 'product',
856
+ subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
857
+ obligations: {},
858
+ });
859
+ // COMPIMP-011 fires
860
+ const c011 = matrix.findings.find(f => f.code === 'COMPIMP-011');
861
+ expect(c011).toBeDefined();
862
+ expect(c011.severity).toBe('error');
863
+ // Spurious PROCESS column is stripped
864
+ expect(matrix.columns).toHaveLength(1);
865
+ expect(matrix.columns[0].subjectType).toBe('product');
866
+ expect(matrix.columns[0].subjectId).toBe('PRODUCT-A-1');
867
+ });
868
+ it('COMPIMP-011 error: report_type=process with subjects.products strips product columns', () => {
869
+ const canon = emptyCanon();
870
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
871
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
872
+ const matrix = buildImpactMatrix(canon, {
873
+ id: 'V', name: 'V',
874
+ report_type: 'process',
875
+ subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
876
+ obligations: {},
877
+ });
878
+ const c011 = matrix.findings.find(f => f.code === 'COMPIMP-011');
879
+ expect(c011).toBeDefined();
880
+ expect(c011.severity).toBe('error');
881
+ // Spurious PRODUCT column is stripped
882
+ expect(matrix.columns).toHaveLength(1);
883
+ expect(matrix.columns[0].subjectType).toBe('process');
884
+ expect(matrix.columns[0].subjectId).toBe('PROCESS-CS-1');
885
+ });
886
+ it('report_type=combined allows both product and process columns without COMPIMP-011', () => {
887
+ const canon = emptyCanon();
888
+ canon.products.push({ id: 'PRODUCT-A-1', name: 'Product A' });
889
+ canon.subjects.push({ id: 'PROCESS-CS-1', name: 'Customer Support' });
890
+ const matrix = buildImpactMatrix(canon, {
891
+ id: 'V', name: 'V',
892
+ report_type: 'combined',
893
+ subjects: { products: ['PRODUCT-A-1'], processes: ['PROCESS-CS-1'] },
894
+ obligations: {},
895
+ });
896
+ expect(matrix.findings.some(f => f.code === 'COMPIMP-011')).toBe(false);
897
+ expect(matrix.columns).toHaveLength(2);
898
+ });
899
+ it('parseImpactViewConfig round-trips report_type', () => {
900
+ for (const rt of ['product', 'process', 'combined']) {
901
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', report_type: rt, subjects: {}, obligations: {} });
902
+ expect(r.ok).toBe(true);
903
+ if (r.ok)
904
+ expect(r.config.report_type).toBe(rt);
905
+ }
906
+ });
907
+ it('parseImpactViewConfig ignores unknown report_type values', () => {
908
+ const r = parseImpactViewConfig({ id: 'V', name: 'V', report_type: 'unknown-type', subjects: {}, obligations: {} });
909
+ expect(r.ok).toBe(true);
910
+ if (r.ok)
911
+ expect(r.config.report_type).toBeUndefined();
912
+ });
913
+ });
@@ -15,6 +15,12 @@ export interface ComplianceCanon {
15
15
  requirements: IndexRequirement[];
16
16
  assertions: IndexAssertion[];
17
17
  codex: ComplianceCodexDoc[];
18
+ /**
19
+ * Named elements that can serve as assertion subjects beyond `products`:
20
+ * capabilities, processes, applications, and systems. Populated by
21
+ * `ingestComplianceDoc` for the corresponding notation values.
22
+ */
23
+ subjects: ComplianceProduct[];
18
24
  }
19
25
  export declare function emptyCanon(): ComplianceCanon;
20
26
  /**
@@ -3,7 +3,7 @@
3
3
  // (webview previews) and the CLI scan (`export-compliance`), so the recognition
4
4
  // rules live once. Pure: takes a parsed YAML document, no IO.
5
5
  export function emptyCanon() {
6
- return { products: [], requirements: [], assertions: [], codex: [] };
6
+ return { products: [], requirements: [], assertions: [], codex: [], subjects: [] };
7
7
  }
8
8
  const str = (v) => (typeof v === 'string' ? v : undefined);
9
9
  const strArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined;
@@ -25,6 +25,13 @@ export function ingestComplianceDoc(canon, doc) {
25
25
  canon.products.push({ id, name: str(d.name) ?? id });
26
26
  return id;
27
27
  }
28
+ if (d.notation === 'capability' ||
29
+ d.notation === 'process' ||
30
+ d.notation === 'application' ||
31
+ d.notation === 'system') {
32
+ canon.subjects.push({ id, name: str(d.name) ?? id });
33
+ return id;
34
+ }
28
35
  if (d.notation === 'requirement') {
29
36
  canon.requirements.push({
30
37
  id,
@@ -49,6 +56,7 @@ export function ingestComplianceDoc(canon, doc) {
49
56
  evidenceCount: Array.isArray(d.evidence) ? d.evidence.length : 0,
50
57
  admitted_at: str(d.admitted_at),
51
58
  realised_via: strArray(d.realised_via),
59
+ owner_to_confirm: str(d.owner_to_confirm),
52
60
  });
53
61
  return id;
54
62
  }