@ecoma-io/archkeep 0.18.1 → 0.20.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/README.md +1 -3
- package/cli.mjs +149 -3
- package/commands.mjs +2 -0
- package/package.json +1 -1
- package/src/analysis/kotlin.mjs +60 -0
- package/src/analysis/python.mjs +124 -73
- package/src/commands/discover.mjs +40 -0
- package/src/commands/impact-statement.mjs +534 -0
- package/src/commands/impact.mjs +9 -0
- package/src/commands/scenario-evaluation.mjs +642 -0
- package/src/commands/scenario.mjs +186 -0
- package/src/report/evidence.mjs +13 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Impact Statement: a composed, authoritative, deterministic enumeration of
|
|
3
|
+
* every governed entity a change to one project touches — projects, edges,
|
|
4
|
+
* constraints, and recorded Decisions — each tied to the reproducible evidence
|
|
5
|
+
* that supports the claim, and with every gap reported rather than hidden.
|
|
6
|
+
*
|
|
7
|
+
* This is a composition layer: it calls existing deterministic primitives
|
|
8
|
+
* (`computeImpact`, `computeImpactConstraints`, `readAdrContext`,
|
|
9
|
+
* `resolveDecisionRef`) and assembles their outputs into one statement. It
|
|
10
|
+
* does NOT invent evidence, run new analysis, or add a second authority.
|
|
11
|
+
*
|
|
12
|
+
* ## What it composes
|
|
13
|
+
*
|
|
14
|
+
* - **Reverse reachability** — `computeImpact`: direct and transitive
|
|
15
|
+
* dependents of the target project.
|
|
16
|
+
* - **Edge and boundary impact** — `computeImpactConstraints`: which
|
|
17
|
+
* constraint rows govern each dependent's edge and whether it currently
|
|
18
|
+
* violates them.
|
|
19
|
+
* - **Decision impact** — which recorded decisions bind the affected
|
|
20
|
+
* constraint rows, resolved through the ADR registry. A `decisionRef` that
|
|
21
|
+
* does not resolve is reported in `unresolvedDecisionRefs`, never silently
|
|
22
|
+
* dropped.
|
|
23
|
+
* - **Evolution alignment** — the `affected` shape matching
|
|
24
|
+
* `EvolutionEvent.affected` vocabulary: `projects`, `boundaries`,
|
|
25
|
+
* `constraints`, `decisions`.
|
|
26
|
+
*
|
|
27
|
+
* ## Determinism
|
|
28
|
+
*
|
|
29
|
+
* The statement is deterministic: two runs over an unchanged tree produce
|
|
30
|
+
* byte-identical output. Every claim traces to a reproducible evidence source
|
|
31
|
+
* (the graph, the constraint table, the ADR registry).
|
|
32
|
+
*
|
|
33
|
+
* ## Failure states
|
|
34
|
+
*
|
|
35
|
+
* - An unreadable ADR registry: all decision refs are reported as unresolved
|
|
36
|
+
* (listed in `unresolvedDecisionRefs`), never silently evaluated.
|
|
37
|
+
* - An unknown decision ref: reported in `unresolvedDecisionRefs`, never
|
|
38
|
+
* silently dropped.
|
|
39
|
+
*
|
|
40
|
+
* @module
|
|
41
|
+
*/
|
|
42
|
+
import { readAdrContext } from "./adr.mjs";
|
|
43
|
+
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
44
|
+
import { computeImpact } from "./impact.mjs";
|
|
45
|
+
import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
|
|
46
|
+
import { isComboDepConstraint } from "../rules/tags.mjs";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} ImpactStatement
|
|
50
|
+
* @property {string} project The target project name.
|
|
51
|
+
* @property {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
52
|
+
* Reverse reachability: which projects depend on the target.
|
|
53
|
+
* @property {object[]} [constraintImpact] Per-dependent edge constraint
|
|
54
|
+
* analysis. Present only when a boundary config was provided.
|
|
55
|
+
* @property {{decisions: object[], unresolvedDecisionRefs: string[]}} [decisionImpact]
|
|
56
|
+
* Which recorded decisions bind the affected constraint rows. Each decision
|
|
57
|
+
* carries an `evidence` field tracing the causal chain: which constraint
|
|
58
|
+
* row index and which dependent project triggered the binding.
|
|
59
|
+
* Present only when a boundary config with `depConstraints` was provided.
|
|
60
|
+
* @property {{projects: string[], boundaries: string[], constraints: string[],
|
|
61
|
+
* decisions: string[]}} [evolutionAlignment] The `affected` shape matching
|
|
62
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
63
|
+
* @property {{evaluated: boolean, entries: object[], note: string|null}} findingsImpact
|
|
64
|
+
* Findings that affect the impacted projects. `evaluated: false` when no
|
|
65
|
+
* findings data was provided — reported as a gap, never as "no findings".
|
|
66
|
+
* @property {{evaluated: boolean, entries: object[], note: string|null}} debtImpact
|
|
67
|
+
* Debt entries that affect the impacted projects. `evaluated: false` when no
|
|
68
|
+
* debt data was provided — reported as a gap, never as "no debt".
|
|
69
|
+
* @property {{boundaries: object[], evaluated: boolean}} boundaryImpact
|
|
70
|
+
* Boundary-crossing analysis for each affected edge. `evaluated: false` when
|
|
71
|
+
* no constraint impact was available.
|
|
72
|
+
* @property {boolean} complete Whether the statement could be fully composed.
|
|
73
|
+
* @property {string[]} notes Caveats about statement completeness and
|
|
74
|
+
* governance gaps.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a decisionRef to its record details.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} ref The decision reference (bare, `adr:`, or `rule:`/`fitness:`-prefixed).
|
|
81
|
+
* @param {Map<string, object>} byId The ADR registry index.
|
|
82
|
+
* @param {Set<string>} knownFitness Declared fitness names.
|
|
83
|
+
* @returns {{resolution: "adr"|"fitness"|"unknown", record?: object}}
|
|
84
|
+
*/
|
|
85
|
+
function resolveDecision(ref, byId, knownFitness) {
|
|
86
|
+
const resolution = resolveDecisionRef(byId, knownFitness, ref);
|
|
87
|
+
if (resolution === "adr") {
|
|
88
|
+
const record = byId.get(stripAdrPrefix(ref));
|
|
89
|
+
return { resolution, record };
|
|
90
|
+
}
|
|
91
|
+
if (resolution === "fitness") {
|
|
92
|
+
// A fitness ref resolves but has no ADR record entry — it's a
|
|
93
|
+
// rule/fitness id, not an ADR. We report the resolution but have
|
|
94
|
+
// no record details for it.
|
|
95
|
+
return { resolution };
|
|
96
|
+
}
|
|
97
|
+
return { resolution };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Builds the decision impact section: which recorded decisions bind the
|
|
102
|
+
* affected constraint rows.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} root Workspace root path.
|
|
105
|
+
* @param {object[]} constraintImpact Per-dependent constraint analysis.
|
|
106
|
+
* @param {object} config The loaded boundary config (with `depConstraints`).
|
|
107
|
+
* @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
|
|
108
|
+
* null when the ADR registry is unreadable.
|
|
109
|
+
*/
|
|
110
|
+
function buildDecisionImpact(root, constraintImpact, config) {
|
|
111
|
+
// Collect unique decisionRefs ONLY from constraint rows that are actually
|
|
112
|
+
// AFFECTED by the change — rows that govern edges from impacted dependents.
|
|
113
|
+
// A decisionRef in the config is not enough: the decision must be causally
|
|
114
|
+
// bound to a governance entity the change touches.
|
|
115
|
+
const seenRefs = new Set();
|
|
116
|
+
const affectedRefs = [];
|
|
117
|
+
|
|
118
|
+
// Build evidence map: decisionRef -> { constraintRows, dependentProjects }
|
|
119
|
+
/** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
|
|
120
|
+
const evidenceByRef = new Map();
|
|
121
|
+
|
|
122
|
+
if (constraintImpact && config && config.depConstraints) {
|
|
123
|
+
// Use identity matching: constraintImpact.constraintRows are the actual
|
|
124
|
+
// config row objects returned by findConstraintsFor — check by reference,
|
|
125
|
+
// not by string label, for exact causal binding.
|
|
126
|
+
for (const entry of constraintImpact) {
|
|
127
|
+
const activeRows = new Set(entry.constraintRows);
|
|
128
|
+
const sourceProject = entry.project;
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < config.depConstraints.length; i++) {
|
|
131
|
+
const row = config.depConstraints[i];
|
|
132
|
+
if (!row.decisionRef) continue;
|
|
133
|
+
if (!activeRows.has(row)) continue;
|
|
134
|
+
|
|
135
|
+
if (!evidenceByRef.has(row.decisionRef)) {
|
|
136
|
+
evidenceByRef.set(row.decisionRef, {
|
|
137
|
+
constraintRows: [],
|
|
138
|
+
dependentProjects: [],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const evidence = evidenceByRef.get(row.decisionRef);
|
|
142
|
+
if (!evidence.constraintRows.includes(i)) {
|
|
143
|
+
evidence.constraintRows.push(i);
|
|
144
|
+
}
|
|
145
|
+
if (!evidence.dependentProjects.includes(sourceProject)) {
|
|
146
|
+
evidence.dependentProjects.push(sourceProject);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!seenRefs.has(row.decisionRef)) {
|
|
150
|
+
seenRefs.add(row.decisionRef);
|
|
151
|
+
affectedRefs.push(row.decisionRef);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (affectedRefs.length === 0) {
|
|
158
|
+
return { decisions: [], unresolvedDecisionRefs: [] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Try to read the ADR registry — if it fails, all refs are unresolved
|
|
162
|
+
let adrContext;
|
|
163
|
+
try {
|
|
164
|
+
adrContext = readAdrContext(root);
|
|
165
|
+
} catch {
|
|
166
|
+
return {
|
|
167
|
+
decisions: [],
|
|
168
|
+
unresolvedDecisionRefs: [...affectedRefs],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { byId, knownFitness } = adrContext;
|
|
173
|
+
const unresolvedDecisionRefs = [];
|
|
174
|
+
const decisions = [];
|
|
175
|
+
|
|
176
|
+
for (const ref of affectedRefs) {
|
|
177
|
+
const resolved = resolveDecision(ref, byId, knownFitness);
|
|
178
|
+
const evidence = evidenceByRef.get(ref);
|
|
179
|
+
|
|
180
|
+
if (resolved.resolution === "unknown") {
|
|
181
|
+
unresolvedDecisionRefs.push(ref);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (resolved.resolution === "fitness") {
|
|
186
|
+
// Fitness refs are not ADR records — report them as resolved
|
|
187
|
+
// but with no record-level details
|
|
188
|
+
decisions.push({
|
|
189
|
+
id: ref,
|
|
190
|
+
kind: "fitness",
|
|
191
|
+
resolution: "known",
|
|
192
|
+
evidence,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ADR record
|
|
198
|
+
const record = resolved.record;
|
|
199
|
+
decisions.push({
|
|
200
|
+
id: record.id,
|
|
201
|
+
kind: "adr",
|
|
202
|
+
status: record.status,
|
|
203
|
+
hasAuthority: hasAuthority(record.status),
|
|
204
|
+
supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
|
|
205
|
+
supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
|
|
206
|
+
evidence,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
212
|
+
unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Builds the evolution alignment section: the `affected` shape matching
|
|
218
|
+
* `EvolutionEvent.affected` vocabulary.
|
|
219
|
+
*
|
|
220
|
+
*
|
|
221
|
+
* @param {{direct: string[], transitive: string[], dependents: string[]}} impact
|
|
222
|
+
* @param {object[]} [constraintImpact] Per-dependent constraint rows.
|
|
223
|
+
* @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
|
|
224
|
+
* @returns {{projects: string[], boundaries: string[], constraints: string[],
|
|
225
|
+
* decisions: string[]}}
|
|
226
|
+
*/
|
|
227
|
+
function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
|
|
228
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
229
|
+
const affectedConstraints = [];
|
|
230
|
+
const affectedBoundaries = [];
|
|
231
|
+
|
|
232
|
+
if (constraintImpact) {
|
|
233
|
+
for (const entry of constraintImpact) {
|
|
234
|
+
// Collect edge identities for each affected boundary
|
|
235
|
+
for (const edge of entry.edges) {
|
|
236
|
+
const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
|
|
237
|
+
if (!affectedBoundaries.includes(edgeId)) {
|
|
238
|
+
affectedBoundaries.push(edgeId);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Collect constraint row labels
|
|
242
|
+
for (const row of entry.constraintRows) {
|
|
243
|
+
const label = isComboDepConstraint(row)
|
|
244
|
+
? `allSourceTags:${row.allSourceTags.join(",")}`
|
|
245
|
+
: `sourceTag:${row.sourceTag}`;
|
|
246
|
+
if (!affectedConstraints.includes(label)) {
|
|
247
|
+
affectedConstraints.push(label);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
projects: [...new Set(affectedProjects)].sort(),
|
|
255
|
+
boundaries: affectedBoundaries.sort(),
|
|
256
|
+
constraints: affectedConstraints.sort(),
|
|
257
|
+
decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Findings and Debt impact (governance integration)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Evaluates findings impact for the affected projects.
|
|
267
|
+
*
|
|
268
|
+
* When no findings data is provided, reports the gap explicitly rather than
|
|
269
|
+
* claiming no findings exist.
|
|
270
|
+
*
|
|
271
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
272
|
+
* @param {object[]|null} [availableFindings] Optional pre-computed findings
|
|
273
|
+
* from the check pipeline.
|
|
274
|
+
* @returns {{evaluated: boolean, entries: object[], note: string|null}}
|
|
275
|
+
* `evaluated: true` when findings data was available and filtered.
|
|
276
|
+
* `evaluated: false` when findings were not provided.
|
|
277
|
+
*/
|
|
278
|
+
function evaluateFindingsImpact(affectedProjects, availableFindings = null) {
|
|
279
|
+
if (!availableFindings) {
|
|
280
|
+
return {
|
|
281
|
+
evaluated: false,
|
|
282
|
+
entries: [],
|
|
283
|
+
note: "findings impact not evaluated — no findings data provided to impact statement",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Filter findings by affected projects
|
|
288
|
+
const affectedSet = new Set(affectedProjects);
|
|
289
|
+
const entries = availableFindings.filter((f) => {
|
|
290
|
+
const source = f.source ?? f.project ?? "";
|
|
291
|
+
const target = f.target ?? "";
|
|
292
|
+
return affectedSet.has(source) || affectedSet.has(target);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
evaluated: true,
|
|
297
|
+
entries,
|
|
298
|
+
note:
|
|
299
|
+
entries.length > 0
|
|
300
|
+
? `${entries.length} finding(s) affect impacted projects`
|
|
301
|
+
: "no findings affect impacted projects",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Evaluates debt impact for the affected projects.
|
|
307
|
+
*
|
|
308
|
+
*
|
|
309
|
+
* Debt entries have `source` as either a project name (for drift entries) or
|
|
310
|
+
* a file path (for waiver entries). When a `resolveProject` function is
|
|
311
|
+
* provided, file-path sources are resolved to project names for matching.
|
|
312
|
+
* When no debt data is provided, reports the gap explicitly rather than
|
|
313
|
+
* claiming no debt exists.
|
|
314
|
+
*
|
|
315
|
+
* @param {string[]} affectedProjects The projects affected by the change.
|
|
316
|
+
* @param {object[]|null} [availableDebt] Optional pre-computed debt entries
|
|
317
|
+
* from the debt ledger (`computeDebtLedger().entries`).
|
|
318
|
+
* @param {function(string): string|null} [resolveProject] Optional function
|
|
319
|
+
* to resolve a file path to its owning project name. Used for waiver entries
|
|
320
|
+
* whose `source` is a file path, not a project name.
|
|
321
|
+
* @returns {{evaluated: boolean, entries: object[], note: string|null}}
|
|
322
|
+
* `evaluated: true` when debt data was available and filtering was attempted.
|
|
323
|
+
* `evaluated: false` when debt was not provided.
|
|
324
|
+
*/
|
|
325
|
+
function evaluateDebtImpact(affectedProjects, availableDebt = null, resolveProject = null) {
|
|
326
|
+
if (!availableDebt) {
|
|
327
|
+
return {
|
|
328
|
+
evaluated: false,
|
|
329
|
+
entries: [],
|
|
330
|
+
note: "debt impact not evaluated — no debt data provided to impact statement",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Filter debt entries by affected projects.
|
|
335
|
+
// Debt entries use `source` as either a project name (drift, unresolved) or
|
|
336
|
+
// a file path (waiver, expired-waiver). For path-based sources, use the
|
|
337
|
+
// resolveProject function when available.
|
|
338
|
+
const affectedSet = new Set(affectedProjects);
|
|
339
|
+
const entries = availableDebt.filter((d) => {
|
|
340
|
+
// Drift and unresolved entries have source = project name directly
|
|
341
|
+
if (d.kind === "drift" || d.kind === "unresolved") {
|
|
342
|
+
return affectedSet.has(d.source ?? "");
|
|
343
|
+
}
|
|
344
|
+
// Waiver entries have source = file path; resolve via owning project
|
|
345
|
+
if (d.kind === "waiver" || d.kind === "expired-waiver") {
|
|
346
|
+
if (typeof resolveProject === "function") {
|
|
347
|
+
const project = resolveProject(d.source ?? "");
|
|
348
|
+
return project !== null && affectedSet.has(project);
|
|
349
|
+
}
|
|
350
|
+
// Without resolveProject, we cannot match path-based sources
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
// Aspirational-gap entries have source = note text — no project match
|
|
354
|
+
return false;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
evaluated: true,
|
|
359
|
+
entries,
|
|
360
|
+
note:
|
|
361
|
+
entries.length > 0
|
|
362
|
+
? `${entries.length} debt entry(ies) affect impacted projects`
|
|
363
|
+
: "no debt entries affect impacted projects",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Evaluates boundary impact: which boundary tags/layers are crossed by the
|
|
369
|
+
* affected edges.
|
|
370
|
+
*
|
|
371
|
+
* Unlike `evolutionAlignment.boundaries` which collects edge identities, this
|
|
372
|
+
* evaluates whether the change crosses meaningful governance boundaries (e.g.
|
|
373
|
+
* layer transitions, scope crossings).
|
|
374
|
+
*
|
|
375
|
+
* @param {object} graph The project graph.
|
|
376
|
+
* @param {object[]} constraintImpact Per-dependent constraint analysis.
|
|
377
|
+
* @param {string} targetProject The target of the impact analysis.
|
|
378
|
+
* @returns {{boundaries: object[], evaluated: boolean}}
|
|
379
|
+
*/
|
|
380
|
+
function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
|
|
381
|
+
if (!constraintImpact || constraintImpact.length === 0) {
|
|
382
|
+
return { boundaries: [], evaluated: false };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const targetNode = graph.nodes[targetProject];
|
|
386
|
+
const targetTags = targetNode?.data?.tags ?? [];
|
|
387
|
+
const boundaries = [];
|
|
388
|
+
|
|
389
|
+
for (const entry of constraintImpact) {
|
|
390
|
+
const sourceTags = graph.nodes[entry.project]?.data?.tags ?? [];
|
|
391
|
+
|
|
392
|
+
for (const edge of entry.edges) {
|
|
393
|
+
// Determine if this edge crosses a layer boundary
|
|
394
|
+
const sourceLayer = sourceTags.find((t) => t.startsWith("layer:"));
|
|
395
|
+
const targetLayer = targetTags.find((t) => t.startsWith("layer:"));
|
|
396
|
+
const crossesLayer = sourceLayer && targetLayer && sourceLayer !== targetLayer;
|
|
397
|
+
|
|
398
|
+
// Determine if this edge crosses a scope boundary
|
|
399
|
+
const sourceScope = sourceTags.find((t) => t.startsWith("scope:"));
|
|
400
|
+
const targetScope = targetTags.find((t) => t.startsWith("scope:"));
|
|
401
|
+
const crossesScope = sourceScope && targetScope && sourceScope !== targetScope;
|
|
402
|
+
|
|
403
|
+
// Determine if any constraint row governs this edge
|
|
404
|
+
const violated = entry.violations?.length > 0;
|
|
405
|
+
const governingRowCount = entry.constraintRows?.length ?? 0;
|
|
406
|
+
|
|
407
|
+
boundaries.push({
|
|
408
|
+
source: entry.project,
|
|
409
|
+
target: edge.target,
|
|
410
|
+
type: edge.type,
|
|
411
|
+
crossesLayer,
|
|
412
|
+
crossesScope,
|
|
413
|
+
violated,
|
|
414
|
+
governingConstraintRows: governingRowCount,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { boundaries, evaluated: true };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Composes the full Impact Statement for a project.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} projectName The target project.
|
|
426
|
+
* @param {object} commandContext The resolved command context (graph, analysis,
|
|
427
|
+
* root, provider, etc.).
|
|
428
|
+
* @param {object|null} [config] The loaded boundary config. When provided,
|
|
429
|
+
* constraint and decision impact are computed.
|
|
430
|
+
* @param {object} [options] Optional data for governance integration.
|
|
431
|
+
* @param {object[]|null} [options.findings] Pre-computed findings from the
|
|
432
|
+
* check pipeline. When null, findings impact is reported as not evaluated.
|
|
433
|
+
* @param {object[]|null} [options.debt] Pre-computed debt entries from the
|
|
434
|
+
* debt ledger. When null, debt impact is reported as not evaluated.
|
|
435
|
+
* @returns {ImpactStatement}
|
|
436
|
+
* @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
|
|
437
|
+
*/
|
|
438
|
+
export function composeImpactStatement(projectName, commandContext, config = null, options = {}) {
|
|
439
|
+
const { root, graph } = commandContext;
|
|
440
|
+
const { findings: availableFindings = null, debt: availableDebt = null } = options;
|
|
441
|
+
|
|
442
|
+
// Step 1: Reverse reachability (existing primitive)
|
|
443
|
+
const impact = computeImpact(projectName, graph);
|
|
444
|
+
|
|
445
|
+
// Step 2: Edge and constraint impact (existing primitive)
|
|
446
|
+
let constraintImpact = null;
|
|
447
|
+
if (config && config.depConstraints) {
|
|
448
|
+
constraintImpact = computeImpactConstraints(
|
|
449
|
+
projectName,
|
|
450
|
+
impact.dependents,
|
|
451
|
+
graph.nodes,
|
|
452
|
+
graph.dependencies,
|
|
453
|
+
config.depConstraints,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Step 3: Decision impact (with evidence)
|
|
458
|
+
let decisionImpact = null;
|
|
459
|
+
if (constraintImpact) {
|
|
460
|
+
decisionImpact = buildDecisionImpact(root, constraintImpact, config);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Step 4: Evolution alignment
|
|
464
|
+
const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
|
|
465
|
+
const evolutionAlignment = buildEvolutionAlignment(
|
|
466
|
+
projectName,
|
|
467
|
+
impact,
|
|
468
|
+
constraintImpact,
|
|
469
|
+
resolvedDecisions,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// Step 5: Boundary impact evaluation
|
|
473
|
+
const boundaryImpact = evaluateBoundaryImpact(graph, constraintImpact, projectName);
|
|
474
|
+
|
|
475
|
+
// Step 6: Findings and Debt impact evaluation
|
|
476
|
+
const affectedProjects = [projectName, ...impact.dependents];
|
|
477
|
+
const findingsImpact = evaluateFindingsImpact(affectedProjects, availableFindings);
|
|
478
|
+
const debtImpact = evaluateDebtImpact(affectedProjects, availableDebt);
|
|
479
|
+
|
|
480
|
+
// Step 7: Assemble the statement with evidence and coverage notes
|
|
481
|
+
const notes = [];
|
|
482
|
+
|
|
483
|
+
if (config && config.depConstraints) {
|
|
484
|
+
notes.push(
|
|
485
|
+
"constraint impact covers only depConstraints (3 of 15 violation types). " +
|
|
486
|
+
"A project with no violations here may still violate other rules " +
|
|
487
|
+
"that require import-site details. Run `check` for the complete verdict.",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!findingsImpact.evaluated) {
|
|
492
|
+
notes.push(
|
|
493
|
+
"finding impact not evaluated — no findings data provided to impact statement. " +
|
|
494
|
+
"Pass findings data for complete governance evaluation.",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!debtImpact.evaluated) {
|
|
499
|
+
notes.push(
|
|
500
|
+
"debt impact not evaluated — no debt data provided to impact statement. " +
|
|
501
|
+
"Pass debt data for complete governance evaluation.",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const statement = {
|
|
506
|
+
project: impact.project,
|
|
507
|
+
impact: {
|
|
508
|
+
direct: impact.direct,
|
|
509
|
+
transitive: impact.transitive,
|
|
510
|
+
dependents: impact.dependents,
|
|
511
|
+
},
|
|
512
|
+
evolutionAlignment,
|
|
513
|
+
findingsImpact,
|
|
514
|
+
debtImpact,
|
|
515
|
+
boundaryImpact,
|
|
516
|
+
complete: true,
|
|
517
|
+
notes,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if (constraintImpact) {
|
|
521
|
+
statement.constraintImpact = constraintImpact;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (decisionImpact) {
|
|
525
|
+
statement.decisionImpact = decisionImpact;
|
|
526
|
+
if (decisionImpact.unresolvedDecisionRefs.length > 0) {
|
|
527
|
+
statement.notes.push(
|
|
528
|
+
`unresolved decision references: ${decisionImpact.unresolvedDecisionRefs.join(", ")}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return statement;
|
|
534
|
+
}
|
package/src/commands/impact.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
|
41
41
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
42
42
|
import { formatImpactReport } from "../report/impact-text.mjs";
|
|
43
43
|
import { resolveProvenance } from "./provenance.mjs";
|
|
44
|
+
import { composeImpactStatement } from "./impact-statement.mjs";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Computes the impact set: every project that transitively depends on
|
|
@@ -204,6 +205,14 @@ export function impactCommand(projectName, commandContext, config = null) {
|
|
|
204
205
|
config.depConstraints,
|
|
205
206
|
);
|
|
206
207
|
}
|
|
208
|
+
result.impactStatement = composeImpactStatement(projectName, commandContext, config);
|
|
209
|
+
|
|
210
|
+
// Full impact statement: when a boundary config is available, compose the
|
|
211
|
+
// enriched statement that includes decision impact and evolution alignment
|
|
212
|
+
// in addition to the reverse reachability and constraint impact above.
|
|
213
|
+
if (config) {
|
|
214
|
+
result.impactStatement = composeImpactStatement(projectName, commandContext, config);
|
|
215
|
+
}
|
|
207
216
|
|
|
208
217
|
const envelope = jsonEnvelope({
|
|
209
218
|
command: "impact",
|