@ecoma-io/archkeep 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,10 +33,13 @@
33
33
  */
34
34
  import { computeImpact } from "./impact.mjs";
35
35
  import { computeImpactConstraints } from "./edge-constraints.mjs";
36
- import { readAdrContext } from "./adr.mjs";
37
- import { hasAuthority, resolveDecisionRef, stripAdrPrefix } from "../governance/adr-registry.mjs";
38
- import { isComboDepConstraint } from "../rules/tags.mjs";
39
- import { execSync } from "node:child_process";
36
+ import { buildDecisionImpact, buildEvolutionAlignment } from "./evaluation-primitives.mjs";
37
+ import { resolveProvenance } from "./provenance.mjs";
38
+ import {
39
+ buildScenarioCompleteness,
40
+ buildGovernanceCompleteness,
41
+ evaluationStatus,
42
+ } from "./completeness.mjs";
40
43
 
41
44
  // ---------------------------------------------------------------------------
42
45
  // Scenario types
@@ -54,6 +57,9 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
54
57
  * @property {"dependency_added"|"dependency_removed"} type
55
58
  * @property {string} source The source project of the dependency.
56
59
  * @property {string} target The target project of the dependency.
60
+ * @property {string} [edgeType] The dependency edge type (required for
61
+ * dependency_added; used for disambiguation in dependency_removed when
62
+ * multiple edges exist between the same source+target with different types).
57
63
  */
58
64
 
59
65
  /**
@@ -63,8 +69,6 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
63
69
  * @property {DependencyChange[]} changes The hypothetical changes to evaluate.
64
70
  */
65
71
 
66
- // ---------------------------------------------------------------------------
67
- // Output types
68
72
  // ---------------------------------------------------------------------------
69
73
  /**
70
74
  * @typedef {object} ScenarioEvaluation
@@ -73,13 +77,27 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
73
77
  * @property {string} project The target project being evaluated.
74
78
  * @property {object} base The base graph information.
75
79
  * @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).
80
+ * @property {string} base.identity One of: "verified", "unverified", "mismatch", "unattributed".
81
+ * @property {boolean} base.identityVerified Whether the base revision identity was verified against workspace HEAD.
82
+ * @property {boolean} base.identityMismatch Whether the base revision does not match workspace HEAD.
83
+ * @property {string} base.provenance How the base was determined.
78
84
  * @property {string[]} changes The change descriptions that were applied.
79
85
  * @property {string[]|undefined} refused Changes that could not be applied, if any.
80
86
  * @property {object} current The current impact for the target project.
81
87
  * @property {object} scenario The would-be impact after applying the changes.
82
88
  * @property {object} delta What would change.
89
+ * @property {string[]} delta.dependentsAdded Dependents added in the scenario (flat access).
90
+ * @property {string[]} delta.dependentsRemoved Dependents removed in the scenario (flat access).
91
+ * @property {{status: string}} delta.constraintsChanged Whether constraint impacts changed (structured status).
92
+ * @property {{status: string}} delta.decisionsChanged Whether decisions changed (structured status).
93
+ * @property {object} delta.structuralDelta Structured delta for dependency changes.
94
+ * @property {object} delta.governanceDelta Governance-level delta.
95
+ * @property {{status: string}} delta.governanceDelta.findingsChanged Status: "changed" | "unchanged" | "not_evaluated".
96
+ * @property {{status: string}} delta.governanceDelta.debtChanged Status: "changed" | "unchanged" | "not_evaluated".
97
+ * @property {object} delta.evidenceDelta Evidence-level delta metadata.
98
+ * @property {string} delta.evidenceDelta.baseRevision The revision used as base.
99
+ * @property {number} delta.evidenceDelta.changesApplied Number of changes applied.
100
+ * @property {number} delta.evidenceDelta.changesRefused Number of changes refused.
83
101
  * @property {object} [evidenceChain] The provenance chain: base → changes → re-evaluated → delta.
84
102
  * @property {string} evidenceChain.baseRevision The revision the scenario started from.
85
103
  * @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
@@ -92,7 +110,10 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
92
110
  * @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
93
111
  * @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
94
112
  * @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
95
- * @property {boolean} complete Whether the evaluation could be fully completed.
113
+ * @property {string} governanceImpact.findingsStatus EVALUATION_STATUS for findings.
114
+ * @property {string} governanceImpact.debtStatus EVALUATION_STATUS for debt.
115
+ * @property {object} completeness Structured completeness from buildScenarioCompleteness.
116
+ * @property {boolean} complete Backward-compat shorthand: whether all changes were applied (mutationCoverageComplete).
96
117
  * @property {string[]} notes Caveats about the evaluation.
97
118
  */
98
119
 
@@ -107,17 +128,19 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
107
128
  * @returns {{nodes: object, dependencies: object}}
108
129
  */
109
130
  function cloneGraph(graph) {
110
- const nodes = { ...graph.nodes };
111
- const dependencies = {};
112
- for (const [source, edges] of Object.entries(graph.dependencies)) {
113
- dependencies[source] = edges.map((e) => ({ ...e }));
114
- }
131
+ const nodes = structuredClone(graph.nodes);
132
+ const dependencies = structuredClone(graph.dependencies);
115
133
  return { nodes, dependencies };
116
134
  }
117
135
 
118
136
  /**
119
137
  * Applies a scenario's changes to a graph, producing a would-be graph.
120
138
  *
139
+ * Each change must identify the edge type explicitly. For dependency_added,
140
+ * the type field is required. For dependency_removed, identity is resolved
141
+ * by source + target + type. If type is not provided and multiple edges
142
+ * exist between the same source+target, the mutation is refused as ambiguous.
143
+ *
121
144
  * @param {object} graph The base graph to apply changes to.
122
145
  * @param {DependencyChange[]} changes The hypothetical changes.
123
146
  * @returns {{graph: object, applied: string[], refused: string[]}}
@@ -144,231 +167,236 @@ function applyChanges(graph, changes) {
144
167
  continue;
145
168
  }
146
169
 
147
- // Check if edge already exists
170
+ // The edge type must be explicit — do not silently invent "static"
171
+ if (!change.edgeType || typeof change.edgeType !== "string") {
172
+ refused.push(
173
+ `cannot add dependency: edge type is required for ${change.source} → ${change.target}`,
174
+ );
175
+ continue;
176
+ }
177
+
178
+ // Check if edge already exists (source+target+type identity)
148
179
  const existing = cloned.dependencies[change.source] ?? [];
149
- if (existing.some((e) => e.target === change.target)) {
150
- applied.push(`dependency already exists: ${change.source} → ${change.target}`);
180
+ if (existing.some((e) => e.target === change.target && e.type === change.edgeType)) {
181
+ applied.push(
182
+ `dependency already exists: ${change.source} → ${change.target} (${change.edgeType})`,
183
+ );
151
184
  continue;
152
185
  }
153
186
 
154
- // Add the edge
187
+ // Add the edge with explicit type
155
188
  if (!cloned.dependencies[change.source]) {
156
189
  cloned.dependencies[change.source] = [];
157
190
  }
158
191
  cloned.dependencies[change.source].push({
159
192
  target: change.target,
160
- type: "static",
193
+ type: change.edgeType,
161
194
  source: change.source,
162
195
  });
163
- applied.push(`added dependency: ${change.source} → ${change.target}`);
196
+ applied.push(`added dependency: ${change.source} → ${change.target} (${change.edgeType})`);
164
197
  }
165
198
 
166
199
  if (change.type === "dependency_removed") {
167
200
  const existing = cloned.dependencies[change.source] ?? [];
168
- const idx = existing.findIndex((e) => e.target === change.target);
169
- if (idx === -1) {
201
+
202
+ // Use canonical edge identity: source + target + type
203
+ const matching = existing.filter((e) => {
204
+ if (e.target !== change.target) return false;
205
+ if (change.edgeType && e.type !== change.edgeType) return false;
206
+ return true;
207
+ });
208
+
209
+ if (matching.length === 0) {
210
+ const typeInfo = change.edgeType ? ` (${change.edgeType})` : "";
170
211
  refused.push(
171
- `cannot remove dependency: no edge from "${change.source}" to "${change.target}"`,
212
+ `cannot remove dependency: no edge from "${change.source}" to "${change.target}"${typeInfo}`,
172
213
  );
173
214
  continue;
174
215
  }
175
- existing.splice(idx, 1);
176
- applied.push(`removed dependency: ${change.source} → ${change.target}`);
177
- }
178
- }
179
-
180
- return { graph: cloned, applied, refused };
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Decision impact (reuses impact-statement's buildDecisionImpact)
185
- // ---------------------------------------------------------------------------
186
-
187
- /**
188
- * Builds decision impact for the scenario's would-be state.
189
- *
190
- * @param {string} root Workspace root path.
191
- * @param {object[]} constraintImpact Per-dependent constraint analysis.
192
- * @param {object} config The loaded boundary config.
193
- * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
194
- */
195
- function buildScenarioDecisionImpact(root, constraintImpact, config) {
196
- if (!constraintImpact || !config?.depConstraints) {
197
- return { decisions: [], unresolvedDecisionRefs: [] };
198
- }
199
216
 
200
- // Collect unique decisionRefs ONLY from constraint rows that are actually
201
- // AFFECTED by the scenario change rows that govern edges from impacted
202
- // dependents. A decisionRef in the config is not enough.
203
- const seenRefs = new Set();
204
- const affectedRefs = [];
205
-
206
- // Build a set of all constraint rows that appear in constraintImpact,
207
- // using identity matching (the rows are the actual config row objects).
208
- const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
209
-
210
- for (const row of config.depConstraints) {
211
- if (!row.decisionRef) continue;
212
- if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
213
- seenRefs.add(row.decisionRef);
214
- affectedRefs.push(row.decisionRef);
217
+ if (matching.length > 1) {
218
+ // Ambiguous: multiple edges with same source+target but different types,
219
+ // and no type was specified to disambiguate
220
+ const types = matching.map((e) => e.type).join(", ");
221
+ refused.push(
222
+ `ambiguous removal: multiple edges from "${change.source}" to "${change.target}" ` +
223
+ `(${types}). Specify edgeType to disambiguate.`,
224
+ );
225
+ continue;
226
+ }
227
+ // Remove the single matching edge by identity (matching was already computed)
228
+ const idx = existing.indexOf(matching[0]);
229
+ existing.splice(idx, 1);
230
+ const typeLabel = change.edgeType ? ` (${change.edgeType})` : "";
231
+ applied.push(`removed dependency: ${change.source} → ${change.target}${typeLabel}`);
215
232
  }
216
233
  }
217
-
218
- if (affectedRefs.length === 0) {
219
- return { decisions: [], unresolvedDecisionRefs: [] };
220
- }
221
-
222
- let adrContext;
223
- try {
224
- adrContext = readAdrContext(root);
225
- } catch {
226
- return { decisions: [], unresolvedDecisionRefs: [...affectedRefs].sort() };
227
- }
228
-
229
- const { byId, knownFitness } = adrContext;
230
- const unresolvedDecisionRefs = [];
231
- const decisions = [];
232
-
233
- for (const ref of affectedRefs) {
234
- const resolution = resolveDecisionRef(byId, knownFitness, ref);
235
- if (resolution === "unknown") {
236
- unresolvedDecisionRefs.push(ref);
237
- continue;
238
- }
239
- if (resolution === "fitness") {
240
- decisions.push({ id: ref, kind: "fitness", resolution: "known" });
241
- continue;
234
+ // Clean up empty dependency arrays
235
+ for (const [source, edges] of Object.entries(cloned.dependencies)) {
236
+ if (edges.length === 0) {
237
+ delete cloned.dependencies[source];
242
238
  }
243
- const record = byId.get(stripAdrPrefix(ref));
244
- decisions.push({
245
- id: record.id,
246
- kind: "adr",
247
- status: record.status,
248
- hasAuthority: hasAuthority(record.status),
249
- });
250
239
  }
251
240
 
252
- return {
253
- decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
254
- unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
255
- };
256
- }
257
-
258
- /**
259
- * Builds evolution alignment for the scenario.
260
- *
261
- * @param {string} projectName The target project.
262
- *
263
- * @param {object[]} [constraintImpact]
264
- * @param {string[]} [resolvedDecisions]
265
- * @returns {{projects: string[], boundaries: string[], constraints: string[], decisions: string[]}}
266
- */
267
- function buildScenarioEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
268
- const affectedProjects = [projectName, ...impact.dependents];
269
- const affectedConstraints = [];
270
- const affectedBoundaries = [];
271
-
272
- if (constraintImpact) {
273
- for (const entry of constraintImpact) {
274
- // Collect edge identities for each affected boundary
275
- for (const edge of entry.edges) {
276
- const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
277
- if (!affectedBoundaries.includes(edgeId)) {
278
- affectedBoundaries.push(edgeId);
279
- }
280
- }
281
- // Collect constraint row labels using same format as buildEvolutionAlignment
282
- for (const row of entry.constraintRows) {
283
- const label = isComboDepConstraint(row)
284
- ? `allSourceTags:${row.allSourceTags.join(",")}`
285
- : `sourceTag:${row.sourceTag}`;
286
- if (!affectedConstraints.includes(label)) {
287
- affectedConstraints.push(label);
288
- }
289
- }
290
- }
291
- }
292
-
293
- return {
294
- projects: [...new Set(affectedProjects)].sort(),
295
- boundaries: affectedBoundaries.sort(),
296
- constraints: affectedConstraints.sort(),
297
- decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
298
- };
241
+ return { graph: cloned, applied, refused };
299
242
  }
300
243
 
301
244
  /**
302
245
  * Computes the delta between current and scenario.
303
246
  *
247
+ * All boolean delta fields are replaced with structured status objects
248
+ * to avoid boolean information loss. Each status is one of:
249
+ * - "changed": the value differs between current and scenario
250
+ * - "unchanged": the value is identical
251
+ * - "not_evaluated": the comparison could not be performed
252
+ *
304
253
  * @param {object} current Current impact.
305
254
  * @param {object} scenario Scenario impact.
255
+ * @param {object} [extra] Additional context for structured delta sections.
256
+ * @param {string} [extra.baseRevision] The base revision used.
257
+ * @param {number} [extra.changesApplied] Number of changes applied.
258
+ * @param {number} [extra.changesRefused] Number of changes refused.
259
+ * @param {string} [extra.findingsChanged] Status: "changed" | "unchanged" | "not_evaluated".
260
+ * @param {string} [extra.debtChanged] Status: "changed" | "unchanged" | "not_evaluated".
306
261
  * @returns {{dependentsAdded: string[], dependentsRemoved: string[],
307
- * constraintsChanged: boolean, decisionsChanged: boolean}}
262
+ * constraintsChanged: {status: string}, decisionsChanged: {status: string},
263
+ * structuralDelta: {dependentsAdded: string[], dependentsRemoved: string[]},
264
+ * governanceDelta: {findingsChanged: {status: string}, debtChanged: {status: string}},
265
+ * evidenceDelta: {baseRevision: string, changesApplied: number, changesRefused: number}}}
308
266
  */
309
- function computeDelta(current, scenario) {
267
+ function computeDelta(current, scenario, extra = {}) {
310
268
  const currentDeps = new Set(current.impact.dependents ?? []);
311
269
  const scenarioDeps = new Set(scenario.impact.dependents ?? []);
312
270
 
313
271
  const dependentsAdded = [...scenarioDeps].filter((d) => !currentDeps.has(d)).sort();
314
272
  const dependentsRemoved = [...currentDeps].filter((d) => !scenarioDeps.has(d)).sort();
315
273
 
316
- const constraintsChanged =
317
- JSON.stringify(current.constraintImpact ?? []) !==
318
- JSON.stringify(scenario.constraintImpact ?? []);
274
+ const constraintsChanged = {
275
+ status:
276
+ JSON.stringify(current.constraintImpact ?? []) !==
277
+ JSON.stringify(scenario.constraintImpact ?? [])
278
+ ? "changed"
279
+ : "unchanged",
280
+ };
281
+
282
+ const decisionsChanged = {
283
+ status:
284
+ JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? [])
285
+ ? "changed"
286
+ : "unchanged",
287
+ };
319
288
 
320
- const decisionsChanged =
321
- JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? []);
289
+ // Use structured status for governance changes
290
+ const findingsStatus = extra.findingsChanged ?? "not_evaluated";
291
+ const debtStatus = extra.debtChanged ?? "not_evaluated";
322
292
 
323
293
  return {
324
294
  dependentsAdded,
325
295
  dependentsRemoved,
326
296
  constraintsChanged,
327
297
  decisionsChanged,
298
+ structuralDelta: {
299
+ dependentsAdded,
300
+ dependentsRemoved,
301
+ },
302
+ governanceDelta: {
303
+ findingsChanged: { status: findingsStatus },
304
+ debtChanged: { status: debtStatus },
305
+ },
306
+ evidenceDelta: {
307
+ baseRevision: extra.baseRevision ?? "(unknown)",
308
+ changesApplied: extra.changesApplied ?? 0,
309
+ changesRefused: extra.changesRefused ?? 0,
310
+ },
328
311
  };
329
312
  }
313
+
330
314
  /**
331
- * Resolves the base revision for a scenario evaluation.
315
+ * Resolves the base revision for a scenario, with explicit identity verification.
332
316
  *
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.
317
+ * The following identity states are represented:
318
+ * - verified: the base revision matches the verified workspace HEAD
319
+ * - unverified: the base revision could not be verified against workspace state
320
+ * - mismatch: the base revision differs from the verified workspace HEAD
321
+ * - unattributed: no git revision could be resolved
337
322
  *
338
323
  * @param {string} root The workspace root.
339
- * @param {string|undefined} userBase The user-provided base (optional).
340
- * @returns {{revision: string, attributed: boolean, provenance: string}}
324
+ * @param {string} [userBase] Optional user-provided base revision.
325
+ * @returns {{revision: string, identity: string, provenance: string,
326
+ * identityVerified: boolean, identityMismatch: boolean}}
341
327
  */
342
328
  function resolveBaseRevision(root, userBase) {
329
+ let workspaceHead = null;
330
+ let isDirty = false;
331
+ try {
332
+ const provenance = resolveProvenance(root);
333
+ if (provenance && provenance.commit && provenance.commit.length > 0) {
334
+ workspaceHead = provenance.commit;
335
+ isDirty = !!provenance.dirty;
336
+ }
337
+ } catch {
338
+ // Provenance unavailable — will use "(unknown)" below
339
+ }
340
+
343
341
  if (typeof userBase === "string" && userBase.length > 0) {
342
+ // User-provided base — verify against workspace HEAD
343
+ if (workspaceHead) {
344
+ if (isDirty) {
345
+ // Dirty tree: cannot verify relationship
346
+ return {
347
+ revision: userBase,
348
+ identity: "unverified",
349
+ identityVerified: false,
350
+ identityMismatch: false,
351
+ provenance: `user-provided (HEAD is ${workspaceHead}, dirty — identity unverifiable)`,
352
+ };
353
+ }
354
+ if (userBase === workspaceHead) {
355
+ return {
356
+ revision: userBase,
357
+ identity: "verified",
358
+ identityVerified: true,
359
+ identityMismatch: false,
360
+ provenance: `user-provided (matches HEAD ${workspaceHead})`,
361
+ };
362
+ }
363
+ // User-provided base does not match HEAD
364
+ return {
365
+ revision: userBase,
366
+ identity: "mismatch",
367
+ identityVerified: false,
368
+ identityMismatch: true,
369
+ provenance: `user-provided (HEAD is ${workspaceHead}, requested ${userBase})`,
370
+ };
371
+ }
372
+
373
+ // No workspace HEAD to verify against
344
374
  return {
345
375
  revision: userBase,
346
- attributed: true,
347
- provenance: "user-provided",
376
+ identity: "unverified",
377
+ identityVerified: false,
378
+ identityMismatch: false,
379
+ provenance: "user-provided (could not verify — no git HEAD available)",
348
380
  };
349
381
  }
350
382
 
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
383
+ // Auto-resolve from git
384
+ if (workspaceHead) {
385
+ const suffix = isDirty ? " (dirty)" : "";
386
+ return {
387
+ revision: workspaceHead,
388
+ identity: isDirty ? "unverified" : "verified",
389
+ identityVerified: !isDirty,
390
+ identityMismatch: false,
391
+ provenance: `auto-resolved: git commit ${workspaceHead}${suffix}`,
392
+ };
367
393
  }
368
394
 
369
395
  return {
370
396
  revision: "(unattributed workspace)",
371
- attributed: false,
397
+ identity: "unattributed",
398
+ identityVerified: false,
399
+ identityMismatch: false,
372
400
  provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
373
401
  };
374
402
  }
@@ -432,12 +460,8 @@ export function evaluateScenario(
432
460
  }
433
461
 
434
462
  // Step 5: Build decision impact for both sides
435
- const currentDecisionImpact = buildScenarioDecisionImpact(root, currentConstraintImpact, config);
436
- const scenarioDecisionImpact = buildScenarioDecisionImpact(
437
- root,
438
- scenarioConstraintImpact,
439
- config,
440
- );
463
+ const currentDecisionImpact = buildDecisionImpact(root, currentConstraintImpact, config);
464
+ const scenarioDecisionImpact = buildDecisionImpact(root, scenarioConstraintImpact, config);
441
465
 
442
466
  // Step 6: Build evolution alignment for both sides
443
467
  const currentResolved = currentDecisionImpact
@@ -447,13 +471,13 @@ export function evaluateScenario(
447
471
  ? scenarioDecisionImpact.decisions.map((d) => d.id)
448
472
  : [];
449
473
 
450
- const currentEvolution = buildScenarioEvolutionAlignment(
474
+ const currentEvolution = buildEvolutionAlignment(
451
475
  projectName,
452
476
  currentImpact,
453
477
  currentConstraintImpact,
454
478
  currentResolved,
455
479
  );
456
- const scenarioEvolution = buildScenarioEvolutionAlignment(
480
+ const scenarioEvolution = buildEvolutionAlignment(
457
481
  projectName,
458
482
  scenarioImpact,
459
483
  scenarioConstraintImpact,
@@ -471,9 +495,14 @@ export function evaluateScenario(
471
495
  constraintImpact: scenarioConstraintImpact,
472
496
  decisionImpact: scenarioDecisionImpact,
473
497
  };
474
- const delta = computeDelta(currentState, scenarioState);
475
-
476
- // Step 8: Build evidence chain
498
+ const delta = computeDelta(currentState, scenarioState, {
499
+ baseRevision: base.revision,
500
+ changesApplied: applied.length,
501
+ changesRefused: refused.length,
502
+ // Governance was NOT re-evaluated — filtering is not evaluation
503
+ findingsChanged: "not_evaluated",
504
+ debtChanged: "not_evaluated",
505
+ });
477
506
  const evidenceChain = {
478
507
  baseRevision: base.revision,
479
508
  appliedChanges: applied,
@@ -484,6 +513,9 @@ export function evaluateScenario(
484
513
  dependentsRemoved: delta.dependentsRemoved,
485
514
  constraintsChanged: delta.constraintsChanged,
486
515
  decisionsChanged: delta.decisionsChanged,
516
+ structuralDelta: delta.structuralDelta,
517
+ governanceDelta: delta.governanceDelta,
518
+ evidenceDelta: delta.evidenceDelta,
487
519
  },
488
520
  };
489
521
 
@@ -492,11 +524,24 @@ export function evaluateScenario(
492
524
  let scenarioFindings = null;
493
525
  let scenarioDebt = null;
494
526
  if (availableFindings) {
495
- // Re-filter findings for the hypothetical graph's affected projects
527
+ // Build set of edges that were removed in this scenario
528
+ const removedEdges = new Set();
529
+ for (const change of scenarioInput.changes) {
530
+ if (change.type === "dependency_removed") {
531
+ removedEdges.add(`${change.source}|${change.target}`);
532
+ }
533
+ }
534
+
535
+ // Re-filter findings for the hypothetical graph's affected projects,
536
+ // excluding findings whose edge was removed
496
537
  const affectedSet = new Set(scenarioAffectedProjects);
497
538
  scenarioFindings = availableFindings.filter((f) => {
539
+ // Check if this finding's edge was removed
498
540
  const source = f.source ?? f.project ?? "";
499
541
  const target = f.target ?? "";
542
+ if (removedEdges.has(`${source}|${target}`)) {
543
+ return false; // edge no longer exists in hypothetical graph
544
+ }
500
545
  return affectedSet.has(source) || affectedSet.has(target);
501
546
  });
502
547
  }
@@ -537,11 +582,45 @@ export function evaluateScenario(
537
582
  notes.push(`changes that could not be applied: ${refused.join("; ")}`);
538
583
  }
539
584
  // 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;
544
- const isComplete = refused.length === 0;
585
+ //
586
+ // Governance filtering (re-applying precomputed findings/debt to the
587
+ // hypothetical graph) is NOT governance re-evaluation. True re-evaluation
588
+ // would run the full check pipeline against the hypothetical graph.
589
+ // When we only filter, governance is NOT_EVALUATED.
590
+ const findingsReEvaluated = availableFindings !== null;
591
+ const debtReEvaluated = availableDebt !== null;
592
+
593
+ // Mark governance as NOT_EVALUATED when not truly re-evaluated
594
+ const findingsStatus = evaluationStatus({
595
+ evaluated: false, // filtering is NOT evaluation
596
+ notEvaluated: !findingsReEvaluated,
597
+ });
598
+ const debtStatus = evaluationStatus({
599
+ evaluated: false, // filtering is NOT evaluation
600
+ notEvaluated: !debtReEvaluated,
601
+ });
602
+
603
+ // Build governance completeness
604
+ const governanceCompleteness = buildGovernanceCompleteness({
605
+ findingsStatus,
606
+ debtStatus,
607
+ findingsCount: scenarioFindings?.length ?? 0,
608
+ debtCount: scenarioDebt?.length ?? 0,
609
+ });
610
+
611
+ // Determine mutation coverage completeness
612
+ const totalChanges = scenarioInput.changes.length;
613
+ const appliedCount = applied.length;
614
+ const refusedCount = refused.length;
615
+ const mutationCoverageComplete = totalChanges === appliedCount && refusedCount === 0;
616
+
617
+ // Build overall scenario completeness
618
+ const scenarioCompleteness = buildScenarioCompleteness({
619
+ changesComplete: mutationCoverageComplete,
620
+ baseIdentityVerified: base.identityVerified,
621
+ mutationCoverageComplete,
622
+ governance: governanceCompleteness,
623
+ });
545
624
 
546
625
  return {
547
626
  virtual: true,
@@ -576,14 +655,17 @@ export function evaluateScenario(
576
655
  ...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
577
656
  },
578
657
  governanceImpact: {
579
- findingsReEvaluated: availableFindings !== null,
580
- debtReEvaluated: availableDebt !== null,
581
- governanceComplete,
658
+ findingsReEvaluated,
659
+ debtReEvaluated,
660
+ governanceComplete: false, // filtering is NOT evaluation
582
661
  scenarioFindingsCount: scenarioFindings?.length ?? 0,
583
662
  scenarioDebtCount: scenarioDebt?.length ?? 0,
663
+ findingsStatus,
664
+ debtStatus,
584
665
  },
585
666
  delta,
586
- complete: isComplete,
667
+ completeness: scenarioCompleteness,
668
+ complete: mutationCoverageComplete,
587
669
  notes,
588
670
  };
589
671
  }
@@ -637,6 +719,12 @@ export function parseScenarioInput(jsonString) {
637
719
 
638
720
  return {
639
721
  base: typeof parsed.base === "string" ? parsed.base : undefined,
640
- changes: changes.map((c) => ({ type: c.type, source: c.source, target: c.target })),
722
+ changes: changes.map((c) => {
723
+ const change = { type: c.type, source: c.source, target: c.target };
724
+ if (c.edgeType && typeof c.edgeType === "string") {
725
+ change.edgeType = c.edgeType;
726
+ }
727
+ return change;
728
+ }),
641
729
  };
642
730
  }