@ecoma-io/archkeep 0.19.0 → 0.20.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
@@ -53,13 +53,25 @@ import { isComboDepConstraint } from "../rules/tags.mjs";
53
53
  * @property {object[]} [constraintImpact] Per-dependent edge constraint
54
54
  * analysis. Present only when a boundary config was provided.
55
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.
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.
58
60
  * @property {{projects: string[], boundaries: string[], constraints: string[],
59
61
  * decisions: string[]}} [evolutionAlignment] The `affected` shape matching
60
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.
61
72
  * @property {boolean} complete Whether the statement could be fully composed.
62
- * @property {string[]} notes Caveats about statement completeness.
73
+ * @property {string[]} notes Caveats about statement completeness and
74
+ * governance gaps.
63
75
  */
64
76
 
65
77
  /**
@@ -103,17 +115,41 @@ function buildDecisionImpact(root, constraintImpact, config) {
103
115
  const seenRefs = new Set();
104
116
  const affectedRefs = [];
105
117
 
118
+ // Build evidence map: decisionRef -> { constraintRows, dependentProjects }
119
+ /** @type {Map<string, {constraintRows: number[], dependentProjects: string[]}>} */
120
+ const evidenceByRef = new Map();
121
+
106
122
  if (constraintImpact && config && config.depConstraints) {
107
123
  // Use identity matching: constraintImpact.constraintRows are the actual
108
124
  // config row objects returned by findConstraintsFor — check by reference,
109
125
  // not by string label, for exact causal binding.
110
- const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
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;
111
134
 
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);
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
+ }
117
153
  }
118
154
  }
119
155
  }
@@ -139,6 +175,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
139
175
 
140
176
  for (const ref of affectedRefs) {
141
177
  const resolved = resolveDecision(ref, byId, knownFitness);
178
+ const evidence = evidenceByRef.get(ref);
142
179
 
143
180
  if (resolved.resolution === "unknown") {
144
181
  unresolvedDecisionRefs.push(ref);
@@ -152,6 +189,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
152
189
  id: ref,
153
190
  kind: "fitness",
154
191
  resolution: "known",
192
+ evidence,
155
193
  });
156
194
  continue;
157
195
  }
@@ -165,6 +203,7 @@ function buildDecisionImpact(root, constraintImpact, config) {
165
203
  hasAuthority: hasAuthority(record.status),
166
204
  supersedes: record.supersedes.length > 0 ? record.supersedes : undefined,
167
205
  supersededBy: (record.supersededBy ?? []).length > 0 ? record.supersededBy : undefined,
206
+ evidence,
168
207
  });
169
208
  }
170
209
 
@@ -219,6 +258,167 @@ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolved
219
258
  };
220
259
  }
221
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
+
222
422
  /**
223
423
  * Composes the full Impact Statement for a project.
224
424
  *
@@ -227,11 +427,17 @@ function buildEvolutionAlignment(projectName, impact, constraintImpact, resolved
227
427
  * root, provider, etc.).
228
428
  * @param {object|null} [config] The loaded boundary config. When provided,
229
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.
230
435
  * @returns {ImpactStatement}
231
436
  * @throws {import("../errors.mjs").UsageError} When the project is not in the graph.
232
437
  */
233
- export function composeImpactStatement(projectName, commandContext, config = null) {
438
+ export function composeImpactStatement(projectName, commandContext, config = null, options = {}) {
234
439
  const { root, graph } = commandContext;
440
+ const { findings: availableFindings = null, debt: availableDebt = null } = options;
235
441
 
236
442
  // Step 1: Reverse reachability (existing primitive)
237
443
  const impact = computeImpact(projectName, graph);
@@ -248,7 +454,7 @@ export function composeImpactStatement(projectName, commandContext, config = nul
248
454
  );
249
455
  }
250
456
 
251
- // Step 3: Decision impact
457
+ // Step 3: Decision impact (with evidence)
252
458
  let decisionImpact = null;
253
459
  if (constraintImpact) {
254
460
  decisionImpact = buildDecisionImpact(root, constraintImpact, config);
@@ -263,7 +469,15 @@ export function composeImpactStatement(projectName, commandContext, config = nul
263
469
  resolvedDecisions,
264
470
  );
265
471
 
266
- // Step 5: Assemble the statement with coverage notes
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
267
481
  const notes = [];
268
482
 
269
483
  if (config && config.depConstraints) {
@@ -274,10 +488,19 @@ export function composeImpactStatement(projectName, commandContext, config = nul
274
488
  );
275
489
  }
276
490
 
277
- notes.push(
278
- "finding and debt impact are not yet evaluated. " +
279
- "The impact statement covers dependency structure and constraint violations only.",
280
- );
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
+ }
281
504
 
282
505
  const statement = {
283
506
  project: impact.project,
@@ -287,6 +510,9 @@ export function composeImpactStatement(projectName, commandContext, config = nul
287
510
  dependents: impact.dependents,
288
511
  },
289
512
  evolutionAlignment,
513
+ findingsImpact,
514
+ debtImpact,
515
+ boundaryImpact,
290
516
  complete: true,
291
517
  notes,
292
518
  };
@@ -36,6 +36,7 @@ import { computeImpactConstraints } from "./edge-constraints.mjs";
36
36
  import { readAdrContext } from "./adr.mjs";
37
37
  import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
38
38
  import { isComboDepConstraint } from "../rules/tags.mjs";
39
+ import { execSync } from "node:child_process";
39
40
 
40
41
  // ---------------------------------------------------------------------------
41
42
  // Scenario types
@@ -57,26 +58,41 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
57
58
 
58
59
  /**
59
60
  * @typedef {object} ScenarioInput
60
- * @property {string} [base] Optional git revision for attribution.
61
+ * @property {string} [base] Optional git revision for attribution. When not
62
+ * provided, resolved from `git rev-parse HEAD`.
61
63
  * @property {DependencyChange[]} changes The hypothetical changes to evaluate.
62
64
  */
63
65
 
64
66
  // ---------------------------------------------------------------------------
65
67
  // Output types
66
68
  // ---------------------------------------------------------------------------
67
-
68
69
  /**
69
70
  * @typedef {object} ScenarioEvaluation
70
71
  * @property {boolean} virtual Always true — a scenario is never authoritative.
71
72
  * @property {boolean} notAuthoritative Always true — mirrors `virtual`.
72
73
  * @property {string} project The target project being evaluated.
73
74
  * @property {object} base The base graph information.
75
+ * @property {string} base.revision The git revision or snapshot identity.
76
+ * @property {boolean} base.attributed Whether the base is a real, verifiable revision.
77
+ * @property {string} base.provenance How the base was determined (user-provided, auto-resolved, or unverifiable).
74
78
  * @property {string[]} changes The change descriptions that were applied.
75
79
  * @property {string[]|undefined} refused Changes that could not be applied, if any.
76
80
  * @property {object} current The current impact for the target project.
77
81
  * @property {object} scenario The would-be impact after applying the changes.
78
82
  * @property {object} delta What would change.
79
- * @property {boolean} complete Whether the evaluation could be completed.
83
+ * @property {object} [evidenceChain] The provenance chain: base changes → re-evaluated → delta.
84
+ * @property {string} evidenceChain.baseRevision The revision the scenario started from.
85
+ * @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
86
+ * @property {string} evidenceChain.currentState The state before applying changes ("current").
87
+ * @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
88
+ * @property {object} evidenceChain.delta The computed differences.
89
+ * @property {object} [governanceImpact] Governance re-evaluation results.
90
+ * @property {boolean} governanceImpact.findingsReEvaluated Whether findings were re-evaluated.
91
+ * @property {boolean} governanceImpact.debtReEvaluated Whether debt was re-evaluated.
92
+ * @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
93
+ * @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
94
+ * @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
95
+ * @property {boolean} complete Whether the evaluation could be fully completed.
80
96
  * @property {string[]} notes Caveats about the evaluation.
81
97
  */
82
98
 
@@ -311,6 +327,51 @@ function computeDelta(current, scenario) {
311
327
  decisionsChanged,
312
328
  };
313
329
  }
330
+ /**
331
+ * Resolves the base revision for a scenario evaluation.
332
+ *
333
+ * When the user provides a `base` string, it is used as-is and marked as
334
+ * attributed. When no base is provided, we attempt to resolve from
335
+ * `git rev-parse HEAD`. If that fails, we report the gap rather than
336
+ * fabricating a revision.
337
+ *
338
+ * @param {string} root The workspace root.
339
+ * @param {string|undefined} userBase The user-provided base (optional).
340
+ * @returns {{revision: string, attributed: boolean, provenance: string}}
341
+ */
342
+ function resolveBaseRevision(root, userBase) {
343
+ if (typeof userBase === "string" && userBase.length > 0) {
344
+ return {
345
+ revision: userBase,
346
+ attributed: true,
347
+ provenance: "user-provided",
348
+ };
349
+ }
350
+
351
+ // Attempt to resolve from git
352
+ try {
353
+ const revision = execSync("git rev-parse HEAD", {
354
+ cwd: root,
355
+ encoding: "utf8",
356
+ timeout: 5000,
357
+ }).trim();
358
+ if (revision && revision.length === 40) {
359
+ return {
360
+ revision,
361
+ attributed: true,
362
+ provenance: "auto-resolved: git rev-parse HEAD",
363
+ };
364
+ }
365
+ } catch {
366
+ // Fall through to unverifiable
367
+ }
368
+
369
+ return {
370
+ revision: "(unattributed workspace)",
371
+ attributed: false,
372
+ provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
373
+ };
374
+ }
314
375
 
315
376
  /**
316
377
  * Evaluates a scenario against the current workspace.
@@ -319,12 +380,27 @@ function computeDelta(current, scenario) {
319
380
  * @param {object} commandContext The resolved command context.
320
381
  * @param {ScenarioInput} scenarioInput The scenario description.
321
382
  * @param {object|null} [config] The loaded boundary config.
383
+ * @param {object} [options] Optional data for governance integration.
384
+ * @param {object[]|null} [options.findings] Pre-computed findings for
385
+ * governance re-evaluation on the hypothetical graph.
386
+ * @param {object[]|null} [options.debt] Pre-computed debt entries for
387
+ * governance re-evaluation.
322
388
  * @returns {ScenarioEvaluation}
323
389
  */
324
- export function evaluateScenario(projectName, commandContext, scenarioInput, config = null) {
390
+ export function evaluateScenario(
391
+ projectName,
392
+ commandContext,
393
+ scenarioInput,
394
+ config = null,
395
+ options = {},
396
+ ) {
325
397
  const { root, graph } = commandContext;
398
+ const { findings: availableFindings = null, debt: availableDebt = null } = options;
399
+
400
+ // Step 1: Resolve base revision (real attribution)
401
+ const base = resolveBaseRevision(root, scenarioInput.base);
326
402
 
327
- // Step 1: Compute current impact
403
+ // Step 2: Compute current impact
328
404
  const currentImpact = computeImpact(projectName, graph);
329
405
 
330
406
  let currentConstraintImpact = null;
@@ -338,10 +414,10 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
338
414
  );
339
415
  }
340
416
 
341
- // Step 2: Apply scenario changes to the graph
417
+ // Step 3: Apply scenario changes to the graph
342
418
  const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
343
419
 
344
- // Step 3: Compute scenario impact
420
+ // Step 4: Compute scenario impact
345
421
  const scenarioImpact = computeImpact(projectName, scenarioGraph);
346
422
 
347
423
  let scenarioConstraintImpact = null;
@@ -355,7 +431,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
355
431
  );
356
432
  }
357
433
 
358
- // Step 4: Build decision impact for both sides
434
+ // Step 5: Build decision impact for both sides
359
435
  const currentDecisionImpact = buildScenarioDecisionImpact(root, currentConstraintImpact, config);
360
436
  const scenarioDecisionImpact = buildScenarioDecisionImpact(
361
437
  root,
@@ -363,7 +439,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
363
439
  config,
364
440
  );
365
441
 
366
- // Step 5: Build evolution alignment for both sides
442
+ // Step 6: Build evolution alignment for both sides
367
443
  const currentResolved = currentDecisionImpact
368
444
  ? currentDecisionImpact.decisions.map((d) => d.id)
369
445
  : [];
@@ -384,7 +460,7 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
384
460
  scenarioResolved,
385
461
  );
386
462
 
387
- // Step 6: Compute delta
463
+ // Step 7: Compute delta
388
464
  const currentState = {
389
465
  impact: currentImpact,
390
466
  constraintImpact: currentConstraintImpact,
@@ -397,7 +473,44 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
397
473
  };
398
474
  const delta = computeDelta(currentState, scenarioState);
399
475
 
400
- // Step 7: Build notes — coverage and completeness
476
+ // Step 8: Build evidence chain
477
+ const evidenceChain = {
478
+ baseRevision: base.revision,
479
+ appliedChanges: applied,
480
+ currentState: "current",
481
+ scenarioState: "scenario",
482
+ delta: {
483
+ dependentsAdded: delta.dependentsAdded,
484
+ dependentsRemoved: delta.dependentsRemoved,
485
+ constraintsChanged: delta.constraintsChanged,
486
+ decisionsChanged: delta.decisionsChanged,
487
+ },
488
+ };
489
+
490
+ // Step 9: Evaluate governance impact on scenario (hypothetical re-evaluation)
491
+ const scenarioAffectedProjects = [projectName, ...scenarioImpact.dependents];
492
+ let scenarioFindings = null;
493
+ let scenarioDebt = null;
494
+ if (availableFindings) {
495
+ // Re-filter findings for the hypothetical graph's affected projects
496
+ const affectedSet = new Set(scenarioAffectedProjects);
497
+ scenarioFindings = availableFindings.filter((f) => {
498
+ const source = f.source ?? f.project ?? "";
499
+ const target = f.target ?? "";
500
+ return affectedSet.has(source) || affectedSet.has(target);
501
+ });
502
+ }
503
+ if (availableDebt) {
504
+ const affectedSet = new Set(scenarioAffectedProjects);
505
+ scenarioDebt = availableDebt.filter((d) => {
506
+ if (d.kind === "drift" || d.kind === "unresolved") {
507
+ return affectedSet.has(d.source ?? "");
508
+ }
509
+ return false;
510
+ });
511
+ }
512
+
513
+ // Step 10: Build notes — coverage and completeness
401
514
  const notes = [
402
515
  "virtual evaluation — not authoritative",
403
516
  "this scenario has not been committed; run `check` for the real verdict",
@@ -408,31 +521,36 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
408
521
  "A project with no violations here may still violate other rules.",
409
522
  );
410
523
  }
411
- notes.push(
412
- "finding and debt impact are not yet evaluated. " +
413
- "The scenario covers dependency structure and constraint violations only.",
414
- );
524
+ if (!availableFindings) {
525
+ notes.push(
526
+ "finding impact not re-evaluated no findings data provided to scenario. " +
527
+ "Pass findings data for complete governance re-evaluation.",
528
+ );
529
+ }
530
+ if (!availableDebt) {
531
+ notes.push(
532
+ "debt impact not re-evaluated — no debt data provided to scenario. " +
533
+ "Pass debt data for complete governance re-evaluation.",
534
+ );
535
+ }
415
536
  if (refused.length > 0) {
416
537
  notes.push(`changes that could not be applied: ${refused.join("; ")}`);
417
538
  }
418
- // Step 8: Assemble — determine provenance and completeness semantics
419
- // Determine provenance: base is attributable only when a real revision was provided
420
- const baseRevision = scenarioInput.base ?? "(current workspace)";
421
- const isAttributed = typeof scenarioInput.base === "string" && scenarioInput.base.length > 0;
422
-
423
- // Complete means all changes were applied AND all required evaluations completed
539
+ // Step 11: Assemble — determine provenance and completeness semantics
540
+ // `complete` reflects whether the core evaluation (graph + constraints)
541
+ // completed. `governanceComplete` tracks whether governance dimensions
542
+ // were fully evaluated.
543
+ const governanceComplete = availableFindings !== null && availableDebt !== null;
424
544
  const isComplete = refused.length === 0;
425
545
 
426
546
  return {
427
547
  virtual: true,
428
548
  notAuthoritative: true,
429
549
  project: projectName,
430
- base: {
431
- revision: baseRevision,
432
- attributed: isAttributed,
433
- },
550
+ base,
434
551
  changes: applied,
435
552
  refused: refused.length > 0 ? refused : undefined,
553
+ evidenceChain,
436
554
  current: {
437
555
  impact: {
438
556
  project: currentImpact.project,
@@ -454,6 +572,15 @@ export function evaluateScenario(projectName, commandContext, scenarioInput, con
454
572
  constraintImpact: scenarioConstraintImpact,
455
573
  decisionImpact: scenarioDecisionImpact,
456
574
  evolutionAlignment: scenarioEvolution,
575
+ ...(scenarioFindings !== null ? { findings: scenarioFindings } : {}),
576
+ ...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
577
+ },
578
+ governanceImpact: {
579
+ findingsReEvaluated: availableFindings !== null,
580
+ debtReEvaluated: availableDebt !== null,
581
+ governanceComplete,
582
+ scenarioFindingsCount: scenarioFindings?.length ?? 0,
583
+ scenarioDebtCount: scenarioDebt?.length ?? 0,
457
584
  },
458
585
  delta,
459
586
  complete: isComplete,
@@ -87,6 +87,8 @@ export function scenarioCommand(projectName, scenarioJson, commandContext, confi
87
87
  refused: scenario.refused,
88
88
  current: scenario.current,
89
89
  scenario: scenario.scenario,
90
+ governanceImpact: scenario.governanceImpact,
91
+ evidenceChain: scenario.evidenceChain,
90
92
  delta: scenario.delta,
91
93
  notes: scenario.notes,
92
94
  };