@ecoma-io/archkeep 0.20.0 → 0.21.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/package.json +1 -1
- package/src/commands/completeness.mjs +601 -0
- package/src/commands/evaluation-primitives.mjs +499 -0
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +5 -4
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/scenario-evaluation.mjs +284 -196
- package/src/commands/scenario.mjs +4 -3
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/report/provenance-text.mjs +30 -7
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluation primitives shared between Impact Statement and Scenario Evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Both the impact statement (`./impact-statement.mjs`) and scenario evaluation
|
|
5
|
+
* (`./scenario-evaluation.mjs`) compose deterministic evaluation primitives
|
|
6
|
+
* into a single statement about what a change touches. These are the shared
|
|
7
|
+
* primitives that live in one place so two callers cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { readAdrContext } from "./adr.mjs";
|
|
12
|
+
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
13
|
+
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
14
|
+
import { computeDecisionProvenance } from "../governance/provenance-graph.mjs";
|
|
15
|
+
import { resolveFileAttribution } from "./provenance.mjs";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
buildCompleteness,
|
|
19
|
+
buildGovernanceCompleteness,
|
|
20
|
+
evaluationStatus,
|
|
21
|
+
EVALUATION_STATUS,
|
|
22
|
+
} from "./completeness.mjs";
|
|
23
|
+
import { computeImpact } from "./impact.mjs";
|
|
24
|
+
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Decision resolution
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a decisionRef to its record details.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} ref The decision reference (e.g. `adr:0001` or `fitness:cyclic`).
|
|
33
|
+
* @param {Map<string, object>} byId ADR records by id.
|
|
34
|
+
* @param {Set<string>} knownFitness Known fitness function ids.
|
|
35
|
+
* @returns {{resolution: string, record?: object}}
|
|
36
|
+
*/
|
|
37
|
+
function resolveDecision(ref, byId, knownFitness) {
|
|
38
|
+
const resolution = resolveDecisionRef(byId, knownFitness, ref);
|
|
39
|
+
if (resolution === "adr") {
|
|
40
|
+
const record = byId.get(stripAdrPrefix(ref));
|
|
41
|
+
return { resolution, record };
|
|
42
|
+
}
|
|
43
|
+
if (resolution === "fitness") {
|
|
44
|
+
// A fitness ref resolves but has no ADR record entry — it's a
|
|
45
|
+
// rule/fitness id, not an ADR. We report the resolution but have
|
|
46
|
+
// no record details for it.
|
|
47
|
+
return { resolution };
|
|
48
|
+
}
|
|
49
|
+
return { resolution };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Decision Impact
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the decision impact section: which recorded decisions bind the
|
|
58
|
+
* affected constraint rows.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} root Workspace root path.
|
|
61
|
+
* @param {object[]} constraintImpact Per-dependent constraint analysis.
|
|
62
|
+
* @param {object} config The loaded boundary config (with `depConstraints`).
|
|
63
|
+
* @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
|
|
64
|
+
* null when the ADR registry is unreadable.
|
|
65
|
+
*/
|
|
66
|
+
export function buildDecisionImpact(root, constraintImpact, config) {
|
|
67
|
+
// Collect unique decisionRefs ONLY from constraint rows that are actually
|
|
68
|
+
// AFFECTED by the change — rows that govern edges from impacted dependents.
|
|
69
|
+
// A decisionRef in the config is not enough: the decision must be causally
|
|
70
|
+
// bound to a governance entity the change touches.
|
|
71
|
+
const seenRefs = new Set();
|
|
72
|
+
const affectedRefs = [];
|
|
73
|
+
|
|
74
|
+
// Build evidence map: decisionRef -> { constraintRows, dependentProjects }
|
|
75
|
+
/** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
|
|
76
|
+
const evidenceByRef = new Map();
|
|
77
|
+
|
|
78
|
+
if (constraintImpact && config && config.depConstraints) {
|
|
79
|
+
// Use identity matching: constraintImpact.constraintRows are the actual
|
|
80
|
+
// config row objects returned by findConstraintsFor — check by reference,
|
|
81
|
+
// not by string label, for exact causal binding.
|
|
82
|
+
for (const entry of constraintImpact) {
|
|
83
|
+
const activeRows = new Set(entry.constraintRows);
|
|
84
|
+
const sourceProject = entry.project;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < config.depConstraints.length; i++) {
|
|
87
|
+
const row = config.depConstraints[i];
|
|
88
|
+
if (!row.decisionRef) continue;
|
|
89
|
+
if (!activeRows.has(row)) continue;
|
|
90
|
+
|
|
91
|
+
if (!evidenceByRef.has(row.decisionRef)) {
|
|
92
|
+
evidenceByRef.set(row.decisionRef, {
|
|
93
|
+
constraintRows: [],
|
|
94
|
+
dependentProjects: [],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const evidence = evidenceByRef.get(row.decisionRef);
|
|
98
|
+
if (!evidence.constraintRows.includes(i)) {
|
|
99
|
+
evidence.constraintRows.push(i);
|
|
100
|
+
}
|
|
101
|
+
if (!evidence.dependentProjects.includes(sourceProject)) {
|
|
102
|
+
evidence.dependentProjects.push(sourceProject);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!seenRefs.has(row.decisionRef)) {
|
|
106
|
+
seenRefs.add(row.decisionRef);
|
|
107
|
+
affectedRefs.push(row.decisionRef);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (affectedRefs.length === 0) {
|
|
114
|
+
return { decisions: [], unresolvedDecisionRefs: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Try to read the ADR registry — if it fails, all refs are unresolved
|
|
118
|
+
let adrContext;
|
|
119
|
+
try {
|
|
120
|
+
adrContext = readAdrContext(root);
|
|
121
|
+
} catch {
|
|
122
|
+
return {
|
|
123
|
+
decisions: [],
|
|
124
|
+
unresolvedDecisionRefs: [...affectedRefs],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const { records, byId, knownFitness } = adrContext;
|
|
128
|
+
const decisionProvenance = computeDecisionProvenance(records, (file) =>
|
|
129
|
+
resolveFileAttribution(root, file),
|
|
130
|
+
);
|
|
131
|
+
const unresolvedDecisionRefs = [];
|
|
132
|
+
const decisions = [];
|
|
133
|
+
|
|
134
|
+
for (const ref of affectedRefs) {
|
|
135
|
+
const resolved = resolveDecision(ref, byId, knownFitness);
|
|
136
|
+
const evidence = evidenceByRef.get(ref);
|
|
137
|
+
|
|
138
|
+
if (resolved.resolution === "unknown") {
|
|
139
|
+
unresolvedDecisionRefs.push(ref);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (resolved.resolution === "fitness") {
|
|
144
|
+
// Fitness refs are not ADR records — report them as resolved
|
|
145
|
+
// but with no record-level details
|
|
146
|
+
decisions.push({
|
|
147
|
+
id: ref,
|
|
148
|
+
kind: "fitness",
|
|
149
|
+
resolution: "known",
|
|
150
|
+
evidence,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// ADR record
|
|
155
|
+
const record = resolved.record;
|
|
156
|
+
const prov = decisionProvenance.get(record.id) ?? { attested: false, attribution: null };
|
|
157
|
+
decisions.push({
|
|
158
|
+
id: record.id,
|
|
159
|
+
kind: "adr",
|
|
160
|
+
status: record.status,
|
|
161
|
+
hasAuthority: hasAuthority(record.status),
|
|
162
|
+
supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
|
|
163
|
+
supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
|
|
164
|
+
provenance: {
|
|
165
|
+
attested: prov.attested,
|
|
166
|
+
origin: prov.attribution,
|
|
167
|
+
},
|
|
168
|
+
evidence,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
174
|
+
unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Evaluation helpers (shared between Impact Statement and Scenario Evaluation)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Evaluates findings impact: which findings affect the impacted projects.
|
|
183
|
+
*
|
|
184
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
185
|
+
* @param {object[]|null} availableFindings Pre-computed findings, or null when
|
|
186
|
+
* not available.
|
|
187
|
+
* @returns {{evaluated: boolean, findings: object[], count: number}}
|
|
188
|
+
*/
|
|
189
|
+
export function evaluateFindingsImpact(affectedProjects, availableFindings) {
|
|
190
|
+
if (!availableFindings || availableFindings.length === 0) {
|
|
191
|
+
return { evaluated: false, findings: [], count: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const entries = availableFindings.filter((f) =>
|
|
195
|
+
affectedProjects.includes(f.project ?? f.target ?? ""),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
evaluated: true,
|
|
200
|
+
findings: entries,
|
|
201
|
+
count: entries.length,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Evaluates debt impact: which debt entries affect the impacted projects.
|
|
207
|
+
*
|
|
208
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
209
|
+
* @param {object[]|null} availableDebt Pre-computed debt entries, or null when
|
|
210
|
+
* not available.
|
|
211
|
+
* @param {Function|null} resolveProject Optional function to resolve a debt
|
|
212
|
+
* entry's associated project.
|
|
213
|
+
* @returns {{evaluated: boolean, debt: object[], count: number}}
|
|
214
|
+
*/
|
|
215
|
+
export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
|
|
216
|
+
if (!availableDebt || availableDebt.length === 0) {
|
|
217
|
+
return { evaluated: false, debt: [], count: 0 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const entries = availableDebt.filter((d) => {
|
|
221
|
+
const project = resolveProject ? resolveProject(d) : (d.project ?? d.id ?? "");
|
|
222
|
+
return affectedProjects.includes(project);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
evaluated: true,
|
|
227
|
+
debt: entries,
|
|
228
|
+
count: entries.length,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Evaluates boundary impact: which boundary tags/layers are crossed by the
|
|
234
|
+
* affected edges.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} graph The project graph.
|
|
237
|
+
* @param {object[]|null} constraintImpact Per-dependent constraint analysis.
|
|
238
|
+
* @param {string} targetProject The target of the impact analysis.
|
|
239
|
+
* @returns {{boundaries: object[], evaluated: boolean}}
|
|
240
|
+
*/
|
|
241
|
+
export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
|
|
242
|
+
if (!constraintImpact || constraintImpact.length === 0) {
|
|
243
|
+
return { boundaries: [], evaluated: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const targetNode = graph.nodes[targetProject];
|
|
247
|
+
const targetTags = targetNode?.data?.tags ?? [];
|
|
248
|
+
const boundaries = [];
|
|
249
|
+
|
|
250
|
+
for (const entry of constraintImpact) {
|
|
251
|
+
const sourceTags = graph.nodes[entry.project]?.data?.tags ?? [];
|
|
252
|
+
|
|
253
|
+
for (const edge of entry.edges) {
|
|
254
|
+
// Determine if this edge crosses a layer boundary
|
|
255
|
+
const sourceLayer = sourceTags.find((t) => t.startsWith("layer:"));
|
|
256
|
+
const targetLayer = targetTags.find((t) => t.startsWith("layer:"));
|
|
257
|
+
const crossesLayer = sourceLayer && targetLayer && sourceLayer !== targetLayer;
|
|
258
|
+
|
|
259
|
+
// Determine if this edge crosses a scope boundary
|
|
260
|
+
const sourceScope = sourceTags.find((t) => t.startsWith("scope:"));
|
|
261
|
+
const targetScope = targetTags.find((t) => t.startsWith("scope:"));
|
|
262
|
+
const crossesScope = sourceScope && targetScope && sourceScope !== targetScope;
|
|
263
|
+
|
|
264
|
+
// Determine if any constraint row governs this edge
|
|
265
|
+
const violated = entry.violations?.length > 0;
|
|
266
|
+
const governingRowCount = entry.constraintRows?.length ?? 0;
|
|
267
|
+
|
|
268
|
+
boundaries.push({
|
|
269
|
+
source: entry.project,
|
|
270
|
+
target: edge.target,
|
|
271
|
+
type: edge.type,
|
|
272
|
+
crossesLayer,
|
|
273
|
+
crossesScope,
|
|
274
|
+
violated,
|
|
275
|
+
governingConstraintRows: governingRowCount,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { boundaries, evaluated: true };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Canonical Architecture Evaluation
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Evaluates the complete architecture state for a target project.
|
|
289
|
+
*
|
|
290
|
+
* This is the canonical evaluation function that composes all evaluation
|
|
291
|
+
* dimensions (structural, constraint, boundary, decision, governance) into a
|
|
292
|
+
* single result.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} params
|
|
295
|
+
* @param {object} params.graph The project graph.
|
|
296
|
+
* @param {object|null} params.config The loaded boundary config.
|
|
297
|
+
* @param {string} params.projectName The target project.
|
|
298
|
+
* @param {string} [params.root] The ADR root directory path. When null (default),
|
|
299
|
+
* decision impact cannot resolve decision refs and reports them as unresolved.
|
|
300
|
+
* @param {object[]|null} [params.findings] Pre-computed findings.
|
|
301
|
+
* @param {object[]|null} [params.debt] Pre-computed debt entries.
|
|
302
|
+
* @returns {object} The complete evaluation result with all domains and
|
|
303
|
+
* completeness.
|
|
304
|
+
*/
|
|
305
|
+
export function evaluateArchitectureState({
|
|
306
|
+
graph,
|
|
307
|
+
config,
|
|
308
|
+
projectName,
|
|
309
|
+
root = null,
|
|
310
|
+
findings = null,
|
|
311
|
+
debt = null,
|
|
312
|
+
}) {
|
|
313
|
+
// Step 1: Reverse reachability
|
|
314
|
+
const impact = computeImpact(projectName, graph);
|
|
315
|
+
|
|
316
|
+
// Step 2: Edge and constraint impact
|
|
317
|
+
let constraintImpact = null;
|
|
318
|
+
if (config && config.depConstraints) {
|
|
319
|
+
constraintImpact = computeImpactConstraints(
|
|
320
|
+
projectName,
|
|
321
|
+
impact.dependents,
|
|
322
|
+
graph.nodes,
|
|
323
|
+
graph.dependencies,
|
|
324
|
+
config.depConstraints,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Step 3: Decision impact
|
|
329
|
+
let decisionImpact = null;
|
|
330
|
+
if (constraintImpact) {
|
|
331
|
+
decisionImpact = buildDecisionImpact(root, constraintImpact, config);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Step 4: Evolution alignment
|
|
335
|
+
const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
|
|
336
|
+
const evolutionAlignment = buildEvolutionAlignment(
|
|
337
|
+
projectName,
|
|
338
|
+
impact,
|
|
339
|
+
constraintImpact,
|
|
340
|
+
resolvedDecisions,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Step 5: Boundary impact
|
|
344
|
+
const boundaryImpact = evaluateBoundaryImpact(graph, constraintImpact, projectName);
|
|
345
|
+
|
|
346
|
+
// Step 6: Findings and Debt impact
|
|
347
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
348
|
+
const findingsImpact = evaluateFindingsImpact(affectedProjects, findings);
|
|
349
|
+
const debtImpact = evaluateDebtImpact(affectedProjects, debt);
|
|
350
|
+
|
|
351
|
+
// Step 7: Build completeness with all 8 domains (structural, constraint, boundary,
|
|
352
|
+
// decision, findings, debt, governance, evidence)
|
|
353
|
+
const hasConfig = config !== null;
|
|
354
|
+
|
|
355
|
+
const structuralStatus = evaluationStatus({ evaluated: true });
|
|
356
|
+
const constraintStatus = evaluationStatus({
|
|
357
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
358
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
359
|
+
});
|
|
360
|
+
const boundaryStatus = evaluationStatus({
|
|
361
|
+
evaluated: hasConfig,
|
|
362
|
+
notEvaluated: !hasConfig,
|
|
363
|
+
});
|
|
364
|
+
const decisionStatus = evaluationStatus({
|
|
365
|
+
evaluated: hasConfig,
|
|
366
|
+
notEvaluated: !hasConfig,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const governanceResult = buildGovernanceCompleteness({
|
|
370
|
+
findingsStatus: findingsImpact.evaluated
|
|
371
|
+
? EVALUATION_STATUS.EVALUATED
|
|
372
|
+
: EVALUATION_STATUS.NOT_EVALUATED,
|
|
373
|
+
debtStatus: debtImpact.evaluated
|
|
374
|
+
? EVALUATION_STATUS.EVALUATED
|
|
375
|
+
: EVALUATION_STATUS.NOT_EVALUATED,
|
|
376
|
+
findingsCount: findingsImpact.count,
|
|
377
|
+
debtCount: debtImpact.count,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Build evidence domain: evaluated when any evaluation produced evidence
|
|
381
|
+
// In the canonical evaluator, evidence is always produced (structural,
|
|
382
|
+
// constraint, decision all produce traceable output). The evidence domain
|
|
383
|
+
// tracks whether we can verify that claims have supporting evidence.
|
|
384
|
+
const evidenceEvaluated = true; // canonical evaluator always produces evidence
|
|
385
|
+
const evidenceStatus = evaluationStatus({ evaluated: evidenceEvaluated });
|
|
386
|
+
|
|
387
|
+
const completeness = buildCompleteness({
|
|
388
|
+
structural: {
|
|
389
|
+
status: structuralStatus,
|
|
390
|
+
evaluated: true,
|
|
391
|
+
partial: false,
|
|
392
|
+
notEvaluated: false,
|
|
393
|
+
unsupported: false,
|
|
394
|
+
refused: false,
|
|
395
|
+
note: "",
|
|
396
|
+
},
|
|
397
|
+
constraint: {
|
|
398
|
+
status: constraintStatus,
|
|
399
|
+
evaluated: hasConfig && config.depConstraints !== undefined,
|
|
400
|
+
partial: false,
|
|
401
|
+
notEvaluated: !hasConfig || config.depConstraints === undefined,
|
|
402
|
+
unsupported: false,
|
|
403
|
+
refused: false,
|
|
404
|
+
note: "",
|
|
405
|
+
},
|
|
406
|
+
boundary: {
|
|
407
|
+
status: boundaryStatus,
|
|
408
|
+
evaluated: hasConfig,
|
|
409
|
+
partial: false,
|
|
410
|
+
notEvaluated: !hasConfig,
|
|
411
|
+
unsupported: false,
|
|
412
|
+
refused: false,
|
|
413
|
+
note: "",
|
|
414
|
+
},
|
|
415
|
+
decision: {
|
|
416
|
+
status: decisionStatus,
|
|
417
|
+
evaluated: hasConfig,
|
|
418
|
+
partial: false,
|
|
419
|
+
notEvaluated: !hasConfig,
|
|
420
|
+
unsupported: false,
|
|
421
|
+
refused: false,
|
|
422
|
+
note: "",
|
|
423
|
+
},
|
|
424
|
+
findings: governanceResult.findings,
|
|
425
|
+
debt: governanceResult.debt,
|
|
426
|
+
governance: governanceResult.domain,
|
|
427
|
+
evidence: {
|
|
428
|
+
status: evidenceStatus,
|
|
429
|
+
evaluated: evidenceEvaluated,
|
|
430
|
+
partial: false,
|
|
431
|
+
notEvaluated: false,
|
|
432
|
+
unsupported: false,
|
|
433
|
+
refused: false,
|
|
434
|
+
note: "",
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
project: projectName,
|
|
440
|
+
impact,
|
|
441
|
+
constraintImpact,
|
|
442
|
+
decisionImpact,
|
|
443
|
+
evolutionAlignment,
|
|
444
|
+
boundaryImpact,
|
|
445
|
+
findingsImpact,
|
|
446
|
+
debtImpact,
|
|
447
|
+
completeness,
|
|
448
|
+
affectedProjects,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Evolution Alignment
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Builds the evolution alignment section: the `affected` shape matching
|
|
458
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} projectName The target project.
|
|
461
|
+
* @param {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
462
|
+
* @param {object[]} [constraintImpact] Per-dependent constraint rows.
|
|
463
|
+
* @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
|
|
464
|
+
* @returns {{projects: string[], boundaries: string[], constraints: string[],
|
|
465
|
+
* decisions: string[]}}
|
|
466
|
+
*/
|
|
467
|
+
export function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
|
|
468
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
469
|
+
const affectedConstraints = [];
|
|
470
|
+
const affectedBoundaries = [];
|
|
471
|
+
|
|
472
|
+
if (constraintImpact) {
|
|
473
|
+
for (const entry of constraintImpact) {
|
|
474
|
+
// Collect edge identities for each affected boundary
|
|
475
|
+
for (const edge of entry.edges) {
|
|
476
|
+
const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
|
|
477
|
+
if (!affectedBoundaries.includes(edgeId)) {
|
|
478
|
+
affectedBoundaries.push(edgeId);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Collect constraint row labels
|
|
482
|
+
for (const row of entry.constraintRows) {
|
|
483
|
+
const label = isComboDepConstraint(row)
|
|
484
|
+
? `allSourceTags:${row.allSourceTags.join(",")}`
|
|
485
|
+
: `sourceTag:${row.sourceTag}`;
|
|
486
|
+
if (!affectedConstraints.includes(label)) {
|
|
487
|
+
affectedConstraints.push(label);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
projects: [...new Set(affectedProjects)].sort(),
|
|
495
|
+
boundaries: affectedBoundaries.sort(),
|
|
496
|
+
constraints: affectedConstraints.sort(),
|
|
497
|
+
decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
|
|
498
|
+
};
|
|
499
|
+
}
|