@transitrix/diagrams 1.13.3 → 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.
@@ -0,0 +1,501 @@
1
+ import { isCanonicalId } from '../typed-id.js';
2
+ import { validateNeed } from '../need/validate.js';
3
+ import { validateRequirement } from '../requirement/validate.js';
4
+ import { checkAgreement } from '../agreement.js';
5
+ import { validateVerification } from '../verification/validate.js';
6
+ export const CHAIN_STAGES = ['Source document', 'Driver', 'Need', 'Stakeholder requirement',
7
+ 'System requirement', 'Software requirement', 'Unclassified requirement', 'Verification definition', 'Result'];
8
+ const text = (v) => typeof v === 'string' && v.trim().length > 0;
9
+ const map = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
10
+ const sorted = (v) => [...new Set(v)].sort();
11
+ export function chainDate(v) {
12
+ return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) &&
13
+ Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v;
14
+ }
15
+ function set(ids, complete) {
16
+ const list = sorted(ids);
17
+ return { ids: list, completeness: complete ? 'complete' : 'incomplete', total: complete ? list.length : null };
18
+ }
19
+ function freeze(value) {
20
+ if (value && typeof value === 'object') {
21
+ for (const child of Object.values(value))
22
+ freeze(child);
23
+ Object.freeze(value);
24
+ }
25
+ return value;
26
+ }
27
+ const CODEX = ['LAW', 'REGULATION', 'STANDARD', 'POLICY', 'INTERNAL_STANDARD', 'PRINCIPLE'];
28
+ const FIELD = ['INTERVIEW', 'SURVEY', 'OBSERVATION', 'DRAFT'];
29
+ /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
30
+ export function buildRequirementChain(input) {
31
+ var _a;
32
+ const { index, asAt, scope } = input;
33
+ const findings = structuredClone(index.findings);
34
+ const scopeFindings = [];
35
+ const records = new Map();
36
+ let complete = input.complete && !findings.some(f => f.severity === 'error');
37
+ const finding = (owner, field, code, message, reference = false, warning = false) => {
38
+ const id = `${owner}.${field}`;
39
+ if (!findings.some(f => f.id === id && f.code === code))
40
+ findings.push({ id, owner, field, code, message, reference, severity: warning ? 'warning' : 'error' });
41
+ };
42
+ for (const [id, versions] of index.recordsById) {
43
+ if (versions.length !== 1) {
44
+ finding(id, 'id', 'DUPLICATE', 'Ambiguous catalogue identity');
45
+ complete = false;
46
+ }
47
+ else
48
+ records.set(id, structuredClone(versions[0]));
49
+ }
50
+ if (!chainDate(asAt)) {
51
+ complete = false;
52
+ scopeFindings.push('Invalid as-at date');
53
+ }
54
+ if (!scope.catalogue || !input.snapshotId) {
55
+ complete = false;
56
+ scopeFindings.push('Missing catalogue or snapshot identity');
57
+ }
58
+ const envelope = new Map();
59
+ const active = new Map();
60
+ for (const r of records.values()) {
61
+ const d = r.raw;
62
+ const dates = chainDate(d.valid_from) && (d.valid_to === null || chainDate(d.valid_to)) &&
63
+ (d.valid_to === null || d.valid_to >= d.valid_from);
64
+ const admitted = chainDate(d.admitted_at) && text(d.admitted_by) && map(d.gate_checks) &&
65
+ Object.keys(d.gate_checks).length > 0 && Object.values(d.gate_checks).every(v => v === 'pass');
66
+ const zone = CODEX.includes(r.type) ? 'codex' : FIELD.includes(r.type) ? 'field' : 'canon';
67
+ const valid = dates && admitted && d.zone === zone && isCanonicalId(r.id);
68
+ if (!isCanonicalId(r.id))
69
+ finding(r.id, 'id', 'IDENTITY', 'Malformed canonical identity');
70
+ for (const v of checkAgreement(d))
71
+ finding(r.id, v.path ?? 'agreement', v.code, v.message);
72
+ envelope.set(r.id, valid);
73
+ active.set(r.id, valid && d.valid_from <= asAt && (d.valid_to === null || asAt <= d.valid_to));
74
+ if (!dates)
75
+ finding(r.id, 'valid_from', 'LIFECYCLE', 'Malformed or missing inclusive lifecycle window');
76
+ if (!admitted || d.zone !== zone)
77
+ finding(r.id, 'admission', 'ADMISSION', 'Invalid admission envelope or zone');
78
+ }
79
+ const validationCatalog = { typeOf: (id) => envelope.get(id) ? records.get(id)?.type : undefined };
80
+ const validNeeds = new Set();
81
+ for (const r of records.values()) {
82
+ const verdict = r.type === 'NEED' ? validateNeed(r.raw, { catalog: validationCatalog }) :
83
+ r.type === 'REQUIREMENT' ? validateRequirement(r.raw, { catalog: validationCatalog }) : undefined;
84
+ if (!verdict)
85
+ continue;
86
+ for (const v of [...verdict.errors, ...verdict.warnings])
87
+ finding(r.id, v.path ?? '', v.code, v.message, ['NEED-002', 'REQ-002', 'REQ-003', 'REQ-SERVES-001'].includes(v.code), verdict.warnings.includes(v));
88
+ if (r.type === 'NEED' && !verdict.errors.some(v => !v.code.startsWith('AGREE-')))
89
+ validNeeds.add(r.id);
90
+ }
91
+ const is = (id, type) => text(id) && records.get(id)?.type === type && envelope.get(id) === true;
92
+ const isProject = (id) => is(id, 'ACTION') && records.get(id)?.raw.type === 'Project';
93
+ const requirements = [...records.values()].filter(r => r.type === 'REQUIREMENT');
94
+ const nodes = [];
95
+ const edges = [];
96
+ function node(r, stage, suffix = '', context = []) {
97
+ nodes.push({ id: r.id + suffix, recordId: r.id, name: text(r.raw.name) ? r.raw.name : r.id,
98
+ stage, sourcePath: r.sourcePath, context, raw: r.raw });
99
+ }
100
+ for (const r of records.values()) {
101
+ if (r.type === 'REQUIREMENT') {
102
+ const levels = ['stakeholder', 'system', 'software'];
103
+ const level = levels.indexOf(String(r.raw.level));
104
+ node(r, level < 0 ? 6 : level + 3);
105
+ if (r.raw.level !== undefined && level < 0)
106
+ finding(r.id, 'level', 'REQ-LEVEL', 'Invalid level; displayed as unclassified');
107
+ }
108
+ else if (r.type === 'NEED')
109
+ node(r, 2);
110
+ else if (r.type === 'DRIVER')
111
+ node(r, 1);
112
+ else if (['PRODUCT', 'RELEASE', 'ACTION'].includes(r.type))
113
+ node(r, -1);
114
+ else if (CODEX.includes(r.type) || FIELD.includes(r.type)) {
115
+ node(r, 0);
116
+ if (r.raw.source_document !== undefined) {
117
+ const d = r.raw.source_document;
118
+ if (!map(d) || !['title', 'uri', 'revision'].every(k => text(d[k])) || d.revision === 'latest') {
119
+ finding(r.id, 'source_document', 'SOURCE-DOC-001', 'Source document needs title, URI and immutable revision');
120
+ }
121
+ }
122
+ }
123
+ }
124
+ function addEdge(kind, from, to, owner, field, allowed, trace, reverse = true, temporal = true) {
125
+ const target = typeof to === 'string' ? to : String(to);
126
+ const identity = `${owner}.${field}`;
127
+ const valid = allowed && temporal;
128
+ if (!valid)
129
+ finding(owner, field, kind === 'parent' ? 'REQ-PARENT-001' : 'REL-002', 'Invalid endpoint, type or lifecycle reference', true);
130
+ const edge = { id: `${kind}:${from}:${target}`, kind, storedFrom: from, storedTo: target,
131
+ from: reverse ? target : from, to: reverse ? from : target, identities: [identity], trace, valid };
132
+ const existing = edges.find(e => e.id === edge.id && e.valid === valid);
133
+ if (existing) {
134
+ existing.identities.push(identity);
135
+ existing.trace || (existing.trace = trace);
136
+ return existing;
137
+ }
138
+ edges.push(edge);
139
+ return edge;
140
+ }
141
+ const contains = (owner, target) => {
142
+ if (!target || !envelope.get(target.id) || !envelope.get(owner.id))
143
+ return false;
144
+ const a = owner.raw, b = target.raw;
145
+ return a.valid_from >= b.valid_from &&
146
+ (b.valid_to === null || (a.valid_to !== null && a.valid_to <= b.valid_to));
147
+ };
148
+ for (const r of requirements) {
149
+ for (const [field, type] of [['parent', 'REQUIREMENT'], ['serves', 'NEED']]) {
150
+ if (r.raw[field] !== undefined)
151
+ addEdge(field === 'parent' ? 'parent' : field, r.id, r.raw[field], r.id, field, is(r.raw[field], type), true, true, contains(r, records.get(String(r.raw[field]))));
152
+ }
153
+ if (r.raw.derived_from !== undefined) {
154
+ const refs = Array.isArray(r.raw.derived_from) ? r.raw.derived_from : [];
155
+ refs.forEach((ref, i) => addEdge('derived_from', r.id, ref, r.id, `derived_from[${i}]`, text(ref) && CODEX.includes(records.get(ref)?.type ?? '') && records.get(ref)?.raw.zone === 'codex', true, true, contains(r, records.get(String(ref)))));
156
+ }
157
+ }
158
+ const rels = [...records.values()].filter(r => r.type === 'REL');
159
+ const usable = new Set();
160
+ for (const r of rels) {
161
+ const d = r.raw;
162
+ const from = String(d.from);
163
+ const to = String(d.to);
164
+ const kind = String(d.type);
165
+ const src = records.get(from), dst = records.get(to);
166
+ const endpoints = kind === 'requirement_parent' ? is(from, 'REQUIREMENT') && is(to, 'REQUIREMENT') :
167
+ kind === 'serves' ? is(from, 'REQUIREMENT') && is(to, 'NEED') :
168
+ kind === 'product_scope' ? is(from, 'REQUIREMENT') && is(to, 'PRODUCT') :
169
+ kind === 'project_scope' ? is(from, 'REQUIREMENT') && isProject(to) :
170
+ kind === 'project_product' ? isProject(from) && is(to, 'PRODUCT') :
171
+ kind === 'required_for' ? is(from, 'REQUIREMENT') && is(to, 'RELEASE') :
172
+ kind === 'source_trace' ? !!src && !!dst && envelope.get(from) === true && envelope.get(to) === true &&
173
+ (['REQUIREMENT', 'NEED'].includes(src.type) && (dst.type === 'DRIVER' || FIELD.includes(dst.type)) ||
174
+ src.type === 'DRIVER' && FIELD.includes(dst.type)) : !!src && !!dst;
175
+ const temporal = contains(r, src) && contains(r, dst);
176
+ if (!temporal)
177
+ finding(r.id, 'to', 'REL-003', 'Relation window must be contained in both endpoints', true);
178
+ const trace = ['requirement_parent', 'serves', 'source_trace'].includes(kind);
179
+ addEdge(kind === 'requirement_parent' ? 'parent' : kind, from, to, r.id, 'to', endpoints, trace && active.get(r.id) === true, trace, temporal);
180
+ if (endpoints && temporal && active.get(r.id))
181
+ usable.add(r.id);
182
+ }
183
+ // Cycle defects retain traversal edges so a separate source path is not erased.
184
+ const parents = edges.filter(e => e.kind === 'parent' && e.trace && e.valid);
185
+ const reachable = (seed, candidates, direction) => {
186
+ const seen = new Set([seed]);
187
+ const queue = [seed];
188
+ while (queue.length) {
189
+ const id = queue.pop();
190
+ for (const e of candidates) {
191
+ const a = direction === 'upstream' ? e.to : e.from;
192
+ const b = direction === 'upstream' ? e.from : e.to;
193
+ if (a === id && !seen.has(b)) {
194
+ seen.add(b);
195
+ queue.push(b);
196
+ }
197
+ }
198
+ }
199
+ return seen;
200
+ };
201
+ for (const e of parents) {
202
+ if (reachable(e.to, parents, 'downstream').has(e.from)) {
203
+ for (const identity of e.identities) {
204
+ const split = identity.lastIndexOf('.');
205
+ finding(identity.slice(0, split), identity.slice(split + 1), e.from === e.to ? 'REL-009' : 'REL-010', 'Decomposition cycle', true);
206
+ }
207
+ }
208
+ }
209
+ const memberships = (id, kind) => rels.filter(r => usable.has(r.id) && r.raw.type === kind && r.raw.from === id).map(r => String(r.raw.to));
210
+ const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0).map(r => r.id);
211
+ const P = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').includes(scope.product ?? '')).map(r => r.id);
212
+ let productComplete = complete && unresolved.length === 0;
213
+ if (unresolved.length)
214
+ scopeFindings.push('Unresolved product membership');
215
+ if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === 'product_scope' && active.get(r.id) && !usable.has(r.id)))
216
+ productComplete = false;
217
+ const invalidAssignments = new Set();
218
+ for (const r of rels.filter(r => r.raw.type === 'required_for')) {
219
+ const from = String(r.raw.from), to = records.get(String(r.raw.to));
220
+ const historical = chainDate(r.raw.valid_to) && r.raw.valid_to < asAt;
221
+ const future = chainDate(r.raw.valid_from) && r.raw.valid_from > asAt;
222
+ if (!historical && !future && (!usable.has(r.id) || !memberships(from, 'product_scope').includes(String(to?.raw.of)))) {
223
+ invalidAssignments.add(from);
224
+ finding(r.id, 'to', 'ASSIGNMENT', 'Invalid, dangling or wrong-product assignment', true);
225
+ if (!envelope.get(r.id))
226
+ productComplete = false;
227
+ }
228
+ }
229
+ let selectedComplete = complete;
230
+ if (!is(scope.product, 'PRODUCT')) {
231
+ productComplete = false;
232
+ selectedComplete = false;
233
+ scopeFindings.push('Product unselected or unresolved');
234
+ }
235
+ if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== scope.product) {
236
+ selectedComplete = false;
237
+ scopeFindings.push('Release unselected, unresolved or wrong product');
238
+ }
239
+ if (scope.project && (!isProject(scope.project) || !memberships(scope.project, 'project_product').includes(scope.product ?? ''))) {
240
+ selectedComplete = false;
241
+ scopeFindings.push('Invalid or unmodelled project/product pair');
242
+ }
243
+ function query(release) {
244
+ const chain = [];
245
+ let cursor = release;
246
+ let valid = true;
247
+ while (cursor) {
248
+ const r = records.get(cursor);
249
+ if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== scope.product) {
250
+ valid = false;
251
+ finding(chain[chain.length - 1] ?? release, 'predecessor', 'RELEASE-SCOPE', chain.includes(cursor) ? 'Release predecessor cycle' : 'Missing, invalid or cross-product predecessor');
252
+ break;
253
+ }
254
+ chain.push(cursor);
255
+ cursor = r?.raw.predecessor === undefined || r.raw.predecessor === null ? undefined : String(r.raw.predecessor);
256
+ }
257
+ if (!valid)
258
+ scopeFindings.push(`Incomplete predecessor chain: ${release}`);
259
+ const provenance = [];
260
+ for (const id of P) {
261
+ const attachments = rels.filter(r => usable.has(r.id) && r.raw.type === 'required_for' && r.raw.from === id && chain.includes(String(r.raw.to)))
262
+ .sort((a, b) => chain.indexOf(String(a.raw.to)) - chain.indexOf(String(b.raw.to)) || a.id.localeCompare(b.id));
263
+ if (attachments.length)
264
+ provenance.push({ requirement: id, nearest: String(attachments[0].raw.to),
265
+ depth: chain.indexOf(String(attachments[0].raw.to)), relations: attachments.map(r => r.id) });
266
+ }
267
+ return { ids: provenance.map(p => p.requirement), provenance, complete: valid };
268
+ }
269
+ const selected = scope.release ? query(scope.release) : { ids: [], provenance: [], complete: false };
270
+ const L = selected.ids;
271
+ selectedComplete && (selectedComplete = selected.complete);
272
+ if (rels.some(r => r.raw.type === 'required_for' && String(r.raw.to) === scope.release && unresolved.includes(String(r.raw.from))))
273
+ selectedComplete = false;
274
+ 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)))) {
275
+ selectedComplete = false;
276
+ scopeFindings.push('Unresolved project membership');
277
+ }
278
+ if (rels.some(r => ['product_scope', 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id)))
279
+ selectedComplete = false;
280
+ if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, 'project_product').includes(scope.product ?? '')))) {
281
+ selectedComplete = false;
282
+ scopeFindings.push('Inconsistent project/product membership');
283
+ }
284
+ const S = L.filter(id => !scope.project || memberships(id, 'project_scope').includes(scope.project));
285
+ const elsewhere = new Set();
286
+ for (const release of records.values())
287
+ if (release.type === 'RELEASE' && release.raw.of === scope.product) {
288
+ const q = query(release.id);
289
+ productComplete && (productComplete = q.complete);
290
+ q.ids.forEach(id => elsewhere.add(id));
291
+ }
292
+ const unassigned = P.filter(id => !elsewhere.has(id) && !invalidAssignments.has(id));
293
+ const definitions = {}, executions = {};
294
+ const verifications = [...records.values()].filter(r => r.type === 'VERIFICATION');
295
+ const catalog = { typeOf: (id) => envelope.get(id) ? records.get(id)?.type : undefined };
296
+ for (const r of verifications) {
297
+ const d = r.raw, target = String(d.verifies);
298
+ const verdict = validateVerification(d, { catalog });
299
+ for (const v of [...verdict.errors, ...verdict.warnings])
300
+ finding(r.id, v.path ?? '', v.code, v.message, v.code === 'VERIF-002' || v.code === 'VERIF-005', verdict.warnings.includes(v));
301
+ const definitionErrors = verdict.errors.filter(v => !['outcome', 'evidence', 'performed_at', 'verified_on', 'result'].some(k => v.path?.startsWith(k)));
302
+ const validDefinition = active.get(r.id) && definitionErrors.length === 0 && is(d.verifies, 'REQUIREMENT') && contains(r, records.get(target));
303
+ let executionValid = verdict.errors.length === 0;
304
+ if (d.performed_at !== undefined && (!chainDate(d.performed_at) || d.performed_at > asAt)) {
305
+ finding(r.id, 'performed_at', 'EXECUTION-DATE', 'Malformed or future execution date');
306
+ executionValid = false;
307
+ }
308
+ else if (d.performed_at === undefined)
309
+ finding(r.id, 'performed_at', 'EXECUTION-DATE', 'Execution date absent', false, true);
310
+ if (d.result !== undefined && !text(d.result)) {
311
+ finding(r.id, 'result', 'VERIF-001', 'Malformed result narrative');
312
+ executionValid = false;
313
+ }
314
+ if (Array.isArray(d.evidence))
315
+ d.evidence.forEach((v, i) => {
316
+ if (!map(v) || !['note', 'canonical_ref', 'external_doc'].includes(String(v.kind)) ||
317
+ (v.kind === 'note' && !text(v.text)) || (v.kind === 'external_doc' && (!text(v.url) || !text(v.title)))) {
318
+ finding(r.id, `evidence[${i}]`, 'VERIF-001', 'Malformed evidence entry');
319
+ executionValid = false;
320
+ }
321
+ });
322
+ if (d.verified_on !== undefined && !is(d.verified_on, 'RELEASE')) {
323
+ finding(r.id, 'verified_on', 'VERIF-RELEASE', 'Unresolved release qualifier', true);
324
+ executionValid = false;
325
+ }
326
+ const context = !active.get(r.id) ? ['inactive'] : !validDefinition ? ['invalid definition'] : [];
327
+ if (d.verified_on === undefined)
328
+ context.push('unqualified');
329
+ else if (d.verified_on !== scope.release)
330
+ context.push('other release');
331
+ if (!executionValid)
332
+ context.push('malformed or future execution');
333
+ node(r, 7, '.definition', [...context]);
334
+ const verificationEdge = addEdge('verifies', r.id + '.definition', target, r.id, 'verifies', is(d.verifies, 'REQUIREMENT'), true, true, contains(r, records.get(target)));
335
+ verificationEdge.storedFrom = r.id;
336
+ if (['pass', 'fail', 'inconclusive'].includes(String(d.outcome))) {
337
+ node(r, 8, '.result', [...context]);
338
+ addEdge('result', r.id + '.definition', r.id + '.result', r.id, 'result', true, true, false);
339
+ }
340
+ if (validDefinition)
341
+ (definitions[target] ?? (definitions[target] = [])).push(r.id);
342
+ if (validDefinition && executionValid && d.verified_on === scope.release && ['pass', 'fail', 'inconclusive'].includes(String(d.outcome)))
343
+ (executions[target] ?? (executions[target] = [])).push(r.id);
344
+ }
345
+ const traceEdges = edges.filter(e => e.trace && e.valid);
346
+ const affectedRequirements = {};
347
+ const noSource = [];
348
+ for (const r of requirements) {
349
+ const ancestors = reachable(r.id, traceEdges, 'upstream');
350
+ const owners = new Set(ancestors);
351
+ for (const v of verifications)
352
+ if (v.raw.verifies === r.id)
353
+ owners.add(v.id);
354
+ for (const rel of rels) {
355
+ const ownScope = ['product_scope', 'project_scope', 'required_for'].includes(String(rel.raw.type));
356
+ if (ownScope ? rel.raw.from === r.id : owners.has(String(rel.raw.from)))
357
+ owners.add(rel.id);
358
+ }
359
+ for (const f of findings)
360
+ if (owners.has(f.owner))
361
+ (affectedRequirements[_a = f.id] ?? (affectedRequirements[_a] = [])).push(r.id);
362
+ const source = [...ancestors].some(id => {
363
+ const a = records.get(id);
364
+ if (!a || !active.get(id))
365
+ return false;
366
+ return (a.type === 'NEED' && validNeeds.has(id)) || a.type === 'DRIVER' || CODEX.includes(a.type) ||
367
+ FIELD.includes(a.type) && map(a.raw.source_document) && !findings.some(f => f.owner === id && f.code === 'SOURCE-DOC-001');
368
+ });
369
+ if (!source && S.includes(r.id))
370
+ noSource.push(r.id);
371
+ }
372
+ for (const collection of [affectedRequirements, definitions, executions]) {
373
+ for (const key of Object.keys(collection))
374
+ collection[key] = sorted(collection[key]);
375
+ }
376
+ findings.sort((a, b) => a.id.localeCompare(b.id) || a.code.localeCompare(b.code));
377
+ const refs = findings.filter(f => f.reference);
378
+ const selectedRefs = refs.filter(f => affectedRequirements[f.id]?.some(id => S.includes(id)));
379
+ const broken = S.filter(id => selectedRefs.some(f => affectedRequirements[f.id]?.includes(id)));
380
+ for (const n of nodes)
381
+ if (records.get(n.recordId)?.type === 'REQUIREMENT' && !S.includes(n.recordId)) {
382
+ n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? 'unresolved or other product' :
383
+ !L.includes(n.recordId) ? 'other release or unassigned' : 'other project');
384
+ }
385
+ const unassignedSet = set(unassigned, productComplete);
386
+ const result = {
387
+ contract: 'requirement-chain/0.2', contractRevision: '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
388
+ scope: structuredClone(scope), completeness: productComplete && selectedComplete ? 'complete' : 'incomplete', scopeFindings: sorted(scopeFindings),
389
+ populations: { product: set(P, productComplete), release: set(L, selectedComplete), selected: set(S, selectedComplete),
390
+ 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),
391
+ unresolved: set(unresolved, complete) },
392
+ assignments: { here: set(L, selectedComplete), otherReleaseOnly: set(P.filter(id => elsewhere.has(id) && !L.includes(id)), productComplete),
393
+ unassigned: unassignedSet, invalid: set(P.filter(id => invalidAssignments.has(id)), complete), provenance: selected.provenance.sort((a, b) => a.requirement.localeCompare(b.requirement)) },
394
+ stages: CHAIN_STAGES.map((_, stage) => set(nodes.filter(n => n.stage === stage && S.includes(n.id)).map(n => n.id), selectedComplete)),
395
+ metrics: { broken: set(broken, selectedComplete), noSource: set(noSource, selectedComplete),
396
+ noDefinition: set(S.filter(id => !definitions[id]?.length), selectedComplete),
397
+ noResult: set(S.filter(id => definitions[id]?.length && !executions[id]?.length), selectedComplete),
398
+ failed: set(S.filter(id => executions[id]?.some(v => records.get(v)?.raw.outcome === 'fail')), selectedComplete), unassigned: unassignedSet },
399
+ records: structuredClone([...index.recordsById.values()].flat()).sort((a, b) => a.id.localeCompare(b.id) || a.sourcePath.localeCompare(b.sourcePath)),
400
+ nodes: nodes.sort((a, b) => a.stage - b.stage || a.id.localeCompare(b.id)),
401
+ 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)),
402
+ findings, defectiveReferences: set(refs.map(f => f.id), complete), selectedReferences: set(selectedRefs.map(f => f.id), selectedComplete),
403
+ unattributableFindings: set(findings.filter(f => !affectedRequirements[f.id]?.length).map(f => f.id), complete),
404
+ affectedRequirements, definitions, executions,
405
+ };
406
+ return freeze(result);
407
+ }
408
+ /** Both is two independent walks, never an expansion from the union frontier. */
409
+ export function selectRequirementChain(p, focus, direction = 'both') {
410
+ const ids = new Set();
411
+ const edgeIds = new Set();
412
+ const walk = (seeds, upstream) => {
413
+ const seen = new Set(seeds), queue = [...seeds];
414
+ seeds.forEach(id => ids.add(id));
415
+ while (queue.length) {
416
+ const id = queue.pop();
417
+ for (const e of p.edges.filter(e => e.trace)) {
418
+ if ((upstream ? e.to : e.from) !== id)
419
+ continue;
420
+ edgeIds.add(e.id);
421
+ const next = upstream ? e.from : e.to;
422
+ if (e.valid && p.nodes.some(n => n.id === next) && !seen.has(next)) {
423
+ seen.add(next);
424
+ ids.add(next);
425
+ queue.push(next);
426
+ }
427
+ }
428
+ }
429
+ };
430
+ if (focus) {
431
+ if (direction !== 'downstream')
432
+ walk([focus], true);
433
+ if (direction !== 'upstream')
434
+ walk([focus], false);
435
+ }
436
+ else {
437
+ walk(p.populations.selected.ids, true);
438
+ for (const e of p.edges)
439
+ if (e.kind === 'verifies' && p.populations.selected.ids.includes(e.from)) {
440
+ ids.add(e.to);
441
+ edgeIds.add(e.id);
442
+ for (const result of p.edges.filter(x => x.kind === 'result' && x.from === e.to)) {
443
+ ids.add(result.to);
444
+ edgeIds.add(result.id);
445
+ }
446
+ }
447
+ }
448
+ return { nodes: p.nodes.filter(n => ids.has(n.id)), edges: p.edges.filter(e => edgeIds.has(e.id)) };
449
+ }
450
+ /** Count/list contract for the release report, derived solely from the shared snapshot. */
451
+ export function requirementReleaseCounts(p) {
452
+ const counts = {};
453
+ const add = (key, label, value, unit = 'requirements') => {
454
+ counts[key] = { label, unit, set: value };
455
+ };
456
+ const labels = { broken: 'Broken references', noSource: 'No accepted source path', noDefinition: 'No valid verification definition',
457
+ noResult: 'No applicable executed result (defined verification)', failed: 'Applicable failed verification', unassigned: 'No effective release assignment (whole product)' };
458
+ for (const key of Object.keys(labels))
459
+ add('metric-' + key, labels[key], p.metrics[key]);
460
+ for (let stage = 3; stage <= 6; stage++) {
461
+ add('stage-' + stage, CHAIN_STAGES[stage], p.stages[stage]);
462
+ 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'));
463
+ }
464
+ add('selected', 'Selected requirements', p.populations.selected);
465
+ add('product', 'Product requirements', p.populations.product);
466
+ add('here', 'Assigned here (whole product)', p.assignments.here);
467
+ add('other', 'Other-release-only (whole product)', p.assignments.otherReleaseOnly);
468
+ add('invalid', 'Invalid or dangling assignment (whole product)', p.assignments.invalid);
469
+ add('unresolved', 'Unresolved membership (catalogue)', p.populations.unresolved);
470
+ add('selected-references', 'Selected defective references', p.selectedReferences, 'references');
471
+ add('all-references', 'Known reference inventory', p.defectiveReferences, 'references');
472
+ add('unattributable', 'Unattributable findings', p.unattributableFindings, 'findings');
473
+ const view = selectRequirementChain(p);
474
+ const contextUnits = { 0: 'source documents', 1: 'drivers', 2: 'needs', 7: 'definition parts', 8: 'result parts' };
475
+ for (const stage of [0, 1, 2, 7, 8])
476
+ add('context-' + stage, CHAIN_STAGES[stage], set(view.nodes.filter(n => n.stage === stage).map(n => n.id), p.populations.selected.completeness === 'complete'), contextUnits[stage]);
477
+ return freeze(counts);
478
+ }
479
+ /** Monotonic refresh gate shared by reports; late successes and failures cannot replace newer data. */
480
+ export class RequirementChainSnapshot {
481
+ constructor() {
482
+ this.generation = 0;
483
+ this.stale = false;
484
+ }
485
+ async refresh(load) {
486
+ const generation = ++this.generation;
487
+ try {
488
+ const input = await load();
489
+ if (generation !== this.generation)
490
+ return false;
491
+ this.current = buildRequirementChain(input);
492
+ this.stale = false;
493
+ return true;
494
+ }
495
+ catch {
496
+ if (generation === this.generation)
497
+ this.stale = true;
498
+ return false;
499
+ }
500
+ }
501
+ }
@@ -18,6 +18,9 @@ function push(map, key, value) {
18
18
  * (ELEMENT_PRIMITIVES.md §9).
19
19
  */
20
20
  export function buildComplianceIndex(input) {
21
+ const recordsById = new Map();
22
+ for (const record of input.records ?? [])
23
+ push(recordsById, record.id, record);
21
24
  const requirementById = new Map();
22
25
  const requirementsByLaw = new Map();
23
26
  const assertionsByRequirement = new Map();
@@ -50,6 +53,7 @@ export function buildComplianceIndex(input) {
50
53
  push(validationsByNeed, v.validates, v);
51
54
  }
52
55
  return {
56
+ recordsById, findings: input.findings ?? [],
53
57
  requirementById, requirementsByLaw, assertionsByRequirement, assertionsBySubject,
54
58
  requirementsByParent, verificationsByRequirement,
55
59
  needById, requirementsByNeed, validationsByNeed,
@@ -162,6 +162,8 @@ export interface ObjectDetailInput {
162
162
  details: ObjectDetailDef[];
163
163
  }
164
164
  export interface ComplianceIndexInput {
165
+ records?: ChainRecord[];
166
+ findings?: ChainFinding[];
165
167
  requirements: IndexRequirement[];
166
168
  assertions: IndexAssertion[];
167
169
  /** Optional — absent inputs (existing callers) simply index no verifications. */
@@ -173,6 +175,8 @@ export interface ComplianceIndexInput {
173
175
  }
174
176
  /** The reverse-index — all maps are keyed by canonical id. */
175
177
  export interface ComplianceIndex {
178
+ recordsById: Map<string, ChainRecord[]>;
179
+ findings: ChainFinding[];
176
180
  requirementById: Map<string, IndexRequirement>;
177
181
  /** Codex artefact id → requirements whose `derived_from` names it. */
178
182
  requirementsByLaw: Map<string, IndexRequirement[]>;
@@ -284,3 +288,19 @@ export interface TraceElementCatalog {
284
288
  /** id → human-readable name (or the id itself when unnamed). */
285
289
  nameById: Map<string, string>;
286
290
  }
291
+ /** Lossless records retained beside the compatible legacy projections. */
292
+ export interface ChainRecord {
293
+ id: string;
294
+ type: string;
295
+ sourcePath: string;
296
+ raw: Record<string, unknown>;
297
+ }
298
+ export interface ChainFinding {
299
+ id: string;
300
+ owner: string;
301
+ field: string;
302
+ code: string;
303
+ message: string;
304
+ reference: boolean;
305
+ severity: 'error' | 'warning';
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.13.3",
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",
@@ -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
+ }
@@ -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';