@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.
@@ -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
+ }
@@ -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",