@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.
Files changed (67) hide show
  1. package/dist/activities/action-fields.d.ts +6 -0
  2. package/dist/activities/action-fields.js +34 -0
  3. package/dist/activities/validate.d.ts +3 -1
  4. package/dist/activities/validate.js +36 -34
  5. package/dist/applications/validate.js +27 -26
  6. package/dist/capability-map/validate.d.ts +4 -1
  7. package/dist/capability-map/validate.js +32 -30
  8. package/dist/codex/validate.js +3 -3
  9. package/dist/compliance/canon-catalog.js +1 -1
  10. package/dist/compliance/classify.d.ts +4 -1
  11. package/dist/compliance/classify.js +21 -1
  12. package/dist/compliance/coverage-metric.js +3 -3
  13. package/dist/compliance/impact.js +4 -4
  14. package/dist/compliance/index.d.ts +3 -0
  15. package/dist/compliance/index.js +1 -0
  16. package/dist/compliance/requirement-chain.d.ts +111 -0
  17. package/dist/compliance/requirement-chain.js +501 -0
  18. package/dist/compliance/reverse-index.js +4 -0
  19. package/dist/compliance/types.d.ts +20 -0
  20. package/dist/fgca/parse-canonical.d.ts +0 -25
  21. package/dist/fgca/parse-canonical.js +16 -26
  22. package/dist/fgca/projection-origin.d.ts +2 -0
  23. package/dist/fgca/projection-origin.js +2 -0
  24. package/dist/fgca/resolver.d.ts +0 -8
  25. package/dist/fgca/resolver.js +2 -0
  26. package/dist/products/validate.js +30 -25
  27. package/dist/repo-validate/check-strategy-chain.d.ts +8 -2
  28. package/dist/repo-validate/check-strategy-chain.js +64 -64
  29. package/dist/repo-validate/types.d.ts +2 -0
  30. package/dist/repo-validate/validate-repo.js +2 -0
  31. package/dist/risk/index.d.ts +1 -1
  32. package/dist/risk/types.d.ts +8 -1
  33. package/dist/risk/validate.js +19 -4
  34. package/dist/scenarios/validate.js +4 -0
  35. package/dist/schema-finding.d.ts +3 -0
  36. package/dist/schema-finding.js +6 -0
  37. package/dist/webview/entry.js +7 -2
  38. package/dist/webview/render-goals.js +2 -1
  39. package/package.json +1 -1
  40. package/src/activities/action-fields.ts +31 -0
  41. package/src/activities/validate.ts +37 -34
  42. package/src/applications/validate.ts +27 -26
  43. package/src/capability-map/validate.ts +33 -29
  44. package/src/codex/validate.ts +3 -3
  45. package/src/compliance/canon-catalog.ts +1 -1
  46. package/src/compliance/classify.ts +24 -1
  47. package/src/compliance/coverage-metric.ts +3 -3
  48. package/src/compliance/impact.ts +4 -4
  49. package/src/compliance/index.ts +3 -0
  50. package/src/compliance/requirement-chain.ts +430 -0
  51. package/src/compliance/reverse-index.ts +3 -0
  52. package/src/compliance/types.ts +21 -0
  53. package/src/fgca/parse-canonical.ts +14 -1
  54. package/src/fgca/projection-origin.ts +2 -0
  55. package/src/fgca/resolver.ts +2 -0
  56. package/src/products/validate.ts +31 -25
  57. package/src/repo-validate/check-strategy-chain.ts +69 -64
  58. package/src/repo-validate/types.ts +2 -0
  59. package/src/repo-validate/validate-repo.ts +2 -0
  60. package/src/risk/index.ts +1 -1
  61. package/src/risk/types.ts +9 -1
  62. package/src/risk/validate.ts +19 -5
  63. package/src/scenarios/validate.ts +5 -0
  64. package/src/schema-finding.ts +8 -0
  65. package/src/webview/entry.ts +6 -2
  66. package/src/webview/render-goals.ts +2 -1
  67. package/src/webview/styles.css +8 -0
@@ -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-REF',
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-REF',
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-REF',
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-REF',
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
  });
@@ -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,
@@ -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 synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
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',
@@ -0,0 +1,2 @@
1
+ /** Marks an in-memory projection result without admitting a YAML override. */
2
+ export const RESOLVED_DGCA = Symbol.for('@transitrix/diagrams/resolved-dgca');
@@ -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'],