@transitrix/diagrams 1.13.3 → 1.14.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/action-fields.d.ts +6 -0
- package/dist/activities/action-fields.js +34 -0
- package/dist/activities/validate.d.ts +3 -1
- package/dist/activities/validate.js +36 -34
- package/dist/applications/validate.js +27 -26
- package/dist/capability-map/validate.d.ts +4 -1
- package/dist/capability-map/validate.js +32 -30
- package/dist/codex/validate.js +3 -3
- 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/coverage-metric.js +3 -3
- package/dist/compliance/impact.js +4 -4
- 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.d.ts +0 -25
- package/dist/fgca/parse-canonical.js +16 -26
- package/dist/fgca/projection-origin.d.ts +2 -0
- package/dist/fgca/projection-origin.js +2 -0
- package/dist/fgca/resolver.d.ts +0 -8
- package/dist/fgca/resolver.js +2 -0
- package/dist/products/validate.js +30 -25
- package/dist/repo-validate/check-strategy-chain.d.ts +8 -2
- package/dist/repo-validate/check-strategy-chain.js +64 -64
- package/dist/repo-validate/types.d.ts +2 -0
- package/dist/repo-validate/validate-repo.js +2 -0
- package/dist/risk/index.d.ts +1 -1
- package/dist/risk/types.d.ts +8 -1
- package/dist/risk/validate.js +19 -4
- package/dist/scenarios/validate.js +4 -0
- package/dist/schema-finding.d.ts +3 -0
- package/dist/schema-finding.js +6 -0
- package/dist/webview/entry.js +7 -2
- package/dist/webview/render-goals.js +2 -1
- package/package.json +1 -1
- package/src/activities/action-fields.ts +31 -0
- package/src/activities/validate.ts +37 -34
- package/src/applications/validate.ts +27 -26
- package/src/capability-map/validate.ts +33 -29
- package/src/codex/validate.ts +3 -3
- package/src/compliance/canon-catalog.ts +1 -1
- package/src/compliance/classify.ts +24 -1
- package/src/compliance/coverage-metric.ts +3 -3
- package/src/compliance/impact.ts +4 -4
- 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 +14 -1
- package/src/fgca/projection-origin.ts +2 -0
- package/src/fgca/resolver.ts +2 -0
- package/src/products/validate.ts +31 -25
- package/src/repo-validate/check-strategy-chain.ts +69 -64
- package/src/repo-validate/types.ts +2 -0
- package/src/repo-validate/validate-repo.ts +2 -0
- package/src/risk/index.ts +1 -1
- package/src/risk/types.ts +9 -1
- package/src/risk/validate.ts +19 -5
- package/src/scenarios/validate.ts +5 -0
- package/src/schema-finding.ts +8 -0
- package/src/webview/entry.ts +6 -2
- package/src/webview/render-goals.ts +2 -1
- package/src/webview/styles.css +8 -0
package/src/compliance/impact.ts
CHANGED
|
@@ -111,13 +111,13 @@ export function collectImpactViewResolutionFindings(
|
|
|
111
111
|
const t = catalog.typeOf(id);
|
|
112
112
|
if (t === undefined) {
|
|
113
113
|
findings.push({
|
|
114
|
-
code: 'COMPIMP-
|
|
114
|
+
code: 'COMPIMP-003',
|
|
115
115
|
severity: 'error',
|
|
116
116
|
message: `${path}: "${id}" does not resolve to an admitted artefact.`,
|
|
117
117
|
});
|
|
118
118
|
} else if (t !== expectedType) {
|
|
119
119
|
findings.push({
|
|
120
|
-
code: 'COMPIMP-
|
|
120
|
+
code: 'COMPIMP-003',
|
|
121
121
|
severity: 'error',
|
|
122
122
|
message: `${path}: "${id}" resolves to ${t}, not ${expectedType}.`,
|
|
123
123
|
});
|
|
@@ -129,7 +129,7 @@ export function collectImpactViewResolutionFindings(
|
|
|
129
129
|
const t = catalog.typeOf(id);
|
|
130
130
|
if (t === undefined || t !== 'REQUIREMENT') {
|
|
131
131
|
findings.push({
|
|
132
|
-
code: 'COMPIMP-
|
|
132
|
+
code: 'COMPIMP-003',
|
|
133
133
|
severity: 'error',
|
|
134
134
|
message: `obligations.include: "${id}" does not resolve to an admitted REQUIREMENT.`,
|
|
135
135
|
});
|
|
@@ -139,7 +139,7 @@ export function collectImpactViewResolutionFindings(
|
|
|
139
139
|
for (const id of config.obligations.filter?.derived_from_codex ?? []) {
|
|
140
140
|
if (!codexIds.has(id)) {
|
|
141
141
|
findings.push({
|
|
142
|
-
code: 'COMPIMP-
|
|
142
|
+
code: 'COMPIMP-003',
|
|
143
143
|
severity: 'error',
|
|
144
144
|
message: `obligations.filter.derived_from_codex: "${id}" does not resolve to an admitted codex artefact.`,
|
|
145
145
|
});
|
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,
|
package/src/compliance/types.ts
CHANGED
|
@@ -176,6 +176,8 @@ export interface ObjectDetailInput {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
export interface ComplianceIndexInput {
|
|
179
|
+
records?: ChainRecord[];
|
|
180
|
+
findings?: ChainFinding[];
|
|
179
181
|
requirements: IndexRequirement[];
|
|
180
182
|
assertions: IndexAssertion[];
|
|
181
183
|
/** Optional — absent inputs (existing callers) simply index no verifications. */
|
|
@@ -188,6 +190,8 @@ export interface ComplianceIndexInput {
|
|
|
188
190
|
|
|
189
191
|
/** The reverse-index — all maps are keyed by canonical id. */
|
|
190
192
|
export interface ComplianceIndex {
|
|
193
|
+
recordsById: Map<string, ChainRecord[]>;
|
|
194
|
+
findings: ChainFinding[];
|
|
191
195
|
requirementById: Map<string, IndexRequirement>;
|
|
192
196
|
/** Codex artefact id → requirements whose `derived_from` names it. */
|
|
193
197
|
requirementsByLaw: Map<string, IndexRequirement[]>;
|
|
@@ -313,3 +317,20 @@ export interface TraceElementCatalog {
|
|
|
313
317
|
/** id → human-readable name (or the id itself when unnamed). */
|
|
314
318
|
nameById: Map<string, string>;
|
|
315
319
|
}
|
|
320
|
+
|
|
321
|
+
/** Lossless records retained beside the compatible legacy projections. */
|
|
322
|
+
export interface ChainRecord {
|
|
323
|
+
id: string;
|
|
324
|
+
type: string;
|
|
325
|
+
sourcePath: string;
|
|
326
|
+
raw: Record<string, unknown>;
|
|
327
|
+
}
|
|
328
|
+
export interface ChainFinding {
|
|
329
|
+
id: string;
|
|
330
|
+
owner: string;
|
|
331
|
+
field: string;
|
|
332
|
+
code: string;
|
|
333
|
+
message: string;
|
|
334
|
+
reference: boolean;
|
|
335
|
+
severity: 'error' | 'warning';
|
|
336
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RESOLVED_DGCA } from './projection-origin.js';
|
|
1
2
|
/**
|
|
2
3
|
* Canonical-form FGCA parser + validator.
|
|
3
4
|
*
|
|
@@ -167,6 +168,15 @@ export function parseCanonicalFGCA(
|
|
|
167
168
|
|
|
168
169
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
169
170
|
|
|
171
|
+
if (!(input as { [RESOLVED_DGCA]?: boolean })[RESOLVED_DGCA]) {
|
|
172
|
+
for (const [field, values] of [['factors', factorsRaw], ['goals', goalsRaw], ['actions', activitiesRaw], ['changes', changesRaw]] as const) {
|
|
173
|
+
if (field === 'changes' && isDgcaChangesLayerOff(raw)) continue;
|
|
174
|
+
if (values?.length === 0) errors.push({ code: 'DGCA-004', path: field,
|
|
175
|
+
message: `Inline DGCA ${field} must be nonempty.` });
|
|
176
|
+
}
|
|
177
|
+
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
// From here we know all four arrays exist.
|
|
171
181
|
const factors = factorsRaw!;
|
|
172
182
|
const goals = goalsRaw!;
|
|
@@ -435,7 +445,10 @@ export function parseCanonicalFGA(
|
|
|
435
445
|
// changes. Per-layer / per-ref checks all reuse the FGCA implementation;
|
|
436
446
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
437
447
|
// are unreachable here because `changes` is empty.
|
|
438
|
-
const
|
|
448
|
+
const vc = raw.view_config && typeof raw.view_config === 'object' ? raw.view_config as Record<string, unknown> : {};
|
|
449
|
+
const layers = vc.layers && typeof vc.layers === 'object' ? vc.layers as Record<string, unknown> : {};
|
|
450
|
+
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [],
|
|
451
|
+
view_config: { ...vc, layers: { ...layers, changes: 'off' } } };
|
|
439
452
|
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
440
453
|
const remap: Record<string, string> = {
|
|
441
454
|
'FGCA-001': 'FGA-001',
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RESOLVED_DGCA } from './projection-origin.js';
|
|
1
2
|
/**
|
|
2
3
|
* FGCA canon resolver — assembles a flat canonical FGCA document from a
|
|
3
4
|
* view_config projection and a canon element/relation store (VP-3).
|
|
@@ -182,6 +183,7 @@ export function resolveFGCA(
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
return {
|
|
186
|
+
[RESOLVED_DGCA]: true,
|
|
185
187
|
notation: 'dgca',
|
|
186
188
|
id: viewDoc['id'],
|
|
187
189
|
name: viewDoc['name'],
|