@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
|
@@ -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
|
+
}
|
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Canonical-form FGCA parser + validator.
|
|
3
|
-
*
|
|
4
|
-
* Accepts the methodology's canonical FGCA YAML shape (see
|
|
5
|
-
* `transitrix/methodology` `notations/02-fgca.md`):
|
|
6
|
-
*
|
|
7
|
-
* - Flat top-level arrays, no `fgca:` wrapper.
|
|
8
|
-
* - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
|
|
9
|
-
* `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
|
|
10
|
-
* - Plural canonical-direction cross-references: `goal.factors`,
|
|
11
|
-
* `change.goals`, `action.delivers_changes`, `action.goals`.
|
|
12
|
-
* - Per-notation validation codes `FGCA-001 … FGCA-015`.
|
|
13
|
-
*
|
|
14
|
-
* Returns the validation result plus, on success, an internal `FGCADoc`
|
|
15
|
-
* representation built from the canonical input — string IDs are kept
|
|
16
|
-
* as strings; the existing `buildFGCALayout` consumes `id: number`
|
|
17
|
-
* historically but the layout code only uses IDs as opaque keys, so
|
|
18
|
-
* string IDs work fine.
|
|
19
|
-
*
|
|
20
|
-
* This lives at the input boundary so the library's
|
|
21
|
-
* internal types (currently using DSM-API form — numeric IDs and
|
|
22
|
-
* singular cross-refs) can stay where they are. A follow-up task can
|
|
23
|
-
* unify the internal types with the canonical form across all sister
|
|
24
|
-
* modules.
|
|
25
|
-
*/
|
|
26
1
|
import type { FGCADoc } from './validate.js';
|
|
27
2
|
import type { ValidationResult } from '../validation-types.js';
|
|
28
3
|
export interface CanonicalFGCAResult extends ValidationResult {
|
|
@@ -1,28 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Canonical-form FGCA parser + validator.
|
|
3
|
-
*
|
|
4
|
-
* Accepts the methodology's canonical FGCA YAML shape (see
|
|
5
|
-
* `transitrix/methodology` `notations/02-fgca.md`):
|
|
6
|
-
*
|
|
7
|
-
* - Flat top-level arrays, no `fgca:` wrapper.
|
|
8
|
-
* - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
|
|
9
|
-
* `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
|
|
10
|
-
* - Plural canonical-direction cross-references: `goal.factors`,
|
|
11
|
-
* `change.goals`, `action.delivers_changes`, `action.goals`.
|
|
12
|
-
* - Per-notation validation codes `FGCA-001 … FGCA-015`.
|
|
13
|
-
*
|
|
14
|
-
* Returns the validation result plus, on success, an internal `FGCADoc`
|
|
15
|
-
* representation built from the canonical input — string IDs are kept
|
|
16
|
-
* as strings; the existing `buildFGCALayout` consumes `id: number`
|
|
17
|
-
* historically but the layout code only uses IDs as opaque keys, so
|
|
18
|
-
* string IDs work fine.
|
|
19
|
-
*
|
|
20
|
-
* This lives at the input boundary so the library's
|
|
21
|
-
* internal types (currently using DSM-API form — numeric IDs and
|
|
22
|
-
* singular cross-refs) can stay where they are. A follow-up task can
|
|
23
|
-
* unify the internal types with the canonical form across all sister
|
|
24
|
-
* modules.
|
|
25
|
-
*/
|
|
1
|
+
import { RESOLVED_DGCA } from './projection-origin.js';
|
|
26
2
|
import { actionChangeLinkField } from './action-change-ids.js';
|
|
27
3
|
// Canonical ID grammars per IDS_AND_REFERENCES.md §1. The TYPE prefix
|
|
28
4
|
// is fixed per layer; middle segments are optional; the terminal is a
|
|
@@ -137,6 +113,17 @@ export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds
|
|
|
137
113
|
errors.push({ code: 'FGCA-004', message: 'actions must be an array', path: 'actions' });
|
|
138
114
|
if (errors.length > 0)
|
|
139
115
|
return { valid: false, errors, warnings };
|
|
116
|
+
if (!input[RESOLVED_DGCA]) {
|
|
117
|
+
for (const [field, values] of [['factors', factorsRaw], ['goals', goalsRaw], ['actions', activitiesRaw], ['changes', changesRaw]]) {
|
|
118
|
+
if (field === 'changes' && isDgcaChangesLayerOff(raw))
|
|
119
|
+
continue;
|
|
120
|
+
if (values?.length === 0)
|
|
121
|
+
errors.push({ code: 'DGCA-004', path: field,
|
|
122
|
+
message: `Inline DGCA ${field} must be nonempty.` });
|
|
123
|
+
}
|
|
124
|
+
if (errors.length > 0)
|
|
125
|
+
return { valid: false, errors, warnings };
|
|
126
|
+
}
|
|
140
127
|
// From here we know all four arrays exist.
|
|
141
128
|
const factors = factorsRaw;
|
|
142
129
|
const goals = goalsRaw;
|
|
@@ -366,7 +353,10 @@ export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds,
|
|
|
366
353
|
// changes. Per-layer / per-ref checks all reuse the FGCA implementation;
|
|
367
354
|
// codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
|
|
368
355
|
// are unreachable here because `changes` is empty.
|
|
369
|
-
const
|
|
356
|
+
const vc = raw.view_config && typeof raw.view_config === 'object' ? raw.view_config : {};
|
|
357
|
+
const layers = vc.layers && typeof vc.layers === 'object' ? vc.layers : {};
|
|
358
|
+
const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [],
|
|
359
|
+
view_config: { ...vc, layers: { ...layers, changes: 'off' } } };
|
|
370
360
|
const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds, progressData);
|
|
371
361
|
const remap = {
|
|
372
362
|
'FGCA-001': 'FGA-001',
|