@ecoma-io/archkeep 0.18.1 → 0.19.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,308 @@
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. Present only
57
+ * when a boundary config with `depConstraints` was provided.
58
+ * @property {{projects: string[], boundaries: string[], constraints: string[],
59
+ * decisions: string[]}} [evolutionAlignment] The `affected` shape matching
60
+ * `EvolutionEvent.affected` vocabulary.
61
+ * @property {boolean} complete Whether the statement could be fully composed.
62
+ * @property {string[]} notes Caveats about statement completeness.
63
+ */
64
+
65
+ /**
66
+ * Resolve a decisionRef to its record details.
67
+ *
68
+ * @param {string} ref The decision reference (bare, `adr:`, or `rule:`/`fitness:`-prefixed).
69
+ * @param {Map<string, object>} byId The ADR registry index.
70
+ * @param {Set<string>} knownFitness Declared fitness names.
71
+ * @returns {{resolution: "adr"|"fitness"|"unknown", record?: object}}
72
+ */
73
+ function resolveDecision(ref, byId, knownFitness) {
74
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
75
+ if (resolution === "adr") {
76
+ const record = byId.get(stripAdrPrefix(ref));
77
+ return { resolution, record };
78
+ }
79
+ if (resolution === "fitness") {
80
+ // A fitness ref resolves but has no ADR record entry — it's a
81
+ // rule/fitness id, not an ADR. We report the resolution but have
82
+ // no record details for it.
83
+ return { resolution };
84
+ }
85
+ return { resolution };
86
+ }
87
+
88
+ /**
89
+ * Builds the decision impact section: which recorded decisions bind the
90
+ * affected constraint rows.
91
+ *
92
+ * @param {string} root Workspace root path.
93
+ * @param {object[]} constraintImpact Per-dependent constraint analysis.
94
+ * @param {object} config The loaded boundary config (with `depConstraints`).
95
+ * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
96
+ * null when the ADR registry is unreadable.
97
+ */
98
+ function buildDecisionImpact(root, constraintImpact, config) {
99
+ // Collect unique decisionRefs ONLY from constraint rows that are actually
100
+ // AFFECTED by the change — rows that govern edges from impacted dependents.
101
+ // A decisionRef in the config is not enough: the decision must be causally
102
+ // bound to a governance entity the change touches.
103
+ const seenRefs = new Set();
104
+ const affectedRefs = [];
105
+
106
+ if (constraintImpact && config && config.depConstraints) {
107
+ // Use identity matching: constraintImpact.constraintRows are the actual
108
+ // config row objects returned by findConstraintsFor — check by reference,
109
+ // not by string label, for exact causal binding.
110
+ const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
111
+
112
+ for (const row of config.depConstraints) {
113
+ if (!row.decisionRef) continue;
114
+ if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
115
+ seenRefs.add(row.decisionRef);
116
+ affectedRefs.push(row.decisionRef);
117
+ }
118
+ }
119
+ }
120
+
121
+ if (affectedRefs.length === 0) {
122
+ return { decisions: [], unresolvedDecisionRefs: [] };
123
+ }
124
+
125
+ // Try to read the ADR registry — if it fails, all refs are unresolved
126
+ let adrContext;
127
+ try {
128
+ adrContext = readAdrContext(root);
129
+ } catch {
130
+ return {
131
+ decisions: [],
132
+ unresolvedDecisionRefs: [...affectedRefs],
133
+ };
134
+ }
135
+
136
+ const { byId, knownFitness } = adrContext;
137
+ const unresolvedDecisionRefs = [];
138
+ const decisions = [];
139
+
140
+ for (const ref of affectedRefs) {
141
+ const resolved = resolveDecision(ref, byId, knownFitness);
142
+
143
+ if (resolved.resolution === "unknown") {
144
+ unresolvedDecisionRefs.push(ref);
145
+ continue;
146
+ }
147
+
148
+ if (resolved.resolution === "fitness") {
149
+ // Fitness refs are not ADR records — report them as resolved
150
+ // but with no record-level details
151
+ decisions.push({
152
+ id: ref,
153
+ kind: "fitness",
154
+ resolution: "known",
155
+ });
156
+ continue;
157
+ }
158
+
159
+ // ADR record
160
+ const record = resolved.record;
161
+ decisions.push({
162
+ id: record.id,
163
+ kind: "adr",
164
+ status: record.status,
165
+ hasAuthority: hasAuthority(record.status),
166
+ supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
167
+ supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
168
+ });
169
+ }
170
+
171
+ return {
172
+ decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
173
+ unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Builds the evolution alignment section: the `affected` shape matching
179
+ * `EvolutionEvent.affected` vocabulary.
180
+ *
181
+ *
182
+ * @param {{direct: string[], transitive: string[], dependents: string[]}} impact
183
+ * @param {object[]} [constraintImpact] Per-dependent constraint rows.
184
+ * @param {string[]} [resolvedDecisions] Decision IDs that bind affected rows.
185
+ * @returns {{projects: string[], boundaries: string[], constraints: string[],
186
+ * decisions: string[]}}
187
+ */
188
+ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
189
+ const affectedProjects = [projectName, ...impact.dependents];
190
+ const affectedConstraints = [];
191
+ const affectedBoundaries = [];
192
+
193
+ if (constraintImpact) {
194
+ for (const entry of constraintImpact) {
195
+ // Collect edge identities for each affected boundary
196
+ for (const edge of entry.edges) {
197
+ const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
198
+ if (!affectedBoundaries.includes(edgeId)) {
199
+ affectedBoundaries.push(edgeId);
200
+ }
201
+ }
202
+ // Collect constraint row labels
203
+ for (const row of entry.constraintRows) {
204
+ const label = isComboDepConstraint(row)
205
+ ? `allSourceTags:${row.allSourceTags.join(",")}`
206
+ : `sourceTag:${row.sourceTag}`;
207
+ if (!affectedConstraints.includes(label)) {
208
+ affectedConstraints.push(label);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ return {
215
+ projects: [...new Set(affectedProjects)].sort(),
216
+ boundaries: affectedBoundaries.sort(),
217
+ constraints: affectedConstraints.sort(),
218
+ decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Composes the full Impact Statement for a project.
224
+ *
225
+ * @param {string} projectName The target project.
226
+ * @param {object} commandContext The resolved command context (graph, analysis,
227
+ * root, provider, etc.).
228
+ * @param {object|null} [config] The loaded boundary config. When provided,
229
+ * constraint and decision impact are computed.
230
+ * @returns {ImpactStatement}
231
+ * @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
232
+ */
233
+ export function composeImpactStatement(projectName, commandContext, config = null) {
234
+ const { root, graph } = commandContext;
235
+
236
+ // Step 1: Reverse reachability (existing primitive)
237
+ const impact = computeImpact(projectName, graph);
238
+
239
+ // Step 2: Edge and constraint impact (existing primitive)
240
+ let constraintImpact = null;
241
+ if (config && config.depConstraints) {
242
+ constraintImpact = computeImpactConstraints(
243
+ projectName,
244
+ impact.dependents,
245
+ graph.nodes,
246
+ graph.dependencies,
247
+ config.depConstraints,
248
+ );
249
+ }
250
+
251
+ // Step 3: Decision impact
252
+ let decisionImpact = null;
253
+ if (constraintImpact) {
254
+ decisionImpact = buildDecisionImpact(root, constraintImpact, config);
255
+ }
256
+
257
+ // Step 4: Evolution alignment
258
+ const resolvedDecisions = decisionImpact ? decisionImpact.decisions.map((d) => d.id) : [];
259
+ const evolutionAlignment = buildEvolutionAlignment(
260
+ projectName,
261
+ impact,
262
+ constraintImpact,
263
+ resolvedDecisions,
264
+ );
265
+
266
+ // Step 5: Assemble the statement with coverage notes
267
+ const notes = [];
268
+
269
+ if (config && config.depConstraints) {
270
+ notes.push(
271
+ "constraint impact covers only depConstraints (3 of 15 violation types). " +
272
+ "A project with no violations here may still violate other rules " +
273
+ "that require import-site details. Run `check` for the complete verdict.",
274
+ );
275
+ }
276
+
277
+ notes.push(
278
+ "finding and debt impact are not yet evaluated. " +
279
+ "The impact statement covers dependency structure and constraint violations only.",
280
+ );
281
+
282
+ const statement = {
283
+ project: impact.project,
284
+ impact: {
285
+ direct: impact.direct,
286
+ transitive: impact.transitive,
287
+ dependents: impact.dependents,
288
+ },
289
+ evolutionAlignment,
290
+ complete: true,
291
+ notes,
292
+ };
293
+
294
+ if (constraintImpact) {
295
+ statement.constraintImpact = constraintImpact;
296
+ }
297
+
298
+ if (decisionImpact) {
299
+ statement.decisionImpact = decisionImpact;
300
+ if (decisionImpact.unresolvedDecisionRefs.length > 0) {
301
+ statement.notes.push(
302
+ `unresolved decision references: ${decisionImpact.unresolvedDecisionRefs.join(", ")}`,
303
+ );
304
+ }
305
+ }
306
+
307
+ return statement;
308
+ }
@@ -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",