@transitrix/diagrams 1.0.1 → 1.2.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.
- package/dist/activities/__tests__/validate.test.js +32 -1
- package/dist/activities/cpm.js +11 -2
- package/dist/activities/gantt.js +6 -3
- package/dist/activities/types.d.ts +1 -0
- package/dist/activities/validate.js +24 -8
- package/dist/activity-card/__tests__/resolver.test.js +22 -1
- package/dist/activity-card/__tests__/validate.test.js +23 -1
- package/dist/activity-card/resolver.js +28 -20
- package/dist/activity-card/validate.js +22 -11
- package/dist/capability-map/index.d.ts +4 -0
- package/dist/capability-map/index.js +2 -0
- package/dist/capability-map/layout-tree.d.ts +42 -0
- package/dist/capability-map/layout-tree.js +65 -0
- package/dist/capability-map/render-capability-tree.d.ts +15 -0
- package/dist/capability-map/render-capability-tree.js +108 -0
- package/dist/capability-map/types.d.ts +2 -0
- package/dist/compliance/__tests__/impact.test.js +198 -0
- package/dist/compliance/html.js +1 -7
- package/dist/compliance/impact.d.ts +24 -1
- package/dist/compliance/impact.js +97 -21
- package/dist/factor/__tests__/validate.test.js +3 -3
- package/dist/factor/types.d.ts +1 -1
- package/dist/factor/validate.js +2 -2
- package/dist/fgca/__tests__/chain-table.test.js +2 -2
- package/dist/fgca/__tests__/layout.test.js +11 -11
- package/dist/fgca/__tests__/preview-layout.test.js +7 -7
- package/dist/fgca/__tests__/resolver.test.js +2 -2
- package/dist/fgca/__tests__/validate.test.js +2 -2
- package/dist/fgca/chain-table.d.ts +1 -1
- package/dist/fgca/chain-table.js +4 -4
- package/dist/fgca/index.d.ts +2 -2
- package/dist/fgca/index.js +1 -1
- package/dist/fgca/layout.d.ts +2 -2
- package/dist/fgca/layout.js +9 -9
- package/dist/fgca/nodeTypes.d.ts +1 -1
- package/dist/fgca/nodeTypes.js +2 -2
- package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
- package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
- package/dist/fgca/parse-canonical.js +24 -13
- package/dist/fgca/preview-layout.d.ts +1 -1
- package/dist/fgca/preview-layout.js +4 -4
- package/dist/fgca/resolver.js +3 -1
- package/dist/fgca/types.d.ts +13 -11
- package/dist/fgca/types.js +2 -2
- package/dist/fgca/validate.d.ts +2 -2
- package/dist/fgca/validate.js +34 -23
- package/dist/process-blueprint/index.d.ts +1 -1
- package/dist/process-blueprint/layout.js +64 -37
- package/dist/process-blueprint/types.d.ts +14 -0
- package/dist/scenarios/index.d.ts +1 -1
- package/dist/scenarios/types.d.ts +4 -4
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/themes.js +28 -3
- package/dist/theme/tokens.d.ts +47 -1
- package/dist/theme/tokens.js +31 -1
- package/dist/webview/__tests__/entry-notations.test.js +2 -0
- package/dist/webview/__tests__/render-process.test.d.ts +1 -0
- package/dist/webview/__tests__/render-process.test.js +463 -0
- package/dist/webview/__tests__/render-util.test.d.ts +1 -0
- package/dist/webview/__tests__/render-util.test.js +54 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +9 -5
- package/dist/webview/render-activities.d.ts +21 -1
- package/dist/webview/render-activities.js +91 -56
- package/dist/webview/render-activity-card.d.ts +11 -1
- package/dist/webview/render-activity-card.js +11 -2
- package/dist/webview/render-blocks.d.ts +24 -1
- package/dist/webview/render-blocks.js +58 -24
- package/dist/webview/render-fgca.d.ts +27 -0
- package/dist/webview/render-fgca.js +30 -16
- package/dist/webview/render-goals.d.ts +26 -1
- package/dist/webview/render-goals.js +30 -20
- package/dist/webview/render-process-blueprint.d.ts +11 -1
- package/dist/webview/render-process-blueprint.js +118 -53
- package/dist/webview/render-process.d.ts +95 -0
- package/dist/webview/render-process.js +392 -0
- package/dist/webview/render-scenarios.js +2 -2
- package/dist/webview/render-util.d.ts +19 -6
- package/dist/webview/render-util.js +23 -15
- package/package.json +15 -1
- package/src/activities/__tests__/validate.test.ts +35 -1
- package/src/activities/cpm.ts +14 -2
- package/src/activities/gantt.ts +6 -5
- package/src/activities/types.ts +1 -0
- package/src/activities/validate.ts +24 -8
- package/src/activity-card/__tests__/resolver.test.ts +24 -1
- package/src/activity-card/__tests__/validate.test.ts +25 -1
- package/src/activity-card/resolver.ts +29 -20
- package/src/activity-card/validate.ts +20 -11
- package/src/capability-map/index.ts +11 -0
- package/src/capability-map/layout-tree.ts +114 -0
- package/src/capability-map/render-capability-tree.ts +131 -0
- package/src/capability-map/types.ts +2 -0
- package/src/compliance/__tests__/impact.test.ts +219 -0
- package/src/compliance/html.ts +1 -8
- package/src/compliance/impact.ts +141 -23
- package/src/factor/__tests__/validate.test.ts +3 -3
- package/src/factor/types.ts +1 -1
- package/src/factor/validate.ts +2 -2
- package/src/fgca/__tests__/chain-table.test.ts +2 -2
- package/src/fgca/__tests__/layout.test.ts +13 -13
- package/src/fgca/__tests__/preview-layout.test.ts +7 -7
- package/src/fgca/__tests__/resolver.test.ts +2 -2
- package/src/fgca/__tests__/validate.test.ts +2 -2
- package/src/fgca/chain-table.ts +5 -5
- package/src/fgca/index.ts +3 -3
- package/src/fgca/layout.ts +11 -11
- package/src/fgca/nodeTypes.ts +3 -3
- package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
- package/src/fgca/parse-canonical.ts +26 -15
- package/src/fgca/preview-layout.ts +5 -5
- package/src/fgca/resolver.ts +3 -1
- package/src/fgca/types.ts +15 -13
- package/src/fgca/validate.ts +32 -21
- package/src/process-blueprint/index.ts +1 -0
- package/src/process-blueprint/layout.ts +71 -42
- package/src/process-blueprint/types.ts +15 -0
- package/src/scenarios/index.ts +2 -2
- package/src/scenarios/types.ts +4 -4
- package/src/scenarios/validate.ts +3 -3
- package/src/theme/index.ts +1 -1
- package/src/theme/themes.ts +29 -2
- package/src/theme/tokens.ts +33 -1
- package/src/webview/__tests__/entry-notations.test.ts +2 -0
- package/src/webview/__tests__/render-process.test.ts +538 -0
- package/src/webview/__tests__/render-util.test.ts +66 -0
- package/src/webview/entry.ts +11 -5
- package/src/webview/render-activities.ts +107 -67
- package/src/webview/render-activity-card.ts +11 -2
- package/src/webview/render-blocks.ts +78 -28
- package/src/webview/render-fgca.ts +53 -29
- package/src/webview/render-goals.ts +45 -23
- package/src/webview/render-process-blueprint.ts +156 -82
- package/src/webview/render-process.ts +507 -0
- package/src/webview/render-scenarios.ts +3 -3
- package/src/webview/render-util.ts +27 -14
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { layoutCapabilityTree, TREE_NODE_WIDTH, TREE_NODE_HEIGHT, TREE_NODE_SEP } from './layout-tree.js';
|
|
2
|
+
import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
|
|
3
|
+
import { escXml } from '../webview/render-util.js';
|
|
4
|
+
const PAD = 24;
|
|
5
|
+
const BTN_R = 8;
|
|
6
|
+
const LEGEND_H = 40;
|
|
7
|
+
const LABEL_MAX_CHARS = 28;
|
|
8
|
+
const LABEL_TRIM_CHARS = 26;
|
|
9
|
+
const BAND_LABELS = ['Levels 0–2', 'Levels 3–4', 'Levels 5+'];
|
|
10
|
+
function depthBand(depth) {
|
|
11
|
+
if (depth <= 2)
|
|
12
|
+
return 0;
|
|
13
|
+
if (depth <= 4)
|
|
14
|
+
return 1;
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
function truncate(s, max, trim) {
|
|
18
|
+
return s.length > max ? s.slice(0, trim) + '…' : s;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Renders a capability-map as a left-to-right SVG node tree with DSM-matching
|
|
22
|
+
* visual design: depth-banded fills, maturity badges, and +/− collapse buttons.
|
|
23
|
+
*
|
|
24
|
+
* The SVG uses CSS classes (`tree-level-N`, `tree-maturity-N`, `tree-collapse-btn`)
|
|
25
|
+
* that must be resolved by the host's stylesheet (injected via `diagramClassCss()`
|
|
26
|
+
* in themes.ts). Collapse buttons carry `data-node-id` / `data-collapsed` attributes
|
|
27
|
+
* for the webview click handler.
|
|
28
|
+
*/
|
|
29
|
+
export function renderCapabilityTreeSvg(map, opts = {}) {
|
|
30
|
+
const { collapsedIds = new Set(), curvature = DEFAULT_EDGE_CURVATURE } = opts;
|
|
31
|
+
const layout = layoutCapabilityTree(map, collapsedIds);
|
|
32
|
+
if (layout.nodes.length === 0) {
|
|
33
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
|
|
34
|
+
}
|
|
35
|
+
const ox = -layout.bounds.x + PAD;
|
|
36
|
+
const oy = -layout.bounds.y + PAD;
|
|
37
|
+
const svgW = layout.bounds.width + PAD * 2 + BTN_R;
|
|
38
|
+
const svgH = layout.bounds.height + PAD * 2 + LEGEND_H + TREE_NODE_SEP;
|
|
39
|
+
const nodeMap = new Map(layout.nodes.map(n => [n.id, n]));
|
|
40
|
+
const edgeSvg = layout.edges.map(e => {
|
|
41
|
+
const s = nodeMap.get(e.source);
|
|
42
|
+
const t = nodeMap.get(e.target);
|
|
43
|
+
if (!s || !t)
|
|
44
|
+
return '';
|
|
45
|
+
const sx = s.x + ox + s.width;
|
|
46
|
+
const sy = s.y + oy + s.height / 2;
|
|
47
|
+
const tx = t.x + ox;
|
|
48
|
+
const ty = t.y + oy + t.height / 2;
|
|
49
|
+
return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature)}" class="diagram-edge"/>`;
|
|
50
|
+
}).filter(Boolean).join('\n');
|
|
51
|
+
const nodeSvg = layout.nodes.map(n => {
|
|
52
|
+
const x = n.x + ox;
|
|
53
|
+
const y = n.y + oy;
|
|
54
|
+
const band = depthBand(n.depth);
|
|
55
|
+
const mat = Math.max(1, Math.min(5, n.data.current_maturity | 0));
|
|
56
|
+
const nameText = truncate(n.data.name, LABEL_MAX_CHARS, LABEL_TRIM_CHARS);
|
|
57
|
+
const nameTitle = n.data.name.length > LABEL_MAX_CHARS ? escXml(n.data.name) : '';
|
|
58
|
+
// Maturity badge: left side of node
|
|
59
|
+
const badgeW = 28;
|
|
60
|
+
const badgeH = 18;
|
|
61
|
+
const badgeX = x + 10;
|
|
62
|
+
const badgeY = y + (TREE_NODE_HEIGHT - badgeH) / 2;
|
|
63
|
+
// Text area: right of badge
|
|
64
|
+
const textX = badgeX + badgeW + 8;
|
|
65
|
+
const nameY = y + TREE_NODE_HEIGHT / 2 - 8;
|
|
66
|
+
const idY = y + TREE_NODE_HEIGHT / 2 + 9;
|
|
67
|
+
// Collapse button: centered on right edge of node
|
|
68
|
+
const btnCx = x + TREE_NODE_WIDTH;
|
|
69
|
+
const btnCy = y + TREE_NODE_HEIGHT / 2;
|
|
70
|
+
const collapseBtn = n.hasChildren
|
|
71
|
+
? `<circle class="tree-collapse-btn" cx="${btnCx}" cy="${btnCy}" r="${BTN_R}" data-node-id="${escXml(n.id)}" data-collapsed="${n.isCollapsed}"/>
|
|
72
|
+
<text class="tree-collapse-lbl" x="${btnCx}" y="${btnCy}" text-anchor="middle">${n.isCollapsed ? '+' : '−'}</text>`
|
|
73
|
+
: '';
|
|
74
|
+
return `<g${nameTitle ? ` title="${nameTitle}"` : ''}>
|
|
75
|
+
<rect class="tree-level-${band}" x="${x}" y="${y}" width="${TREE_NODE_WIDTH}" height="${TREE_NODE_HEIGHT}" rx="11" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="3"/>
|
|
76
|
+
<rect class="tree-maturity-${mat}" x="${badgeX}" y="${badgeY}" width="${badgeW}" height="${badgeH}" rx="4"/>
|
|
77
|
+
<text class="text-pill" x="${badgeX + badgeW / 2}" y="${badgeY + badgeH / 2}" text-anchor="middle" fill="white">L${mat}</text>
|
|
78
|
+
<text class="text-primary" x="${textX}" y="${nameY}" dominant-baseline="central">${escXml(nameText)}</text>
|
|
79
|
+
<text class="text-id" x="${textX}" y="${idY}" dominant-baseline="central">${escXml(n.id)}</text>
|
|
80
|
+
${collapseBtn}
|
|
81
|
+
</g>`;
|
|
82
|
+
}).join('\n');
|
|
83
|
+
// Legend band: proportional coloured blocks per level-count band
|
|
84
|
+
const legendY = layout.bounds.height + PAD * 2 + TREE_NODE_SEP / 2;
|
|
85
|
+
const legendW = svgW - PAD * 2;
|
|
86
|
+
const totalNodes = layout.nodes.length || 1;
|
|
87
|
+
const bandCounts = [layout.levelCounts.band0, layout.levelCounts.band1, layout.levelCounts.band2];
|
|
88
|
+
let legendX = PAD;
|
|
89
|
+
const legendSvg = bandCounts.map((count, i) => {
|
|
90
|
+
const bw = Math.max(1, Math.round(legendW * count / totalNodes));
|
|
91
|
+
const bx = legendX;
|
|
92
|
+
legendX += bw;
|
|
93
|
+
if (count === 0)
|
|
94
|
+
return '';
|
|
95
|
+
return `<rect class="tree-level-${i}" x="${bx}" y="${legendY}" width="${bw}" height="24" rx="4" stroke="var(--ts-node-stroke,#94a3b8)" stroke-width="1"/>
|
|
96
|
+
<text class="text-id" x="${bx + bw / 2}" y="${legendY + 12}" text-anchor="middle" dominant-baseline="central">${escXml(BAND_LABELS[i])}</text>`;
|
|
97
|
+
}).filter(Boolean).join('\n');
|
|
98
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">
|
|
99
|
+
<defs>
|
|
100
|
+
<marker id="cap-tree-arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
|
|
101
|
+
<path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
|
|
102
|
+
</marker>
|
|
103
|
+
</defs>
|
|
104
|
+
${edgeSvg}
|
|
105
|
+
${nodeSvg}
|
|
106
|
+
${legendSvg}
|
|
107
|
+
</svg>`;
|
|
108
|
+
}
|
|
@@ -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
|
+
});
|
package/dist/compliance/html.js
CHANGED
|
@@ -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, '<')
|
|
26
|
-
.replace(/>/g, '>')
|
|
27
|
-
.replace(/"/g, '"');
|
|
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([
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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() {
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { validateFactor } from '../validate.js';
|
|
3
3
|
function validExternal() {
|
|
4
4
|
return {
|
|
5
|
-
notation: '
|
|
5
|
+
notation: 'driver',
|
|
6
6
|
id: 'FACTOR-EU-REG-1',
|
|
7
7
|
name: 'EU regulatory window',
|
|
8
8
|
type: 'external',
|
|
@@ -19,7 +19,7 @@ function validExternal() {
|
|
|
19
19
|
}
|
|
20
20
|
function validInternal() {
|
|
21
21
|
return {
|
|
22
|
-
notation: '
|
|
22
|
+
notation: 'driver',
|
|
23
23
|
id: 'FACTOR-COMP-1',
|
|
24
24
|
name: 'Support response time',
|
|
25
25
|
type: 'internal',
|
|
@@ -60,7 +60,7 @@ describe('validateFactor — FACTOR-001 (shape / id grammar)', () => {
|
|
|
60
60
|
expect(codes({ ...validExternal(), id: 'factor-1' })).toContain('FACTOR-001');
|
|
61
61
|
});
|
|
62
62
|
it('flags a wrong notation tag and a non-canon zone', () => {
|
|
63
|
-
expect(codes({ ...validExternal(), notation: '
|
|
63
|
+
expect(codes({ ...validExternal(), notation: 'factor' })).toContain('FACTOR-001');
|
|
64
64
|
expect(codes({ ...validExternal(), zone: 'sandbox' })).toContain('FACTOR-001');
|
|
65
65
|
});
|
|
66
66
|
it('flags a missing valid_to key (distinct from a null value)', () => {
|
package/dist/factor/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type FactorType = 'external' | 'internal';
|
|
|
4
4
|
export declare const FACTOR_PESTLE_CATEGORIES: readonly ["political", "economic", "social", "technological", "legal", "environmental"];
|
|
5
5
|
export type FactorPestleCategory = typeof FACTOR_PESTLE_CATEGORIES[number];
|
|
6
6
|
export interface Factor {
|
|
7
|
-
notation: '
|
|
7
|
+
notation: 'driver';
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
10
10
|
type?: FactorType;
|
package/dist/factor/validate.js
CHANGED
|
@@ -27,8 +27,8 @@ export function validateFactor(input, options = {}) {
|
|
|
27
27
|
if (!isCanonicalIdOfType(f.id, 'FACTOR')) {
|
|
28
28
|
errors.push({ code: 'FACTOR-001', message: `id "${String(f.id)}" must match FACTOR-[<middle>-]<INTEGER>.`, path: 'id' });
|
|
29
29
|
}
|
|
30
|
-
if (f.notation !== '
|
|
31
|
-
errors.push({ code: 'FACTOR-001', message: 'notation must be the fixed value "
|
|
30
|
+
if (f.notation !== 'driver') {
|
|
31
|
+
errors.push({ code: 'FACTOR-001', message: 'notation must be the fixed value "driver".', path: 'notation' });
|
|
32
32
|
}
|
|
33
33
|
if (f.zone !== undefined && f.zone !== 'canon') {
|
|
34
34
|
errors.push({ code: 'FACTOR-001', message: 'zone must be "canon" for a FACTOR.', path: 'zone' });
|