@transitrix/diagrams 1.0.1 → 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 (115) hide show
  1. package/dist/activities/cpm.js +11 -2
  2. package/dist/activities/gantt.js +6 -3
  3. package/dist/activities/types.d.ts +1 -0
  4. package/dist/activities/validate.js +3 -3
  5. package/dist/activity-card/__tests__/resolver.test.js +22 -1
  6. package/dist/activity-card/resolver.js +11 -5
  7. package/dist/compliance/__tests__/impact.test.js +198 -0
  8. package/dist/compliance/html.js +1 -7
  9. package/dist/compliance/impact.d.ts +24 -1
  10. package/dist/compliance/impact.js +97 -21
  11. package/dist/factor/__tests__/validate.test.js +3 -3
  12. package/dist/factor/types.d.ts +1 -1
  13. package/dist/factor/validate.js +2 -2
  14. package/dist/fgca/__tests__/chain-table.test.js +2 -2
  15. package/dist/fgca/__tests__/layout.test.js +11 -11
  16. package/dist/fgca/__tests__/preview-layout.test.js +7 -7
  17. package/dist/fgca/__tests__/resolver.test.js +2 -2
  18. package/dist/fgca/chain-table.d.ts +1 -1
  19. package/dist/fgca/chain-table.js +4 -4
  20. package/dist/fgca/index.d.ts +2 -2
  21. package/dist/fgca/index.js +1 -1
  22. package/dist/fgca/layout.d.ts +2 -2
  23. package/dist/fgca/layout.js +9 -9
  24. package/dist/fgca/nodeTypes.d.ts +1 -1
  25. package/dist/fgca/nodeTypes.js +2 -2
  26. package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
  27. package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
  28. package/dist/fgca/parse-canonical.js +15 -10
  29. package/dist/fgca/preview-layout.d.ts +1 -1
  30. package/dist/fgca/preview-layout.js +4 -4
  31. package/dist/fgca/resolver.js +3 -1
  32. package/dist/fgca/types.d.ts +11 -11
  33. package/dist/fgca/types.js +2 -2
  34. package/dist/fgca/validate.d.ts +2 -2
  35. package/dist/fgca/validate.js +17 -14
  36. package/dist/process-blueprint/index.d.ts +1 -1
  37. package/dist/process-blueprint/layout.js +64 -37
  38. package/dist/process-blueprint/types.d.ts +14 -0
  39. package/dist/scenarios/index.d.ts +1 -1
  40. package/dist/scenarios/types.d.ts +4 -4
  41. package/dist/theme/themes.js +2 -0
  42. package/dist/theme/tokens.d.ts +7 -1
  43. package/dist/theme/tokens.js +3 -1
  44. package/dist/webview/__tests__/entry-notations.test.js +2 -0
  45. package/dist/webview/__tests__/render-process.test.d.ts +1 -0
  46. package/dist/webview/__tests__/render-process.test.js +463 -0
  47. package/dist/webview/__tests__/render-util.test.d.ts +1 -0
  48. package/dist/webview/__tests__/render-util.test.js +54 -0
  49. package/dist/webview/entry.d.ts +1 -1
  50. package/dist/webview/entry.js +9 -5
  51. package/dist/webview/render-activities.d.ts +21 -1
  52. package/dist/webview/render-activities.js +91 -56
  53. package/dist/webview/render-activity-card.d.ts +11 -1
  54. package/dist/webview/render-activity-card.js +11 -2
  55. package/dist/webview/render-blocks.d.ts +24 -1
  56. package/dist/webview/render-blocks.js +58 -24
  57. package/dist/webview/render-fgca.d.ts +27 -0
  58. package/dist/webview/render-fgca.js +29 -15
  59. package/dist/webview/render-goals.d.ts +26 -1
  60. package/dist/webview/render-goals.js +30 -20
  61. package/dist/webview/render-process-blueprint.d.ts +11 -1
  62. package/dist/webview/render-process-blueprint.js +118 -53
  63. package/dist/webview/render-process.d.ts +95 -0
  64. package/dist/webview/render-process.js +392 -0
  65. package/dist/webview/render-scenarios.js +2 -2
  66. package/dist/webview/render-util.d.ts +19 -6
  67. package/dist/webview/render-util.js +23 -15
  68. package/package.json +15 -1
  69. package/src/activities/cpm.ts +14 -2
  70. package/src/activities/gantt.ts +6 -5
  71. package/src/activities/types.ts +1 -0
  72. package/src/activities/validate.ts +3 -3
  73. package/src/activity-card/__tests__/resolver.test.ts +24 -1
  74. package/src/activity-card/resolver.ts +12 -5
  75. package/src/compliance/__tests__/impact.test.ts +219 -0
  76. package/src/compliance/html.ts +1 -8
  77. package/src/compliance/impact.ts +141 -23
  78. package/src/factor/__tests__/validate.test.ts +3 -3
  79. package/src/factor/types.ts +1 -1
  80. package/src/factor/validate.ts +2 -2
  81. package/src/fgca/__tests__/chain-table.test.ts +2 -2
  82. package/src/fgca/__tests__/layout.test.ts +13 -13
  83. package/src/fgca/__tests__/preview-layout.test.ts +7 -7
  84. package/src/fgca/__tests__/resolver.test.ts +2 -2
  85. package/src/fgca/chain-table.ts +5 -5
  86. package/src/fgca/index.ts +3 -3
  87. package/src/fgca/layout.ts +11 -11
  88. package/src/fgca/nodeTypes.ts +3 -3
  89. package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
  90. package/src/fgca/parse-canonical.ts +17 -12
  91. package/src/fgca/preview-layout.ts +5 -5
  92. package/src/fgca/resolver.ts +3 -1
  93. package/src/fgca/types.ts +13 -13
  94. package/src/fgca/validate.ts +17 -14
  95. package/src/process-blueprint/index.ts +1 -0
  96. package/src/process-blueprint/layout.ts +71 -42
  97. package/src/process-blueprint/types.ts +15 -0
  98. package/src/scenarios/index.ts +2 -2
  99. package/src/scenarios/types.ts +4 -4
  100. package/src/scenarios/validate.ts +3 -3
  101. package/src/theme/themes.ts +2 -0
  102. package/src/theme/tokens.ts +3 -1
  103. package/src/webview/__tests__/entry-notations.test.ts +2 -0
  104. package/src/webview/__tests__/render-process.test.ts +538 -0
  105. package/src/webview/__tests__/render-util.test.ts +66 -0
  106. package/src/webview/entry.ts +11 -5
  107. package/src/webview/render-activities.ts +107 -67
  108. package/src/webview/render-activity-card.ts +11 -2
  109. package/src/webview/render-blocks.ts +78 -28
  110. package/src/webview/render-fgca.ts +52 -28
  111. package/src/webview/render-goals.ts +45 -23
  112. package/src/webview/render-process-blueprint.ts +156 -82
  113. package/src/webview/render-process.ts +507 -0
  114. package/src/webview/render-scenarios.ts +3 -3
  115. package/src/webview/render-util.ts +27 -14
@@ -2,6 +2,15 @@ export function computeCpm(activities) {
2
2
  const result = new Map();
3
3
  if (activities.length === 0)
4
4
  return result;
5
+ // When no activity carries any duration data, critical path is indeterminate —
6
+ // return all non-critical so the diagram renders in default grey.
7
+ const hasDuration = activities.some((a) => (a.duration !== undefined && a.duration !== null) || (a.duration_days !== undefined && a.duration_days !== null));
8
+ if (!hasDuration) {
9
+ for (const a of activities) {
10
+ result.set(a.id, { es: 0, ef: 0, ls: 0, lf: 0, slack: 0, isCritical: false });
11
+ }
12
+ return result;
13
+ }
5
14
  // Build successor map and in-degree for topological sort
6
15
  const successors = new Map();
7
16
  const predecessors = new Map();
@@ -41,7 +50,7 @@ export function computeCpm(activities) {
41
50
  const ef = new Map();
42
51
  for (const id of topoOrder) {
43
52
  const a = actById.get(id);
44
- const dur = a.duration ?? 0;
53
+ const dur = a.duration ?? a.duration_days ?? 0;
45
54
  const maxPredEf = Math.max(0, ...(predecessors.get(id) ?? []).map(p => ef.get(p) ?? 0));
46
55
  es.set(id, maxPredEf);
47
56
  ef.set(id, maxPredEf + dur);
@@ -53,7 +62,7 @@ export function computeCpm(activities) {
53
62
  const lf = new Map();
54
63
  for (const id of [...topoOrder].reverse()) {
55
64
  const a = actById.get(id);
56
- const dur = a.duration ?? 0;
65
+ const dur = a.duration ?? a.duration_days ?? 0;
57
66
  const succs = successors.get(id) ?? [];
58
67
  const minSuccLs = succs.length === 0 ? projectFinish : Math.min(...succs.map(s => ls.get(s) ?? projectFinish));
59
68
  lf.set(id, minSuccLs);
@@ -86,7 +86,10 @@ function detectMode(doc, leaves) {
86
86
  if (allPinned)
87
87
  return 'pinned';
88
88
  const hasProjectStart = typeof doc.project?.start_date === 'string' && doc.project.start_date.length > 0;
89
- const allHaveDuration = leaves.every((a) => typeof a.duration === 'number' && Number.isFinite(a.duration) && a.duration >= 0);
89
+ const allHaveDuration = leaves.every((a) => {
90
+ const d = a.duration ?? a.duration_days;
91
+ return typeof d === 'number' && Number.isFinite(d) && d >= 0;
92
+ });
90
93
  if (hasProjectStart && allHaveDuration)
91
94
  return 'computed';
92
95
  return null;
@@ -111,7 +114,7 @@ export function computeGanttLayout(doc) {
111
114
  for (const a of leaves) {
112
115
  const startDate = a.start_date;
113
116
  const endDate = a.end_date;
114
- const isMilestone = a.duration === 0 || startDate === endDate;
117
+ const isMilestone = (a.duration ?? a.duration_days) === 0 || startDate === endDate;
115
118
  const bar = {
116
119
  id: a.id,
117
120
  name: a.name,
@@ -134,7 +137,7 @@ export function computeGanttLayout(doc) {
134
137
  for (const a of leaves) {
135
138
  const c = cpm.get(a.id);
136
139
  const es = c?.es ?? 0;
137
- const dur = a.duration ?? 0;
140
+ const dur = a.duration ?? a.duration_days ?? 0;
138
141
  const startDate = nthWorkingDay(projectStart, es, calendar);
139
142
  const endDate = dur === 0
140
143
  ? startDate
@@ -6,6 +6,7 @@ export interface Activity {
6
6
  id: string;
7
7
  name: string;
8
8
  duration?: number;
9
+ duration_days?: number;
9
10
  activity_type?: string;
10
11
  goals?: string[];
11
12
  scenario?: string;
@@ -113,7 +113,7 @@ export function validateActivities(input) {
113
113
  idSet.add(id);
114
114
  }
115
115
  // ACT-009: non-negative numeric fields
116
- const numericFields = ['duration', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'];
116
+ const numericFields = ['duration', 'duration_days', 'labor_cost', 'resources_cost', 'effort', 'score', 'sort'];
117
117
  for (const field of numericFields) {
118
118
  const val = act[field];
119
119
  if (val !== undefined && val !== null) {
@@ -122,8 +122,8 @@ export function validateActivities(input) {
122
122
  }
123
123
  }
124
124
  }
125
- // ACT-011: warn if no duration
126
- if (act.duration === undefined || act.duration === null) {
125
+ // ACT-011: warn if no duration (duration_days is accepted as an alias)
126
+ if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
127
127
  warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
128
128
  }
129
129
  }
@@ -38,7 +38,7 @@ const UNRELATED = {
38
38
  name: 'Other',
39
39
  parent: 'ACTIVITY-SOMETHING-ELSE-1',
40
40
  };
41
- const FACTOR = { notation: 'factor', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
41
+ const FACTOR = { notation: 'driver', id: 'FACTOR-EU-MDR-1', name: 'EU MDR regulation' };
42
42
  const GOAL = { notation: 'goal', id: 'GOAL-EU-MARKET-1', name: 'Access EU market', factors: ['FACTOR-EU-MDR-1'] };
43
43
  const CHANGE = {
44
44
  notation: 'change',
@@ -212,6 +212,27 @@ describe('resolveActivityCard', () => {
212
212
  expect(r.valid).toBe(false);
213
213
  expect(r.errors.some((e) => e.code === 'PC-001')).toBe(true);
214
214
  });
215
+ it('PC-001 diagnostic discloses paths searched and actionable hint (#344)', () => {
216
+ const r = resolveActivityCard(CARD, { elements: [FACTOR, GOAL, CHANGE], relations });
217
+ const pc001 = r.errors.find((e) => e.code === 'PC-001');
218
+ expect(pc001).toBeDefined();
219
+ // Must disclose both searched paths (§7 PC-001)
220
+ expect(pc001.message).toContain('canon/elements/**');
221
+ expect(pc001.message).toContain('canon/views/activities/**');
222
+ // Must include actionable hint with expected file pattern (§7 PC-001)
223
+ expect(pc001.message).toContain('notation: activity');
224
+ expect(pc001.message).toContain(`id: "${CARD.activity_card.project}"`);
225
+ });
226
+ it('resolves without PC-001 when project activity lives in a deep canon/elements subfolder (#344)', () => {
227
+ // Regression: the resolver must accept elements regardless of which subfolder
228
+ // they were loaded from (e.g. canon/elements/05_implementation/activities/).
229
+ // The resolver is filesystem-free, so we simulate the deep-path scenario by
230
+ // including the PROJECT element in the elements pool — just as loadCanon does
231
+ // after walking canon/elements/** recursively.
232
+ const r = resolveActivityCard(CARD, sources);
233
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
234
+ expect(r.errors.some((e) => e.code === 'PC-001')).toBe(false);
235
+ });
215
236
  it('resolves activity_type and status from the Activity element', () => {
216
237
  const r = resolveActivityCard(CARD, {
217
238
  elements: [{ ...PROJECT, activity_type: 'programme', status: 'on_track' }, CHILD, FACTOR, GOAL, CHANGE],
@@ -148,11 +148,16 @@ export function resolveActivityCard(doc, sources) {
148
148
  }
149
149
  const activities = collectByNotation(sources.elements, 'activity');
150
150
  const projectRec = activities.get(projectId);
151
- // PC-001 — project must resolve to an admitted ACTIVITY element in canon.
151
+ // PC-001 — project must resolve to an admitted ACTIVITY element after
152
+ // exhausting the canonical resolution scope (§6.1): canon/elements/**
153
+ // recursively first, then canon/views/activities/** as secondary fallback.
152
154
  if (!projectRec) {
153
155
  errors.push({
154
156
  code: 'PC-001',
155
- message: `activity_card.project "${projectId}" does not resolve to an ACTIVITY element in the canon element store`,
157
+ message: `activity_card.project "${projectId}" not found after searching ` +
158
+ `canon/elements/** and canon/views/activities/** — ` +
159
+ `add a YAML file with notation: activity and id: "${projectId}" ` +
160
+ `(e.g. canon/elements/05_implementation/activities/${projectId}.yaml)`,
156
161
  });
157
162
  return { valid: false, errors, warnings };
158
163
  }
@@ -247,9 +252,10 @@ export function resolveActivityCard(doc, sources) {
247
252
  return { valid: false, errors, warnings };
248
253
  }
249
254
  // ── Motivation chain — expand against canon DRIVER / GOAL / CHANGE elements ──
250
- // Note: YAML corpus still uses `notation: factor` and `goal.factors` field names;
251
- // the YAML keys are stable. The resolved TypeScript API uses "driver/driverIds".
252
- const driverElems = collectByNotation(sources.elements, 'factor');
255
+ // Canon elements use `notation: driver`; `notation: factor` accepted for backward compat.
256
+ const driverElemsLegacy = collectByNotation(sources.elements, 'factor');
257
+ const driverElemsNew = collectByNotation(sources.elements, 'driver');
258
+ const driverElems = new Map([...driverElemsLegacy, ...driverElemsNew]);
253
259
  const goalElems = collectByNotation(sources.elements, 'goal');
254
260
  const changeElems = collectByNotation(sources.elements, 'change');
255
261
  const changes = [];
@@ -713,3 +713,201 @@ describe('mergeStageTaskDetails', () => {
713
713
  expect(merged.details[1].tasks).toBeUndefined();
714
714
  });
715
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
+ });
@@ -11,6 +11,7 @@ import { buildComplianceMatrix } from '../compliance-matrix/index.js';
11
11
  import { buildComplianceIndex } from './reverse-index.js';
12
12
  import { buildLawTree, buildProductView } from './views.js';
13
13
  import { buildGapReport } from './gap-report.js';
14
+ import { escHtml } from '../webview/render-util.js';
14
15
  const STATUS_LABELS = {
15
16
  compliant: 'Compliant',
16
17
  partial: 'Partial',
@@ -19,13 +20,6 @@ const STATUS_LABELS = {
19
20
  pending_owner: 'Pending owner',
20
21
  n_a: 'N/A',
21
22
  };
22
- function escHtml(s) {
23
- return s
24
- .replace(/&/g, '&')
25
- .replace(/</g, '&lt;')
26
- .replace(/>/g, '&gt;')
27
- .replace(/"/g, '&quot;');
28
- }
29
23
  function badge(status) {
30
24
  return `<span class="cmp-badge cmp-${status}">${escHtml(STATUS_LABELS[status])}</span>`;
31
25
  }
@@ -53,6 +53,15 @@ export interface ImpactColumn {
53
53
  label: string;
54
54
  /** Display name for the subject (product/process name, not the id). */
55
55
  subjectName?: string;
56
+ /** Which subjects pool this column derives from (product / process / capability).
57
+ * Used to enforce the subject type label invariant (§5.2, COMPIMP-010). */
58
+ subjectType: 'product' | 'process' | 'capability';
59
+ }
60
+ /** A structural validation finding emitted by the matrix builder. */
61
+ export interface ImpactFinding {
62
+ code: string;
63
+ severity: 'error' | 'warning';
64
+ message: string;
56
65
  }
57
66
  /**
58
67
  * Grouping options for matrix columns.
@@ -64,9 +73,12 @@ export interface ImpactColumn {
64
73
  * - `'product-stage-task'` — one column per (subject, stage, task) triple;
65
74
  * pass `objectDetails` with stages that carry `tasks[]` (built via
66
75
  * `extractObjectDetails` + `extractProcessFlowTasks` + `mergeStageTaskDetails`).
76
+ * - `'process'` / `'process-stage'` / `'process-stage-task'` — process-centric
77
+ * equivalents used when `view.subjects.processes` is the sole entry point
78
+ * (§7.1 process compliance variant). Avoids COMPIMP-010 for process subjects.
67
79
  */
68
80
  export interface ImpactGrouping {
69
- columns: 'product' | 'product-stage' | 'product-stage-task';
81
+ columns: 'product' | 'product-stage' | 'product-stage-task' | 'process' | 'process-stage' | 'process-stage-task';
70
82
  }
71
83
  /**
72
84
  * Named, versioned view-config — the report *definition*.
@@ -84,6 +96,15 @@ export interface ImpactViewConfig {
84
96
  id: string;
85
97
  name: string;
86
98
  description?: string;
99
+ /**
100
+ * Declares the subject scope of the view (§4, COMPIMP-011 enforcement).
101
+ * `product` — covers products only; `subjects.processes` MUST be absent.
102
+ * `process` — covers processes directly; `subjects.products` MUST be absent.
103
+ * `combined` — both subject types allowed; column headers carry explicit badges.
104
+ * Omitting the field preserves backward-compatible behaviour (auto-detected
105
+ * from `subjects.*` presence).
106
+ */
107
+ report_type?: 'product' | 'process' | 'combined';
87
108
  /**
88
109
  * ISO 8601 date of the last report snapshot (YYYY-MM-DD).
89
110
  * Populated automatically by the CLI on each run and stored back to the
@@ -220,6 +241,8 @@ export interface ImpactMatrix {
220
241
  * Empty array when no requirement in the column has a `derived_from`.
221
242
  */
222
243
  obligationsLane: string[][];
244
+ /** Structural validation findings emitted during matrix build (COMPIMP-009, COMPIMP-010, etc.). */
245
+ findings: ImpactFinding[];
223
246
  }
224
247
  /**
225
248
  * Classify a REQUIREMENT deadline relative to a reference date.
@@ -86,15 +86,23 @@ export function parseImpactViewConfig(raw) {
86
86
  ? v.grouping
87
87
  : null;
88
88
  const rawColumns = typeof rawGrouping?.columns === 'string' ? rawGrouping.columns : undefined;
89
- const VALID_COLUMNS = new Set(['product', 'product-stage', 'product-stage-task']);
89
+ const VALID_COLUMNS = new Set([
90
+ 'product', 'product-stage', 'product-stage-task',
91
+ 'process', 'process-stage', 'process-stage-task',
92
+ ]);
90
93
  const normalizedColumns = rawColumns === 'object-details' ? 'product-stage' // backward-compat alias
91
94
  : rawColumns === 'object' ? 'product' // backward-compat alias
92
95
  : VALID_COLUMNS.has(rawColumns ?? '') ? rawColumns
93
96
  : undefined;
97
+ const VALID_REPORT_TYPES = new Set(['product', 'process', 'combined']);
98
+ const rawReportType = typeof v.report_type === 'string' ? v.report_type : undefined;
94
99
  const config = {
95
100
  id: v.id,
96
101
  name: v.name,
97
102
  description: typeof v.description === 'string' ? v.description : undefined,
103
+ report_type: rawReportType && VALID_REPORT_TYPES.has(rawReportType)
104
+ ? rawReportType
105
+ : undefined,
98
106
  snapshot_at: typeof v.snapshot_at === 'string' ? v.snapshot_at : undefined,
99
107
  subjects: {
100
108
  products: Array.isArray(subjects.products)
@@ -226,17 +234,39 @@ function aggregateStatus(assertions) {
226
234
  return 'n_a';
227
235
  }
228
236
  // ── Column builder helpers (CV-3a) ──────────────────────────────────────────
229
- function buildProductColumns(config, namedItems) {
237
+ function buildProductColumns(config, namedItems, isCombined) {
230
238
  const nameMap = new Map(namedItems.map(p => [p.id, p.name]));
231
- const subjects = [
232
- ...(config.subjects.products ?? []),
233
- ...(config.subjects.processes ?? []),
234
- ...(config.subjects.capabilities ?? []),
235
- ];
236
- return subjects.map(id => ({ subjectId: id, label: id, subjectName: nameMap.get(id) }));
239
+ const cols = [];
240
+ for (const id of (config.subjects.products ?? [])) {
241
+ cols.push({
242
+ subjectId: id,
243
+ label: isCombined ? `[PRODUCT] ${id}` : id,
244
+ subjectName: nameMap.get(id),
245
+ subjectType: 'product',
246
+ });
247
+ }
248
+ for (const id of (config.subjects.processes ?? [])) {
249
+ cols.push({
250
+ subjectId: id,
251
+ label: isCombined ? `[PROCESS] ${id}` : id,
252
+ subjectName: nameMap.get(id),
253
+ subjectType: 'process',
254
+ });
255
+ }
256
+ for (const id of (config.subjects.capabilities ?? [])) {
257
+ cols.push({
258
+ subjectId: id,
259
+ label: id,
260
+ subjectName: nameMap.get(id),
261
+ subjectType: 'capability',
262
+ });
263
+ }
264
+ return cols;
237
265
  }
238
- function buildObjectDetailColumns(config, objectDetails) {
266
+ function buildObjectDetailColumns(config, objectDetails, isCombined) {
239
267
  const detailMap = new Map(objectDetails.map(d => [d.objectId, d.details]));
268
+ const productSet = new Set(config.subjects.products ?? []);
269
+ const processSet = new Set(config.subjects.processes ?? []);
240
270
  const subjects = [
241
271
  ...(config.subjects.products ?? []),
242
272
  ...(config.subjects.processes ?? []),
@@ -244,13 +274,15 @@ function buildObjectDetailColumns(config, objectDetails) {
244
274
  ];
245
275
  const cols = [];
246
276
  for (const subjectId of subjects) {
277
+ const subjectType = productSet.has(subjectId) ? 'product' : processSet.has(subjectId) ? 'process' : 'capability';
278
+ const badge = isCombined && subjectType !== 'capability' ? `[${subjectType.toUpperCase()}] ` : '';
247
279
  const details = detailMap.get(subjectId);
248
280
  if (!details || details.length === 0) {
249
- cols.push({ subjectId, label: subjectId });
281
+ cols.push({ subjectId, label: `${badge}${subjectId}`, subjectType });
250
282
  }
251
283
  else {
252
284
  for (const detail of details) {
253
- cols.push({ subjectId, stageId: detail.id, label: `${subjectId}:${detail.id}` });
285
+ cols.push({ subjectId, stageId: detail.id, label: `${badge}${subjectId}:${detail.id}`, subjectType });
254
286
  }
255
287
  }
256
288
  }
@@ -262,8 +294,10 @@ function buildObjectDetailColumns(config, objectDetails) {
262
294
  * per-task columns `{ subjectId, stageId, taskId, label: subjectId:stageId:taskId }`.
263
295
  * Stages with no tasks fall back to a single stage-grain column.
264
296
  */
265
- function buildTaskColumns(config, objectDetails) {
297
+ function buildTaskColumns(config, objectDetails, isCombined) {
266
298
  const detailMap = new Map(objectDetails.map(d => [d.objectId, d.details]));
299
+ const productSet = new Set(config.subjects.products ?? []);
300
+ const processSet = new Set(config.subjects.processes ?? []);
267
301
  const subjects = [
268
302
  ...(config.subjects.products ?? []),
269
303
  ...(config.subjects.processes ?? []),
@@ -271,15 +305,16 @@ function buildTaskColumns(config, objectDetails) {
271
305
  ];
272
306
  const cols = [];
273
307
  for (const subjectId of subjects) {
308
+ const subjectType = productSet.has(subjectId) ? 'product' : processSet.has(subjectId) ? 'process' : 'capability';
309
+ const badge = isCombined && subjectType !== 'capability' ? `[${subjectType.toUpperCase()}] ` : '';
274
310
  const stages = detailMap.get(subjectId);
275
311
  if (!stages || stages.length === 0) {
276
- cols.push({ subjectId, label: subjectId });
312
+ cols.push({ subjectId, label: `${badge}${subjectId}`, subjectType });
277
313
  }
278
314
  else {
279
315
  for (const stage of stages) {
280
316
  if (!stage.tasks || stage.tasks.length === 0) {
281
- // Stage with no tasks: fall back to stage-grain column.
282
- cols.push({ subjectId, stageId: stage.id, label: `${subjectId}:${stage.id}` });
317
+ cols.push({ subjectId, stageId: stage.id, label: `${badge}${subjectId}:${stage.id}`, subjectType });
283
318
  }
284
319
  else {
285
320
  for (const task of stage.tasks) {
@@ -287,7 +322,8 @@ function buildTaskColumns(config, objectDetails) {
287
322
  subjectId,
288
323
  stageId: stage.id,
289
324
  taskId: task.id,
290
- label: `${subjectId}:${stage.id}:${task.id}`,
325
+ label: `${badge}${subjectId}:${stage.id}:${task.id}`,
326
+ subjectType,
291
327
  });
292
328
  }
293
329
  }
@@ -349,11 +385,50 @@ export function buildImpactMatrix(canon, config, objectDetails) {
349
385
  const obligations = orderRows(resolveObligations(config, index, canon.requirements), config.order_rows_by);
350
386
  const grain = config.grouping?.columns ?? 'product';
351
387
  const hasDetails = objectDetails !== undefined && objectDetails.length > 0;
352
- const columns = grain === 'product-stage-task' && hasDetails
353
- ? buildTaskColumns(config, objectDetails)
354
- : (grain === 'product-stage' || grain === 'object-details') && hasDetails
355
- ? buildObjectDetailColumns(config, objectDetails)
356
- : buildProductColumns(config, [...(canon.products ?? []), ...(canon.subjects ?? [])]);
388
+ const hasProducts = (config.subjects.products?.length ?? 0) > 0;
389
+ const hasProcesses = (config.subjects.processes?.length ?? 0) > 0;
390
+ // Combined = both products and processes are explicit entry points (§7.1).
391
+ const isCombined = hasProducts && hasProcesses;
392
+ let columns = (grain === 'product-stage-task' || grain === 'process-stage-task') && hasDetails
393
+ ? buildTaskColumns(config, objectDetails, isCombined)
394
+ : (grain === 'product-stage' || grain === 'process-stage' || grain === 'object-details') && hasDetails
395
+ ? buildObjectDetailColumns(config, objectDetails, isCombined)
396
+ : buildProductColumns(config, [...(canon.products ?? []), ...(canon.subjects ?? [])], isCombined);
397
+ // ── Structural validation (COMPIMP-009, COMPIMP-010, COMPIMP-011) ──────
398
+ const findings = [];
399
+ if (!hasProducts && hasProcesses &&
400
+ (grain === 'product' || grain === 'product-stage' || grain === 'product-stage-task')) {
401
+ findings.push({
402
+ code: 'COMPIMP-009',
403
+ severity: 'warning',
404
+ 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.`,
405
+ });
406
+ }
407
+ if (hasProcesses && (grain === 'product-stage' || grain === 'product-stage-task')) {
408
+ findings.push({
409
+ code: 'COMPIMP-010',
410
+ severity: 'error',
411
+ 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.`,
412
+ });
413
+ }
414
+ // COMPIMP-011: report_type contract — strip cross-type columns and emit an
415
+ // error when the declared scope contradicts the subjects actually present.
416
+ if (config.report_type === 'product' && hasProcesses) {
417
+ findings.push({
418
+ code: 'COMPIMP-011',
419
+ severity: 'error',
420
+ 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'.`,
421
+ });
422
+ columns = columns.filter(c => c.subjectType !== 'process');
423
+ }
424
+ if (config.report_type === 'process' && hasProducts) {
425
+ findings.push({
426
+ code: 'COMPIMP-011',
427
+ severity: 'error',
428
+ 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'.`,
429
+ });
430
+ columns = columns.filter(c => c.subjectType !== 'product');
431
+ }
357
432
  const allowedStatuses = new Set(config.status_display?.show ?? ['compliant', 'partial', 'non_compliant', 'under_review', 'pending_owner', 'n_a']);
358
433
  const rowIndex = new Set(obligations.map(r => r.id));
359
434
  const cells = obligations.map(() => columns.map(() => emptyCell()));
@@ -434,6 +509,7 @@ export function buildImpactMatrix(canon, config, objectDetails) {
434
509
  no_obligation_applies_label: config.empty_cells?.no_obligation_applies_label ?? DEFAULT_NO_OBLIGATION_APPLIES_LABEL,
435
510
  },
436
511
  obligationsLane,
512
+ findings,
437
513
  };
438
514
  }
439
515
  function emptyCell() {