@transitrix/diagrams 1.13.2 → 1.14.0-rc.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/types.d.ts +5 -0
- package/dist/compliance/canon-catalog.js +1 -1
- package/dist/compliance/classify.d.ts +4 -1
- package/dist/compliance/classify.js +21 -1
- package/dist/compliance/index.d.ts +3 -0
- package/dist/compliance/index.js +1 -0
- package/dist/compliance/requirement-chain.d.ts +111 -0
- package/dist/compliance/requirement-chain.js +501 -0
- package/dist/compliance/reverse-index.js +4 -0
- package/dist/compliance/types.d.ts +20 -0
- package/dist/fgca/parse-canonical.js +1 -0
- package/dist/fgca/preview-layout.d.ts +1 -0
- package/dist/fgca/preview-layout.js +1 -1
- package/dist/fgca/types.d.ts +2 -0
- package/dist/webview/completion-percent.d.ts +10 -0
- package/dist/webview/completion-percent.js +12 -0
- package/dist/webview/render-activities.js +2 -0
- package/dist/webview/render-fgca.d.ts +0 -14
- package/dist/webview/render-fgca.js +3 -15
- package/package.json +4 -4
- package/src/activities/types.ts +2 -0
- package/src/compliance/canon-catalog.ts +1 -1
- package/src/compliance/classify.ts +24 -1
- package/src/compliance/index.ts +3 -0
- package/src/compliance/requirement-chain.ts +430 -0
- package/src/compliance/reverse-index.ts +3 -0
- package/src/compliance/types.ts +21 -0
- package/src/fgca/parse-canonical.ts +1 -0
- package/src/fgca/preview-layout.ts +2 -1
- package/src/fgca/types.ts +2 -0
- package/src/webview/completion-percent.ts +18 -0
- package/src/webview/render-activities.ts +2 -0
- package/src/webview/render-fgca.ts +3 -15
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser-safe SVG renderer for the canonical DGCA and DGA notations.
|
|
3
|
-
*
|
|
4
|
-
* Step 4 of the IntelliJ epic (ADR 0001): the webview bundle must turn a
|
|
5
|
-
* validated DGCA/DGA document into renderable SVG so JCEF can drop it into the
|
|
6
|
-
* preview panel. The VS Code path lives in `extension/src/dgca-preview.ts` and
|
|
7
|
-
* pulls in VS Code-specific concerns (themes, title block, save dialogs,
|
|
8
|
-
* spacing/scope controls); this module is the host-neutral subset — pure
|
|
9
|
-
* column layout → SVG with no VS Code APIs, no `node:fs`, no `node:path`.
|
|
10
|
-
*
|
|
11
|
-
* DGCA and DGA share the same renderer; DGA only differs by hiding the
|
|
12
|
-
* `change` column (`hideChanges` / the layout's Goal → Activity collapse),
|
|
13
|
-
* exactly as the extension preview does.
|
|
14
|
-
*/
|
|
15
1
|
import { layoutFGCAPreview } from '../fgca/preview-layout.js';
|
|
16
2
|
import type { FGCADoc } from '../fgca/validate.js';
|
|
17
3
|
import { type EdgeStyle } from '../edge-path.js';
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `change` column (`hideChanges` / the layout's Goal → Activity collapse),
|
|
13
13
|
* exactly as the extension preview does.
|
|
14
14
|
*/
|
|
15
|
+
import { completionPercentSvg } from './completion-percent.js';
|
|
15
16
|
import { layoutFGCAPreview, FGCA_NODE_W, FGCA_NODE_H, FGCA_HEADER_H as HEADER_H, FGCA_PAD as PAD, } from '../fgca/preview-layout.js';
|
|
16
17
|
import { previewEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
|
|
17
18
|
import { parseNodeSizePreset, resolveDgcaNodeSize } from '../node-size-presets.js';
|
|
@@ -39,7 +40,6 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
39
40
|
const edgeStyle = bodyOptions.edgeStyle;
|
|
40
41
|
const showCompletionPercent = bodyOptions.showCompletionPercent ?? true;
|
|
41
42
|
const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
|
|
42
|
-
const STALENESS_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
43
43
|
const headerSvg = columns
|
|
44
44
|
.map(({ col, x }) => `<text class="text-header" x="${x + nodeWidth / 2}" y="${PAD + HEADER_H / 2}" text-anchor="middle" dominant-baseline="central">${escXml(truncateLine(COL_LABELS[col], headerTruncate))}</text>`)
|
|
45
45
|
.join('\n');
|
|
@@ -69,20 +69,8 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
|
|
|
69
69
|
const typeBadge = n.type && n.col === 'activity'
|
|
70
70
|
? `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 10}" text-anchor="end" dominant-baseline="hanging" font-size="10">${escXml(n.type)}</text>`
|
|
71
71
|
: '';
|
|
72
|
-
const progressBadge =
|
|
73
|
-
? (()
|
|
74
|
-
if (n.progress) {
|
|
75
|
-
const computedAt = new Date(n.progress.computedAt);
|
|
76
|
-
const now = new Date();
|
|
77
|
-
const isStale = now.getTime() - computedAt.getTime() > STALENESS_THRESHOLD_MS;
|
|
78
|
-
const opacity = isStale ? ' opacity="0.5"' : '';
|
|
79
|
-
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10"${opacity}>${escXml(n.progress.percent + '%')}</text>`;
|
|
80
|
-
}
|
|
81
|
-
if (n.linked) {
|
|
82
|
-
return `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 22}" text-anchor="end" dominant-baseline="hanging" font-size="10">–%</text>`;
|
|
83
|
-
}
|
|
84
|
-
return '';
|
|
85
|
-
})()
|
|
72
|
+
const progressBadge = n.col === 'activity'
|
|
73
|
+
? completionPercentSvg(n.progress, Boolean(n.linked), n.x + nodeWidth - 6, n.y + 22, showCompletionPercent)
|
|
86
74
|
: '';
|
|
87
75
|
return [
|
|
88
76
|
`<g>`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transitrix/diagrams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0-rc.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"reactflow": "^11.0.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@testing-library/dom": "^10.4.
|
|
55
|
+
"@testing-library/dom": "^10.4.2",
|
|
56
56
|
"@testing-library/react": "^16.3.3",
|
|
57
57
|
"@types/react": "^19.2.17",
|
|
58
|
-
"@types/react-dom": "^19.
|
|
58
|
+
"@types/react-dom": "^19.3.0",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"react": "^19.2.8",
|
|
61
|
-
"react-dom": "^19.
|
|
61
|
+
"react-dom": "^19.3.0",
|
|
62
62
|
"reactflow": "^11.11.4",
|
|
63
63
|
"typescript": "^5.0.0",
|
|
64
64
|
"vitest": "^4.1.11"
|
package/src/activities/types.ts
CHANGED
|
@@ -33,6 +33,8 @@ export interface Activity {
|
|
|
33
33
|
resources_cost?: number;
|
|
34
34
|
effort?: number;
|
|
35
35
|
link?: string;
|
|
36
|
+
/** Derived display metadata, loaded from the model sidecar. */
|
|
37
|
+
progress?: { percent: number; computedAt: string };
|
|
36
38
|
description?: string;
|
|
37
39
|
delivers_changes?: string[];
|
|
38
40
|
}
|
|
@@ -62,7 +62,7 @@ export function buildComplianceScan(docs: ScannedYamlDoc[]): ComplianceScanResul
|
|
|
62
62
|
const pathById = new Map<string, string>();
|
|
63
63
|
|
|
64
64
|
for (const { path, data } of docs) {
|
|
65
|
-
const ingested = ingestComplianceDoc(complianceCanon, data);
|
|
65
|
+
const ingested = ingestComplianceDoc(complianceCanon, data, path);
|
|
66
66
|
if (ingested) pathById.set(ingested, path);
|
|
67
67
|
const admitted = admitDocumentToCatalog(catalogMap, data);
|
|
68
68
|
if (admitted && !pathById.has(admitted)) pathById.set(admitted, path);
|
|
@@ -3,6 +3,8 @@
|
|
|
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
|
|
|
6
|
+
import { typeOfId } from '../typed-id.js';
|
|
7
|
+
import type { ChainRecord, ChainFinding } from './types.js';
|
|
6
8
|
import type { AssertionStatus } from '../assertion/types.js';
|
|
7
9
|
import type { VerificationMethod, VerificationOutcome } from '../verification/types.js';
|
|
8
10
|
import type { ValidationMethod, ValidationOutcome } from '../validation/types.js';
|
|
@@ -21,6 +23,8 @@ export interface ComplianceCodexDoc {
|
|
|
21
23
|
|
|
22
24
|
/** The bucketed result of scanning a repo for compliance canon. */
|
|
23
25
|
export interface ComplianceCanon {
|
|
26
|
+
records: ChainRecord[];
|
|
27
|
+
findings: ChainFinding[];
|
|
24
28
|
products: ComplianceProduct[];
|
|
25
29
|
requirements: IndexRequirement[];
|
|
26
30
|
assertions: IndexAssertion[];
|
|
@@ -52,6 +56,8 @@ export interface ComplianceCanon {
|
|
|
52
56
|
|
|
53
57
|
export function emptyCanon(): ComplianceCanon {
|
|
54
58
|
return {
|
|
59
|
+
records: [],
|
|
60
|
+
findings: [],
|
|
55
61
|
products: [],
|
|
56
62
|
requirements: [],
|
|
57
63
|
assertions: [],
|
|
@@ -92,7 +98,8 @@ function pushUnique<T extends { id: string }>(canon: ComplianceCanon, list: T[],
|
|
|
92
98
|
* its id duplicates one already ingested into the same bucket (see
|
|
93
99
|
* `canon.duplicateIds`).
|
|
94
100
|
*/
|
|
95
|
-
export function ingestComplianceDoc(canon: ComplianceCanon, doc: unknown): string | null {
|
|
101
|
+
export function ingestComplianceDoc(canon: ComplianceCanon, doc: unknown, sourcePath = ''): string | null {
|
|
102
|
+
retainChainRecord(canon, doc, sourcePath);
|
|
96
103
|
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return null;
|
|
97
104
|
const d = doc as Record<string, unknown>;
|
|
98
105
|
const id = str(d.id);
|
|
@@ -178,3 +185,19 @@ export function ingestComplianceDoc(canon: ComplianceCanon, doc: unknown): strin
|
|
|
178
185
|
}
|
|
179
186
|
return null;
|
|
180
187
|
}
|
|
188
|
+
|
|
189
|
+
/** Retain malformed values and cross-type duplicates without changing legacy buckets. */
|
|
190
|
+
function retainChainRecord(canon: ComplianceCanon, doc: unknown, sourcePath: string): void {
|
|
191
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
192
|
+
canon.findings.push({ id: `${sourcePath}:document`, owner: sourcePath, field: '',
|
|
193
|
+
code: 'INTAKE', message: 'Expected a document mapping', reference: false, severity: 'error' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const raw = structuredClone(doc) as Record<string, unknown>;
|
|
197
|
+
if (typeof raw.id !== 'string' || !raw.id.trim()) {
|
|
198
|
+
if ('id' in raw || 'zone' in raw || ['requirement', 'verification', 'need', 'product', 'release', 'relation', 'driver'].includes(String(raw.notation))) canon.findings.push({ id: `${sourcePath}:id`, owner: sourcePath,
|
|
199
|
+
field: 'id', code: 'INTAKE', message: 'Missing record identity', reference: false, severity: 'error' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
canon.records.push({ id: raw.id, type: typeOfId(raw.id) ?? '', sourcePath, raw });
|
|
203
|
+
}
|
package/src/compliance/index.ts
CHANGED
|
@@ -76,3 +76,6 @@ export type {
|
|
|
76
76
|
TraceElementRef,
|
|
77
77
|
TraceSourceRef,
|
|
78
78
|
} from './types.js';
|
|
79
|
+
export { buildRequirementChain, requirementReleaseCounts, selectRequirementChain, RequirementChainSnapshot, CHAIN_STAGES, chainDate } from './requirement-chain.js';
|
|
80
|
+
export type { ChainInput, ChainScope, ChainSet, RequirementChainNode, RequirementChainEdge, RequirementChainProjection } from './requirement-chain.js';
|
|
81
|
+
export type { ChainRecord, ChainFinding } from './types.js';
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { ChainRecord, ChainFinding, ComplianceIndex } from './types.js';
|
|
2
|
+
import { isCanonicalId } from '../typed-id.js';
|
|
3
|
+
import { validateNeed } from '../need/validate.js';
|
|
4
|
+
import { validateRequirement } from '../requirement/validate.js';
|
|
5
|
+
import { checkAgreement } from '../agreement.js';
|
|
6
|
+
import { validateVerification } from '../verification/validate.js';
|
|
7
|
+
|
|
8
|
+
export const CHAIN_STAGES = ['Source document', 'Driver', 'Need', 'Stakeholder requirement',
|
|
9
|
+
'System requirement', 'Software requirement', 'Unclassified requirement', 'Verification definition', 'Result'] as const;
|
|
10
|
+
export interface ChainScope { catalogue: string; product?: string; release?: string; project?: string }
|
|
11
|
+
export interface ChainInput {
|
|
12
|
+
index: ComplianceIndex; scope: ChainScope; asAt: string; snapshotId: string;
|
|
13
|
+
sourceRevision?: string; complete: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface ChainSet { ids: string[]; completeness: 'complete' | 'incomplete'; total: number | null }
|
|
16
|
+
export interface RequirementChainNode {
|
|
17
|
+
id: string; recordId: string; name: string; stage: number; sourcePath: string;
|
|
18
|
+
context: string[]; raw: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export interface RequirementChainEdge {
|
|
21
|
+
id: string; kind: string; storedFrom: string; storedTo: string;
|
|
22
|
+
from: string; to: string; identities: string[]; trace: boolean; valid: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface ChainAssignment { requirement: string; nearest: string; depth: number; relations: string[] }
|
|
25
|
+
export interface RequirementChainProjection {
|
|
26
|
+
contract: 'requirement-chain/0.2'; contractRevision: string; asAt: string; snapshotId: string; sourceRevision?: string;
|
|
27
|
+
scope: ChainScope; completeness: 'complete' | 'incomplete'; scopeFindings: string[];
|
|
28
|
+
populations: { product: ChainSet; release: ChainSet; selected: ChainSet; inactive: ChainSet; unresolved: ChainSet };
|
|
29
|
+
assignments: { here: ChainSet; otherReleaseOnly: ChainSet; unassigned: ChainSet; invalid: ChainSet; provenance: ChainAssignment[] };
|
|
30
|
+
stages: ChainSet[]; metrics: { broken: ChainSet; noSource: ChainSet; noDefinition: ChainSet; noResult: ChainSet; failed: ChainSet; unassigned: ChainSet };
|
|
31
|
+
records: ChainRecord[]; nodes: RequirementChainNode[]; edges: RequirementChainEdge[]; findings: ChainFinding[];
|
|
32
|
+
defectiveReferences: ChainSet; selectedReferences: ChainSet; unattributableFindings: ChainSet;
|
|
33
|
+
affectedRequirements: Record<string, string[]>;
|
|
34
|
+
definitions: Record<string, string[]>; executions: Record<string, string[]>;
|
|
35
|
+
}
|
|
36
|
+
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
|
|
37
|
+
const map = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
38
|
+
const sorted = (v: Iterable<string>): string[] => [...new Set(v)].sort();
|
|
39
|
+
export function chainDate(v: unknown): v is string {
|
|
40
|
+
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) &&
|
|
41
|
+
Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v;
|
|
42
|
+
}
|
|
43
|
+
function set(ids: Iterable<string>, complete: boolean): ChainSet {
|
|
44
|
+
const list = sorted(ids); return { ids: list, completeness: complete ? 'complete' : 'incomplete', total: complete ? list.length : null };
|
|
45
|
+
}
|
|
46
|
+
function freeze<T>(value: T): T {
|
|
47
|
+
if (value && typeof value === 'object') {
|
|
48
|
+
for (const child of Object.values(value)) freeze(child);
|
|
49
|
+
Object.freeze(value);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
const CODEX = ['LAW', 'REGULATION', 'STANDARD', 'POLICY', 'INTERNAL_STANDARD', 'PRINCIPLE'];
|
|
54
|
+
const FIELD = ['INTERVIEW', 'SURVEY', 'OBSERVATION', 'DRAFT'];
|
|
55
|
+
|
|
56
|
+
/** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
|
|
57
|
+
export function buildRequirementChain(input: ChainInput): RequirementChainProjection {
|
|
58
|
+
const { index, asAt, scope } = input;
|
|
59
|
+
const findings = structuredClone(index.findings);
|
|
60
|
+
const scopeFindings: string[] = [];
|
|
61
|
+
const records = new Map<string, ChainRecord>();
|
|
62
|
+
let complete = input.complete && !findings.some(f => f.severity === 'error');
|
|
63
|
+
const finding = (owner: string, field: string, code: string, message: string, reference = false, warning = false) => {
|
|
64
|
+
const id = `${owner}.${field}`;
|
|
65
|
+
if (!findings.some(f => f.id === id && f.code === code)) findings.push({ id, owner, field, code, message, reference, severity: warning ? 'warning' : 'error' });
|
|
66
|
+
};
|
|
67
|
+
for (const [id, versions] of index.recordsById) {
|
|
68
|
+
if (versions.length !== 1) {
|
|
69
|
+
finding(id, 'id', 'DUPLICATE', 'Ambiguous catalogue identity'); complete = false;
|
|
70
|
+
} else records.set(id, structuredClone(versions[0]));
|
|
71
|
+
}
|
|
72
|
+
if (!chainDate(asAt)) { complete = false; scopeFindings.push('Invalid as-at date'); }
|
|
73
|
+
if (!scope.catalogue || !input.snapshotId) { complete = false; scopeFindings.push('Missing catalogue or snapshot identity'); }
|
|
74
|
+
const envelope = new Map<string, boolean>();
|
|
75
|
+
const active = new Map<string, boolean>();
|
|
76
|
+
for (const r of records.values()) {
|
|
77
|
+
const d = r.raw;
|
|
78
|
+
const dates = chainDate(d.valid_from) && (d.valid_to === null || chainDate(d.valid_to)) &&
|
|
79
|
+
(d.valid_to === null || (d.valid_to as string) >= (d.valid_from as string));
|
|
80
|
+
const admitted = chainDate(d.admitted_at) && text(d.admitted_by) && map(d.gate_checks) &&
|
|
81
|
+
Object.keys(d.gate_checks).length > 0 && Object.values(d.gate_checks).every(v => v === 'pass');
|
|
82
|
+
const zone = CODEX.includes(r.type) ? 'codex' : FIELD.includes(r.type) ? 'field' : 'canon';
|
|
83
|
+
const valid = dates && admitted && d.zone === zone && isCanonicalId(r.id);
|
|
84
|
+
if (!isCanonicalId(r.id)) finding(r.id, 'id', 'IDENTITY', 'Malformed canonical identity');
|
|
85
|
+
for (const v of checkAgreement(d)) finding(r.id, v.path ?? 'agreement', v.code, v.message);
|
|
86
|
+
envelope.set(r.id, valid);
|
|
87
|
+
active.set(r.id, valid && (d.valid_from as string) <= asAt && (d.valid_to === null || asAt <= (d.valid_to as string)));
|
|
88
|
+
if (!dates) finding(r.id, 'valid_from', 'LIFECYCLE', 'Malformed or missing inclusive lifecycle window');
|
|
89
|
+
if (!admitted || d.zone !== zone) finding(r.id, 'admission', 'ADMISSION', 'Invalid admission envelope or zone');
|
|
90
|
+
}
|
|
91
|
+
const validationCatalog = { typeOf: (id: string) => envelope.get(id) ? records.get(id)?.type : undefined };
|
|
92
|
+
const validNeeds = new Set<string>();
|
|
93
|
+
for (const r of records.values()) {
|
|
94
|
+
const verdict = r.type === 'NEED' ? validateNeed(r.raw, { catalog: validationCatalog }) :
|
|
95
|
+
r.type === 'REQUIREMENT' ? validateRequirement(r.raw, { catalog: validationCatalog }) : undefined;
|
|
96
|
+
if (!verdict) continue;
|
|
97
|
+
for (const v of [...verdict.errors, ...verdict.warnings]) finding(r.id, v.path ?? '', v.code, v.message,
|
|
98
|
+
['NEED-002', 'REQ-002', 'REQ-003', 'REQ-SERVES-001'].includes(v.code), verdict.warnings.includes(v));
|
|
99
|
+
if (r.type === 'NEED' && !verdict.errors.some(v => !v.code.startsWith('AGREE-'))) validNeeds.add(r.id);
|
|
100
|
+
}
|
|
101
|
+
const is = (id: unknown, type: string) => text(id) && records.get(id)?.type === type && envelope.get(id) === true;
|
|
102
|
+
const isProject = (id: unknown) => is(id, 'ACTION') && records.get(id as string)?.raw.type === 'Project';
|
|
103
|
+
const requirements = [...records.values()].filter(r => r.type === 'REQUIREMENT');
|
|
104
|
+
const nodes: RequirementChainNode[] = [];
|
|
105
|
+
const edges: RequirementChainEdge[] = [];
|
|
106
|
+
function node(r: ChainRecord, stage: number, suffix = '', context: string[] = []) {
|
|
107
|
+
nodes.push({ id: r.id + suffix, recordId: r.id, name: text(r.raw.name) ? r.raw.name : r.id,
|
|
108
|
+
stage, sourcePath: r.sourcePath, context, raw: r.raw });
|
|
109
|
+
}
|
|
110
|
+
for (const r of records.values()) {
|
|
111
|
+
if (r.type === 'REQUIREMENT') {
|
|
112
|
+
const levels = ['stakeholder', 'system', 'software']; const level = levels.indexOf(String(r.raw.level));
|
|
113
|
+
node(r, level < 0 ? 6 : level + 3);
|
|
114
|
+
if (r.raw.level !== undefined && level < 0) finding(r.id, 'level', 'REQ-LEVEL', 'Invalid level; displayed as unclassified');
|
|
115
|
+
} else if (r.type === 'NEED') node(r, 2);
|
|
116
|
+
else if (r.type === 'DRIVER') node(r, 1);
|
|
117
|
+
else if (['PRODUCT', 'RELEASE', 'ACTION'].includes(r.type)) node(r, -1);
|
|
118
|
+
else if (CODEX.includes(r.type) || FIELD.includes(r.type)) {
|
|
119
|
+
node(r, 0);
|
|
120
|
+
if (r.raw.source_document !== undefined) {
|
|
121
|
+
const d = r.raw.source_document;
|
|
122
|
+
if (!map(d) || !['title', 'uri', 'revision'].every(k => text(d[k])) || d.revision === 'latest') {
|
|
123
|
+
finding(r.id, 'source_document', 'SOURCE-DOC-001', 'Source document needs title, URI and immutable revision');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function addEdge(kind: string, from: string, to: unknown, owner: string, field: string, allowed: boolean,
|
|
129
|
+
trace: boolean, reverse = true, temporal = true): RequirementChainEdge {
|
|
130
|
+
const target = typeof to === 'string' ? to : String(to);
|
|
131
|
+
const identity = `${owner}.${field}`;
|
|
132
|
+
const valid = allowed && temporal;
|
|
133
|
+
if (!valid) finding(owner, field, kind === 'parent' ? 'REQ-PARENT-001' : 'REL-002', 'Invalid endpoint, type or lifecycle reference', true);
|
|
134
|
+
const edge: RequirementChainEdge = { id: `${kind}:${from}:${target}`, kind, storedFrom: from, storedTo: target,
|
|
135
|
+
from: reverse ? target : from, to: reverse ? from : target, identities: [identity], trace, valid };
|
|
136
|
+
const existing = edges.find(e => e.id === edge.id && e.valid === valid);
|
|
137
|
+
if (existing) { existing.identities.push(identity); existing.trace ||= trace; return existing; }
|
|
138
|
+
edges.push(edge); return edge;
|
|
139
|
+
}
|
|
140
|
+
const contains = (owner: ChainRecord, target: ChainRecord | undefined): boolean => {
|
|
141
|
+
if (!target || !envelope.get(target.id) || !envelope.get(owner.id)) return false;
|
|
142
|
+
const a = owner.raw, b = target.raw;
|
|
143
|
+
return (a.valid_from as string) >= (b.valid_from as string) &&
|
|
144
|
+
(b.valid_to === null || (a.valid_to !== null && (a.valid_to as string) <= (b.valid_to as string)));
|
|
145
|
+
};
|
|
146
|
+
for (const r of requirements) {
|
|
147
|
+
for (const [field, type] of [['parent', 'REQUIREMENT'], ['serves', 'NEED']]) {
|
|
148
|
+
if (r.raw[field] !== undefined) addEdge(field === 'parent' ? 'parent' : field, r.id, r.raw[field], r.id, field,
|
|
149
|
+
is(r.raw[field], type), true, true, contains(r, records.get(String(r.raw[field]))));
|
|
150
|
+
}
|
|
151
|
+
if (r.raw.derived_from !== undefined) {
|
|
152
|
+
const refs = Array.isArray(r.raw.derived_from) ? r.raw.derived_from : [];
|
|
153
|
+
refs.forEach((ref, i) => addEdge('derived_from', r.id, ref, r.id, `derived_from[${i}]`,
|
|
154
|
+
text(ref) && CODEX.includes(records.get(ref)?.type ?? '') && records.get(ref)?.raw.zone === 'codex', true,
|
|
155
|
+
true, contains(r, records.get(String(ref)))));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const rels = [...records.values()].filter(r => r.type === 'REL');
|
|
159
|
+
const usable = new Set<string>();
|
|
160
|
+
for (const r of rels) {
|
|
161
|
+
const d = r.raw; const from = String(d.from); const to = String(d.to); const kind = String(d.type);
|
|
162
|
+
const src = records.get(from), dst = records.get(to);
|
|
163
|
+
const endpoints = kind === 'requirement_parent' ? is(from, 'REQUIREMENT') && is(to, 'REQUIREMENT') :
|
|
164
|
+
kind === 'serves' ? is(from, 'REQUIREMENT') && is(to, 'NEED') :
|
|
165
|
+
kind === 'product_scope' ? is(from, 'REQUIREMENT') && is(to, 'PRODUCT') :
|
|
166
|
+
kind === 'project_scope' ? is(from, 'REQUIREMENT') && isProject(to) :
|
|
167
|
+
kind === 'project_product' ? isProject(from) && is(to, 'PRODUCT') :
|
|
168
|
+
kind === 'required_for' ? is(from, 'REQUIREMENT') && is(to, 'RELEASE') :
|
|
169
|
+
kind === 'source_trace' ? !!src && !!dst && envelope.get(from) === true && envelope.get(to) === true &&
|
|
170
|
+
(['REQUIREMENT', 'NEED'].includes(src.type) && (dst.type === 'DRIVER' || FIELD.includes(dst.type)) ||
|
|
171
|
+
src.type === 'DRIVER' && FIELD.includes(dst.type)) : !!src && !!dst;
|
|
172
|
+
const temporal = contains(r, src) && contains(r, dst);
|
|
173
|
+
if (!temporal) finding(r.id, 'to', 'REL-003', 'Relation window must be contained in both endpoints', true);
|
|
174
|
+
const trace = ['requirement_parent', 'serves', 'source_trace'].includes(kind);
|
|
175
|
+
addEdge(kind === 'requirement_parent' ? 'parent' : kind, from, to, r.id, 'to', endpoints, trace && active.get(r.id) === true, trace, temporal);
|
|
176
|
+
if (endpoints && temporal && active.get(r.id)) usable.add(r.id);
|
|
177
|
+
}
|
|
178
|
+
// Cycle defects retain traversal edges so a separate source path is not erased.
|
|
179
|
+
const parents = edges.filter(e => e.kind === 'parent' && e.trace && e.valid);
|
|
180
|
+
const reachable = (seed: string, candidates: RequirementChainEdge[], direction: 'upstream' | 'downstream') => {
|
|
181
|
+
const seen = new Set<string>([seed]); const queue = [seed];
|
|
182
|
+
while (queue.length) {
|
|
183
|
+
const id = queue.pop()!;
|
|
184
|
+
for (const e of candidates) {
|
|
185
|
+
const a = direction === 'upstream' ? e.to : e.from;
|
|
186
|
+
const b = direction === 'upstream' ? e.from : e.to;
|
|
187
|
+
if (a === id && !seen.has(b)) { seen.add(b); queue.push(b); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return seen;
|
|
191
|
+
};
|
|
192
|
+
for (const e of parents) {
|
|
193
|
+
if (reachable(e.to, parents, 'downstream').has(e.from)) {
|
|
194
|
+
for (const identity of e.identities) {
|
|
195
|
+
const split = identity.lastIndexOf('.');
|
|
196
|
+
finding(identity.slice(0, split), identity.slice(split + 1), e.from === e.to ? 'REL-009' : 'REL-010', 'Decomposition cycle', true);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const memberships = (id: string, kind: string) => rels.filter(r => usable.has(r.id) && r.raw.type === kind && r.raw.from === id).map(r => String(r.raw.to));
|
|
201
|
+
const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0).map(r => r.id);
|
|
202
|
+
const P = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').includes(scope.product ?? '')).map(r => r.id);
|
|
203
|
+
let productComplete = complete && unresolved.length === 0;
|
|
204
|
+
if (unresolved.length) scopeFindings.push('Unresolved product membership');
|
|
205
|
+
if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === 'product_scope' && active.get(r.id) && !usable.has(r.id))) productComplete = false;
|
|
206
|
+
const invalidAssignments = new Set<string>();
|
|
207
|
+
for (const r of rels.filter(r => r.raw.type === 'required_for')) {
|
|
208
|
+
const from = String(r.raw.from), to = records.get(String(r.raw.to));
|
|
209
|
+
const historical = chainDate(r.raw.valid_to) && r.raw.valid_to < asAt;
|
|
210
|
+
const future = chainDate(r.raw.valid_from) && r.raw.valid_from > asAt;
|
|
211
|
+
if (!historical && !future && (!usable.has(r.id) || !memberships(from, 'product_scope').includes(String(to?.raw.of)))) {
|
|
212
|
+
invalidAssignments.add(from);
|
|
213
|
+
finding(r.id, 'to', 'ASSIGNMENT', 'Invalid, dangling or wrong-product assignment', true);
|
|
214
|
+
if (!envelope.get(r.id)) productComplete = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let selectedComplete = complete;
|
|
218
|
+
if (!is(scope.product, 'PRODUCT')) { productComplete = false; selectedComplete = false; scopeFindings.push('Product unselected or unresolved'); }
|
|
219
|
+
if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== scope.product) {
|
|
220
|
+
selectedComplete = false; scopeFindings.push('Release unselected, unresolved or wrong product');
|
|
221
|
+
}
|
|
222
|
+
if (scope.project && (!isProject(scope.project) || !memberships(scope.project, 'project_product').includes(scope.product ?? ''))) {
|
|
223
|
+
selectedComplete = false; scopeFindings.push('Invalid or unmodelled project/product pair');
|
|
224
|
+
}
|
|
225
|
+
function query(release: string): { ids: string[]; provenance: ChainAssignment[]; complete: boolean } {
|
|
226
|
+
const chain: string[] = []; let cursor: string | undefined = release; let valid = true;
|
|
227
|
+
while (cursor) {
|
|
228
|
+
const r: ChainRecord | undefined = records.get(cursor);
|
|
229
|
+
if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== scope.product) {
|
|
230
|
+
valid = false;
|
|
231
|
+
finding(chain[chain.length - 1] ?? release, 'predecessor', 'RELEASE-SCOPE',
|
|
232
|
+
chain.includes(cursor) ? 'Release predecessor cycle' : 'Missing, invalid or cross-product predecessor');
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
chain.push(cursor);
|
|
236
|
+
cursor = r?.raw.predecessor === undefined || r.raw.predecessor === null ? undefined : String(r.raw.predecessor);
|
|
237
|
+
}
|
|
238
|
+
if (!valid) scopeFindings.push(`Incomplete predecessor chain: ${release}`);
|
|
239
|
+
const provenance: ChainAssignment[] = [];
|
|
240
|
+
for (const id of P) {
|
|
241
|
+
const attachments = rels.filter(r => usable.has(r.id) && r.raw.type === 'required_for' && r.raw.from === id && chain.includes(String(r.raw.to)))
|
|
242
|
+
.sort((a, b) => chain.indexOf(String(a.raw.to)) - chain.indexOf(String(b.raw.to)) || a.id.localeCompare(b.id));
|
|
243
|
+
if (attachments.length) provenance.push({ requirement: id, nearest: String(attachments[0].raw.to),
|
|
244
|
+
depth: chain.indexOf(String(attachments[0].raw.to)), relations: attachments.map(r => r.id) });
|
|
245
|
+
}
|
|
246
|
+
return { ids: provenance.map(p => p.requirement), provenance, complete: valid };
|
|
247
|
+
}
|
|
248
|
+
const selected = scope.release ? query(scope.release) : { ids: [], provenance: [], complete: false };
|
|
249
|
+
const L = selected.ids; selectedComplete &&= selected.complete;
|
|
250
|
+
if (rels.some(r => r.raw.type === 'required_for' && String(r.raw.to) === scope.release && unresolved.includes(String(r.raw.from)))) selectedComplete = false;
|
|
251
|
+
if (scope.project && L.some(id => memberships(id, 'project_scope').length === 0 || rels.some(r => r.raw.type === 'project_scope' && r.raw.from === id && active.get(r.id) && !usable.has(r.id)))) {
|
|
252
|
+
selectedComplete = false; scopeFindings.push('Unresolved project membership');
|
|
253
|
+
}
|
|
254
|
+
if (rels.some(r => ['product_scope', 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id))) selectedComplete = false;
|
|
255
|
+
if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, 'project_product').includes(scope.product ?? '')))) {
|
|
256
|
+
selectedComplete = false; scopeFindings.push('Inconsistent project/product membership');
|
|
257
|
+
}
|
|
258
|
+
const S = L.filter(id => !scope.project || memberships(id, 'project_scope').includes(scope.project));
|
|
259
|
+
const elsewhere = new Set<string>();
|
|
260
|
+
for (const release of records.values()) if (release.type === 'RELEASE' && release.raw.of === scope.product) {
|
|
261
|
+
const q = query(release.id); productComplete &&= q.complete; q.ids.forEach(id => elsewhere.add(id));
|
|
262
|
+
}
|
|
263
|
+
const unassigned = P.filter(id => !elsewhere.has(id) && !invalidAssignments.has(id));
|
|
264
|
+
const definitions: Record<string, string[]> = {}, executions: Record<string, string[]> = {};
|
|
265
|
+
const verifications = [...records.values()].filter(r => r.type === 'VERIFICATION');
|
|
266
|
+
const catalog = { typeOf: (id: string) => envelope.get(id) ? records.get(id)?.type : undefined };
|
|
267
|
+
for (const r of verifications) {
|
|
268
|
+
const d = r.raw, target = String(d.verifies);
|
|
269
|
+
const verdict = validateVerification(d, { catalog });
|
|
270
|
+
for (const v of [...verdict.errors, ...verdict.warnings]) finding(r.id, v.path ?? '', v.code, v.message,
|
|
271
|
+
v.code === 'VERIF-002' || v.code === 'VERIF-005', verdict.warnings.includes(v));
|
|
272
|
+
const definitionErrors = verdict.errors.filter(v => !['outcome', 'evidence', 'performed_at', 'verified_on', 'result'].some(k => v.path?.startsWith(k)));
|
|
273
|
+
const validDefinition = active.get(r.id) && definitionErrors.length === 0 && is(d.verifies, 'REQUIREMENT') && contains(r, records.get(target));
|
|
274
|
+
let executionValid = verdict.errors.length === 0;
|
|
275
|
+
if (d.performed_at !== undefined && (!chainDate(d.performed_at) || d.performed_at > asAt)) {
|
|
276
|
+
finding(r.id, 'performed_at', 'EXECUTION-DATE', 'Malformed or future execution date'); executionValid = false;
|
|
277
|
+
} else if (d.performed_at === undefined) finding(r.id, 'performed_at', 'EXECUTION-DATE', 'Execution date absent', false, true);
|
|
278
|
+
if (d.result !== undefined && !text(d.result)) { finding(r.id, 'result', 'VERIF-001', 'Malformed result narrative'); executionValid = false; }
|
|
279
|
+
if (Array.isArray(d.evidence)) d.evidence.forEach((v, i) => {
|
|
280
|
+
if (!map(v) || !['note', 'canonical_ref', 'external_doc'].includes(String(v.kind)) ||
|
|
281
|
+
(v.kind === 'note' && !text(v.text)) || (v.kind === 'external_doc' && (!text(v.url) || !text(v.title)))) {
|
|
282
|
+
finding(r.id, `evidence[${i}]`, 'VERIF-001', 'Malformed evidence entry'); executionValid = false;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
if (d.verified_on !== undefined && !is(d.verified_on, 'RELEASE')) {
|
|
286
|
+
finding(r.id, 'verified_on', 'VERIF-RELEASE', 'Unresolved release qualifier', true); executionValid = false;
|
|
287
|
+
}
|
|
288
|
+
const context = !active.get(r.id) ? ['inactive'] : !validDefinition ? ['invalid definition'] : [];
|
|
289
|
+
if (d.verified_on === undefined) context.push('unqualified');
|
|
290
|
+
else if (d.verified_on !== scope.release) context.push('other release');
|
|
291
|
+
if (!executionValid) context.push('malformed or future execution');
|
|
292
|
+
node(r, 7, '.definition', [...context]);
|
|
293
|
+
const verificationEdge = addEdge('verifies', r.id + '.definition', target, r.id, 'verifies', is(d.verifies, 'REQUIREMENT'), true, true, contains(r, records.get(target)));
|
|
294
|
+
verificationEdge.storedFrom = r.id;
|
|
295
|
+
if (['pass', 'fail', 'inconclusive'].includes(String(d.outcome))) {
|
|
296
|
+
node(r, 8, '.result', [...context]);
|
|
297
|
+
addEdge('result', r.id + '.definition', r.id + '.result', r.id, 'result', true, true, false);
|
|
298
|
+
}
|
|
299
|
+
if (validDefinition) (definitions[target] ??= []).push(r.id);
|
|
300
|
+
if (validDefinition && executionValid && d.verified_on === scope.release && ['pass', 'fail', 'inconclusive'].includes(String(d.outcome))) (executions[target] ??= []).push(r.id);
|
|
301
|
+
}
|
|
302
|
+
const traceEdges = edges.filter(e => e.trace && e.valid);
|
|
303
|
+
const affectedRequirements: Record<string, string[]> = {};
|
|
304
|
+
const noSource: string[] = [];
|
|
305
|
+
for (const r of requirements) {
|
|
306
|
+
const ancestors = reachable(r.id, traceEdges, 'upstream');
|
|
307
|
+
const owners = new Set(ancestors);
|
|
308
|
+
for (const v of verifications) if (v.raw.verifies === r.id) owners.add(v.id);
|
|
309
|
+
for (const rel of rels) {
|
|
310
|
+
const ownScope = ['product_scope', 'project_scope', 'required_for'].includes(String(rel.raw.type));
|
|
311
|
+
if (ownScope ? rel.raw.from === r.id : owners.has(String(rel.raw.from))) owners.add(rel.id);
|
|
312
|
+
}
|
|
313
|
+
for (const f of findings) if (owners.has(f.owner)) (affectedRequirements[f.id] ??= []).push(r.id);
|
|
314
|
+
const source = [...ancestors].some(id => {
|
|
315
|
+
const a = records.get(id); if (!a || !active.get(id)) return false;
|
|
316
|
+
return (a.type === 'NEED' && validNeeds.has(id)) || a.type === 'DRIVER' || CODEX.includes(a.type) ||
|
|
317
|
+
FIELD.includes(a.type) && map(a.raw.source_document) && !findings.some(f => f.owner === id && f.code === 'SOURCE-DOC-001');
|
|
318
|
+
});
|
|
319
|
+
if (!source && S.includes(r.id)) noSource.push(r.id);
|
|
320
|
+
}
|
|
321
|
+
for (const collection of [affectedRequirements, definitions, executions]) {
|
|
322
|
+
for (const key of Object.keys(collection)) collection[key] = sorted(collection[key]);
|
|
323
|
+
}
|
|
324
|
+
findings.sort((a, b) => a.id.localeCompare(b.id) || a.code.localeCompare(b.code));
|
|
325
|
+
const refs = findings.filter(f => f.reference);
|
|
326
|
+
const selectedRefs = refs.filter(f => affectedRequirements[f.id]?.some(id => S.includes(id)));
|
|
327
|
+
const broken = S.filter(id => selectedRefs.some(f => affectedRequirements[f.id]?.includes(id)));
|
|
328
|
+
for (const n of nodes) if (records.get(n.recordId)?.type === 'REQUIREMENT' && !S.includes(n.recordId)) {
|
|
329
|
+
n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? 'unresolved or other product' :
|
|
330
|
+
!L.includes(n.recordId) ? 'other release or unassigned' : 'other project');
|
|
331
|
+
}
|
|
332
|
+
const unassignedSet = set(unassigned, productComplete);
|
|
333
|
+
const result: RequirementChainProjection = {
|
|
334
|
+
contract: 'requirement-chain/0.2', contractRevision: '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
|
|
335
|
+
scope: structuredClone(scope), completeness: productComplete && selectedComplete ? 'complete' : 'incomplete', scopeFindings: sorted(scopeFindings),
|
|
336
|
+
populations: { product: set(P, productComplete), release: set(L, selectedComplete), selected: set(S, selectedComplete),
|
|
337
|
+
inactive: set(requirements.filter(r => envelope.get(r.id) && !active.get(r.id) && rels.some(rel => rel.raw.type === 'product_scope' && rel.raw.from === r.id && rel.raw.to === scope.product && envelope.get(rel.id))).map(r => r.id), complete),
|
|
338
|
+
unresolved: set(unresolved, complete) },
|
|
339
|
+
assignments: { here: set(L, selectedComplete), otherReleaseOnly: set(P.filter(id => elsewhere.has(id) && !L.includes(id)), productComplete),
|
|
340
|
+
unassigned: unassignedSet, invalid: set(P.filter(id => invalidAssignments.has(id)), complete), provenance: selected.provenance.sort((a, b) => a.requirement.localeCompare(b.requirement)) },
|
|
341
|
+
stages: CHAIN_STAGES.map((_, stage) => set(nodes.filter(n => n.stage === stage && S.includes(n.id)).map(n => n.id), selectedComplete)),
|
|
342
|
+
metrics: { broken: set(broken, selectedComplete), noSource: set(noSource, selectedComplete),
|
|
343
|
+
noDefinition: set(S.filter(id => !definitions[id]?.length), selectedComplete),
|
|
344
|
+
noResult: set(S.filter(id => definitions[id]?.length && !executions[id]?.length), selectedComplete),
|
|
345
|
+
failed: set(S.filter(id => executions[id]?.some(v => records.get(v)?.raw.outcome === 'fail')), selectedComplete), unassigned: unassignedSet },
|
|
346
|
+
records: structuredClone([...index.recordsById.values()].flat()).sort((a, b) => a.id.localeCompare(b.id) || a.sourcePath.localeCompare(b.sourcePath)),
|
|
347
|
+
nodes: nodes.sort((a, b) => a.stage - b.stage || a.id.localeCompare(b.id)),
|
|
348
|
+
edges: edges.map(e => ({ ...e, identities: sorted(e.identities) })).sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.id.localeCompare(b.id)),
|
|
349
|
+
findings, defectiveReferences: set(refs.map(f => f.id), complete), selectedReferences: set(selectedRefs.map(f => f.id), selectedComplete),
|
|
350
|
+
unattributableFindings: set(findings.filter(f => !affectedRequirements[f.id]?.length).map(f => f.id), complete),
|
|
351
|
+
affectedRequirements, definitions, executions,
|
|
352
|
+
};
|
|
353
|
+
return freeze(result);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Both is two independent walks, never an expansion from the union frontier. */
|
|
357
|
+
export function selectRequirementChain(p: RequirementChainProjection, focus?: string, direction: 'upstream' | 'downstream' | 'both' = 'both') {
|
|
358
|
+
const ids = new Set<string>(); const edgeIds = new Set<string>();
|
|
359
|
+
const walk = (seeds: string[], upstream: boolean) => {
|
|
360
|
+
const seen = new Set(seeds), queue = [...seeds]; seeds.forEach(id => ids.add(id));
|
|
361
|
+
while (queue.length) {
|
|
362
|
+
const id = queue.pop()!;
|
|
363
|
+
for (const e of p.edges.filter(e => e.trace)) {
|
|
364
|
+
if ((upstream ? e.to : e.from) !== id) continue;
|
|
365
|
+
edgeIds.add(e.id);
|
|
366
|
+
const next = upstream ? e.from : e.to;
|
|
367
|
+
if (e.valid && p.nodes.some(n => n.id === next) && !seen.has(next)) { seen.add(next); ids.add(next); queue.push(next); }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
if (focus) {
|
|
372
|
+
if (direction !== 'downstream') walk([focus], true);
|
|
373
|
+
if (direction !== 'upstream') walk([focus], false);
|
|
374
|
+
} else {
|
|
375
|
+
walk(p.populations.selected.ids, true);
|
|
376
|
+
for (const e of p.edges) if (e.kind === 'verifies' && p.populations.selected.ids.includes(e.from)) {
|
|
377
|
+
ids.add(e.to); edgeIds.add(e.id);
|
|
378
|
+
for (const result of p.edges.filter(x => x.kind === 'result' && x.from === e.to)) { ids.add(result.to); edgeIds.add(result.id); }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { nodes: p.nodes.filter(n => ids.has(n.id)), edges: p.edges.filter(e => edgeIds.has(e.id)) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Count/list contract for the release report, derived solely from the shared snapshot. */
|
|
385
|
+
export function requirementReleaseCounts(p: RequirementChainProjection) {
|
|
386
|
+
type Count = { label: string; unit: 'requirements' | 'references' | 'findings' | 'source documents' | 'drivers' | 'needs' | 'definition parts' | 'result parts'; set: ChainSet };
|
|
387
|
+
const counts: Record<string, Count> = {};
|
|
388
|
+
const add = (key: string, label: string, value: ChainSet, unit: Count['unit'] = 'requirements') => {
|
|
389
|
+
counts[key] = { label, unit, set: value };
|
|
390
|
+
};
|
|
391
|
+
const labels = { broken: 'Broken references', noSource: 'No accepted source path', noDefinition: 'No valid verification definition',
|
|
392
|
+
noResult: 'No applicable executed result (defined verification)', failed: 'Applicable failed verification', unassigned: 'No effective release assignment (whole product)' };
|
|
393
|
+
for (const key of Object.keys(labels) as (keyof typeof labels)[]) add('metric-' + key, labels[key], p.metrics[key]);
|
|
394
|
+
for (let stage = 3; stage <= 6; stage++) {
|
|
395
|
+
add('stage-' + stage, CHAIN_STAGES[stage], p.stages[stage]);
|
|
396
|
+
add('unassigned-' + stage, 'Unassigned: ' + CHAIN_STAGES[stage], set(p.metrics.unassigned.ids.filter(id => p.nodes.some(n => n.id === id && n.stage === stage)), p.metrics.unassigned.completeness === 'complete'));
|
|
397
|
+
}
|
|
398
|
+
add('selected', 'Selected requirements', p.populations.selected);
|
|
399
|
+
add('product', 'Product requirements', p.populations.product);
|
|
400
|
+
add('here', 'Assigned here (whole product)', p.assignments.here);
|
|
401
|
+
add('other', 'Other-release-only (whole product)', p.assignments.otherReleaseOnly);
|
|
402
|
+
add('invalid', 'Invalid or dangling assignment (whole product)', p.assignments.invalid);
|
|
403
|
+
add('unresolved', 'Unresolved membership (catalogue)', p.populations.unresolved);
|
|
404
|
+
add('selected-references', 'Selected defective references', p.selectedReferences, 'references');
|
|
405
|
+
add('all-references', 'Known reference inventory', p.defectiveReferences, 'references');
|
|
406
|
+
add('unattributable', 'Unattributable findings', p.unattributableFindings, 'findings');
|
|
407
|
+
const view = selectRequirementChain(p);
|
|
408
|
+
const contextUnits = { 0: 'source documents', 1: 'drivers', 2: 'needs', 7: 'definition parts', 8: 'result parts' } as const;
|
|
409
|
+
for (const stage of [0, 1, 2, 7, 8] as const) add('context-' + stage, CHAIN_STAGES[stage],
|
|
410
|
+
set(view.nodes.filter(n => n.stage === stage).map(n => n.id), p.populations.selected.completeness === 'complete'), contextUnits[stage]);
|
|
411
|
+
return freeze(counts);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Monotonic refresh gate shared by reports; late successes and failures cannot replace newer data. */
|
|
415
|
+
export class RequirementChainSnapshot {
|
|
416
|
+
private generation = 0;
|
|
417
|
+
current?: RequirementChainProjection;
|
|
418
|
+
stale = false;
|
|
419
|
+
async refresh(load: () => Promise<ChainInput>): Promise<boolean> {
|
|
420
|
+
const generation = ++this.generation;
|
|
421
|
+
try {
|
|
422
|
+
const input = await load();
|
|
423
|
+
if (generation !== this.generation) return false;
|
|
424
|
+
this.current = buildRequirementChain(input); this.stale = false; return true;
|
|
425
|
+
} catch {
|
|
426
|
+
if (generation === this.generation) this.stale = true;
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -28,6 +28,8 @@ function push<K, V>(map: Map<K, V[]>, key: K, value: V): void {
|
|
|
28
28
|
* (ELEMENT_PRIMITIVES.md §9).
|
|
29
29
|
*/
|
|
30
30
|
export function buildComplianceIndex(input: ComplianceIndexInput): ComplianceIndex {
|
|
31
|
+
const recordsById = new Map<string, import('./types.js').ChainRecord[]>();
|
|
32
|
+
for (const record of input.records ?? []) push(recordsById, record.id, record);
|
|
31
33
|
const requirementById = new Map<string, IndexRequirement>();
|
|
32
34
|
const requirementsByLaw = new Map<string, IndexRequirement[]>();
|
|
33
35
|
const assertionsByRequirement = new Map<string, IndexAssertion[]>();
|
|
@@ -59,6 +61,7 @@ export function buildComplianceIndex(input: ComplianceIndexInput): ComplianceInd
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
return {
|
|
64
|
+
recordsById, findings: input.findings ?? [],
|
|
62
65
|
requirementById, requirementsByLaw, assertionsByRequirement, assertionsBySubject,
|
|
63
66
|
requirementsByParent, verificationsByRequirement,
|
|
64
67
|
needById, requirementsByNeed, validationsByNeed,
|