@ecoma-io/archkeep 0.20.1 → 0.22.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.
Files changed (41) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +708 -0
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +691 -0
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact-statement.mjs +31 -409
  27. package/src/commands/impact.mjs +18 -18
  28. package/src/commands/plan-context-command.mjs +10 -5
  29. package/src/commands/provenance-command.mjs +33 -2
  30. package/src/commands/reconcile.mjs +14 -17
  31. package/src/commands/scenario-evaluation.mjs +363 -198
  32. package/src/commands/scenario.mjs +32 -21
  33. package/src/commands/waivers.mjs +36 -28
  34. package/src/governance/evolution-event.mjs +62 -9
  35. package/src/governance/provenance-graph.mjs +479 -0
  36. package/src/intent/intent-manifest.json +83 -39
  37. package/src/report/json.mjs +32 -5
  38. package/src/report/provenance-text.mjs +30 -7
  39. package/src/report/text.mjs +82 -12
  40. package/src/verdict.mjs +78 -36
  41. package/src/workspace.mjs +126 -2
@@ -33,10 +33,22 @@
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 {
37
+ buildDecisionImpact,
38
+ buildEvolutionAlignment,
39
+ decisionProvenanceCoverage,
40
+ } from "./evaluation-primitives.mjs";
41
+ import { resolveProvenance } from "./provenance.mjs";
42
+ import {
43
+ buildGovernanceCompleteness,
44
+ buildScenarioCompleteness,
45
+ buildEvidenceComplete,
46
+ createDomain,
47
+ EVALUATED,
48
+ NOT_EVALUATED,
49
+ evaluationStatus,
50
+ EVALUATION_CONTRACT_TYPES,
51
+ } from "./completeness.mjs";
40
52
 
41
53
  // ---------------------------------------------------------------------------
42
54
  // Scenario types
@@ -54,6 +66,9 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
54
66
  * @property {"dependency_added"|"dependency_removed"} type
55
67
  * @property {string} source The source project of the dependency.
56
68
  * @property {string} target The target project of the dependency.
69
+ * @property {string} [edgeType] The dependency edge type (required for
70
+ * dependency_added; used for disambiguation in dependency_removed when
71
+ * multiple edges exist between the same source+target with different types).
57
72
  */
58
73
 
59
74
  /**
@@ -63,8 +78,6 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
63
78
  * @property {DependencyChange[]} changes The hypothetical changes to evaluate.
64
79
  */
65
80
 
66
- // ---------------------------------------------------------------------------
67
- // Output types
68
81
  // ---------------------------------------------------------------------------
69
82
  /**
70
83
  * @typedef {object} ScenarioEvaluation
@@ -73,13 +86,27 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
73
86
  * @property {string} project The target project being evaluated.
74
87
  * @property {object} base The base graph information.
75
88
  * @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).
89
+ * @property {string} base.identity One of: "verified", "unverified", "mismatch", "unattributed".
90
+ * @property {boolean} base.identityVerified Whether the base revision identity was verified against workspace HEAD.
91
+ * @property {boolean} base.identityMismatch Whether the base revision does not match workspace HEAD.
92
+ * @property {string} base.provenance How the base was determined.
78
93
  * @property {string[]} changes The change descriptions that were applied.
79
94
  * @property {string[]|undefined} refused Changes that could not be applied, if any.
80
95
  * @property {object} current The current impact for the target project.
81
96
  * @property {object} scenario The would-be impact after applying the changes.
82
97
  * @property {object} delta What would change.
98
+ * @property {string[]} delta.dependentsAdded Dependents added in the scenario (flat access).
99
+ * @property {string[]} delta.dependentsRemoved Dependents removed in the scenario (flat access).
100
+ * @property {{status: string}} delta.constraintsChanged Whether constraint impacts changed (structured status).
101
+ * @property {{status: string}} delta.decisionsChanged Whether decisions changed (structured status).
102
+ * @property {object} delta.structuralDelta Structured delta for dependency changes.
103
+ * @property {object} delta.governanceDelta Governance-level delta.
104
+ * @property {{status: string}} delta.governanceDelta.findingsChanged Status: "changed" | "unchanged" | "not_evaluated".
105
+ * @property {{status: string}} delta.governanceDelta.debtChanged Status: "changed" | "unchanged" | "not_evaluated".
106
+ * @property {object} delta.evidenceDelta Evidence-level delta metadata.
107
+ * @property {string} delta.evidenceDelta.baseRevision The revision used as base.
108
+ * @property {number} delta.evidenceDelta.changesApplied Number of changes applied.
109
+ * @property {number} delta.evidenceDelta.changesRefused Number of changes refused.
83
110
  * @property {object} [evidenceChain] The provenance chain: base → changes → re-evaluated → delta.
84
111
  * @property {string} evidenceChain.baseRevision The revision the scenario started from.
85
112
  * @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
@@ -87,12 +114,15 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
87
114
  * @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
88
115
  * @property {object} evidenceChain.delta The computed differences.
89
116
  * @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.
117
+ * @property {boolean} governanceImpact.findingsFiltered Whether precomputed findings were filtered into the scenario state.
118
+ * @property {boolean} governanceImpact.debtFiltered Whether precomputed debt was filtered into the scenario state.
92
119
  * @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
93
120
  * @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
94
121
  * @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
95
- * @property {boolean} complete Whether the evaluation could be fully completed.
122
+ * @property {string} governanceImpact.findingsStatus EVALUATION_STATUS for findings.
123
+ * @property {string} governanceImpact.debtStatus EVALUATION_STATUS for debt.
124
+ * @property {object} completeness Structured completeness from buildScenarioCompleteness.
125
+ * @property {boolean} complete Backward-compat shorthand: whether all changes were applied (mutationCoverageComplete).
96
126
  * @property {string[]} notes Caveats about the evaluation.
97
127
  */
98
128
 
@@ -107,17 +137,19 @@ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "depende
107
137
  * @returns {{nodes: object, dependencies: object}}
108
138
  */
109
139
  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
- }
140
+ const nodes = structuredClone(graph.nodes);
141
+ const dependencies = structuredClone(graph.dependencies);
115
142
  return { nodes, dependencies };
116
143
  }
117
144
 
118
145
  /**
119
146
  * Applies a scenario's changes to a graph, producing a would-be graph.
120
147
  *
148
+ * Each change must identify the edge type explicitly. For dependency_added,
149
+ * the type field is required. For dependency_removed, identity is resolved
150
+ * by source + target + type. If type is not provided and multiple edges
151
+ * exist between the same source+target, the mutation is refused as ambiguous.
152
+ *
121
153
  * @param {object} graph The base graph to apply changes to.
122
154
  * @param {DependencyChange[]} changes The hypothetical changes.
123
155
  * @returns {{graph: object, applied: string[], refused: string[]}}
@@ -144,231 +176,236 @@ function applyChanges(graph, changes) {
144
176
  continue;
145
177
  }
146
178
 
147
- // Check if edge already exists
179
+ // The edge type must be explicit — do not silently invent "static"
180
+ if (!change.edgeType || typeof change.edgeType !== "string") {
181
+ refused.push(
182
+ `cannot add dependency: edge type is required for ${change.source} → ${change.target}`,
183
+ );
184
+ continue;
185
+ }
186
+
187
+ // Check if edge already exists (source+target+type identity)
148
188
  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}`);
189
+ if (existing.some((e) => e.target === change.target && e.type === change.edgeType)) {
190
+ applied.push(
191
+ `dependency already exists: ${change.source} → ${change.target} (${change.edgeType})`,
192
+ );
151
193
  continue;
152
194
  }
153
195
 
154
- // Add the edge
196
+ // Add the edge with explicit type
155
197
  if (!cloned.dependencies[change.source]) {
156
198
  cloned.dependencies[change.source] = [];
157
199
  }
158
200
  cloned.dependencies[change.source].push({
159
201
  target: change.target,
160
- type: "static",
202
+ type: change.edgeType,
161
203
  source: change.source,
162
204
  });
163
- applied.push(`added dependency: ${change.source} → ${change.target}`);
205
+ applied.push(`added dependency: ${change.source} → ${change.target} (${change.edgeType})`);
164
206
  }
165
207
 
166
208
  if (change.type === "dependency_removed") {
167
209
  const existing = cloned.dependencies[change.source] ?? [];
168
- const idx = existing.findIndex((e) => e.target === change.target);
169
- if (idx === -1) {
210
+
211
+ // Use canonical edge identity: source + target + type
212
+ const matching = existing.filter((e) => {
213
+ if (e.target !== change.target) return false;
214
+ if (change.edgeType && e.type !== change.edgeType) return false;
215
+ return true;
216
+ });
217
+
218
+ if (matching.length === 0) {
219
+ const typeInfo = change.edgeType ? ` (${change.edgeType})` : "";
170
220
  refused.push(
171
- `cannot remove dependency: no edge from "${change.source}" to "${change.target}"`,
221
+ `cannot remove dependency: no edge from "${change.source}" to "${change.target}"${typeInfo}`,
172
222
  );
173
223
  continue;
174
224
  }
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
-
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);
215
- }
216
- }
217
225
 
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;
226
+ if (matching.length > 1) {
227
+ // Ambiguous: multiple edges with same source+target but different types,
228
+ // and no type was specified to disambiguate
229
+ const types = matching.map((e) => e.type).join(", ");
230
+ refused.push(
231
+ `ambiguous removal: multiple edges from "${change.source}" to "${change.target}" ` +
232
+ `(${types}). Specify edgeType to disambiguate.`,
233
+ );
234
+ continue;
235
+ }
236
+ // Remove the single matching edge by identity (matching was already computed)
237
+ const idx = existing.indexOf(matching[0]);
238
+ existing.splice(idx, 1);
239
+ const typeLabel = change.edgeType ? ` (${change.edgeType})` : "";
240
+ applied.push(`removed dependency: ${change.source} → ${change.target}${typeLabel}`);
242
241
  }
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
242
  }
251
-
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
- }
243
+ // Clean up empty dependency arrays
244
+ for (const [source, edges] of Object.entries(cloned.dependencies)) {
245
+ if (edges.length === 0) {
246
+ delete cloned.dependencies[source];
290
247
  }
291
248
  }
292
249
 
293
- return {
294
- projects: [...new Set(affectedProjects)].sort(),
295
- boundaries: affectedBoundaries.sort(),
296
- constraints: affectedConstraints.sort(),
297
- decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
298
- };
250
+ return { graph: cloned, applied, refused };
299
251
  }
300
252
 
301
253
  /**
302
254
  * Computes the delta between current and scenario.
303
255
  *
256
+ * All boolean delta fields are replaced with structured status objects
257
+ * to avoid boolean information loss. Each status is one of:
258
+ * - "changed": the value differs between current and scenario
259
+ * - "unchanged": the value is identical
260
+ * - "not_evaluated": the comparison could not be performed
261
+ *
304
262
  * @param {object} current Current impact.
305
263
  * @param {object} scenario Scenario impact.
264
+ * @param {object} [extra] Additional context for structured delta sections.
265
+ * @param {string} [extra.baseRevision] The base revision used.
266
+ * @param {number} [extra.changesApplied] Number of changes applied.
267
+ * @param {number} [extra.changesRefused] Number of changes refused.
268
+ * @param {string} [extra.findingsChanged] Status: "changed" | "unchanged" | "not_evaluated".
269
+ * @param {string} [extra.debtChanged] Status: "changed" | "unchanged" | "not_evaluated".
306
270
  * @returns {{dependentsAdded: string[], dependentsRemoved: string[],
307
- * constraintsChanged: boolean, decisionsChanged: boolean}}
271
+ * constraintsChanged: {status: string}, decisionsChanged: {status: string},
272
+ * structuralDelta: {dependentsAdded: string[], dependentsRemoved: string[]},
273
+ * governanceDelta: {findingsChanged: {status: string}, debtChanged: {status: string}},
274
+ * evidenceDelta: {baseRevision: string, changesApplied: number, changesRefused: number}}}
308
275
  */
309
- function computeDelta(current, scenario) {
276
+ function computeDelta(current, scenario, extra = {}) {
310
277
  const currentDeps = new Set(current.impact.dependents ?? []);
311
278
  const scenarioDeps = new Set(scenario.impact.dependents ?? []);
312
279
 
313
280
  const dependentsAdded = [...scenarioDeps].filter((d) => !currentDeps.has(d)).sort();
314
281
  const dependentsRemoved = [...currentDeps].filter((d) => !scenarioDeps.has(d)).sort();
315
282
 
316
- const constraintsChanged =
317
- JSON.stringify(current.constraintImpact ?? []) !==
318
- JSON.stringify(scenario.constraintImpact ?? []);
283
+ const constraintsChanged = {
284
+ status:
285
+ JSON.stringify(current.constraintImpact ?? []) !==
286
+ JSON.stringify(scenario.constraintImpact ?? [])
287
+ ? "changed"
288
+ : "unchanged",
289
+ };
319
290
 
320
- const decisionsChanged =
321
- JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? []);
291
+ const decisionsChanged = {
292
+ status:
293
+ JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? [])
294
+ ? "changed"
295
+ : "unchanged",
296
+ };
297
+
298
+ // Use structured status for governance changes
299
+ const findingsStatus = extra.findingsChanged ?? "not_evaluated";
300
+ const debtStatus = extra.debtChanged ?? "not_evaluated";
322
301
 
323
302
  return {
324
303
  dependentsAdded,
325
304
  dependentsRemoved,
326
305
  constraintsChanged,
327
306
  decisionsChanged,
307
+ structuralDelta: {
308
+ dependentsAdded,
309
+ dependentsRemoved,
310
+ },
311
+ governanceDelta: {
312
+ findingsChanged: { status: findingsStatus },
313
+ debtChanged: { status: debtStatus },
314
+ },
315
+ evidenceDelta: {
316
+ baseRevision: extra.baseRevision ?? "(unknown)",
317
+ changesApplied: extra.changesApplied ?? 0,
318
+ changesRefused: extra.changesRefused ?? 0,
319
+ },
328
320
  };
329
321
  }
322
+
330
323
  /**
331
- * Resolves the base revision for a scenario evaluation.
324
+ * Resolves the base revision for a scenario, with explicit identity verification.
332
325
  *
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.
326
+ * The following identity states are represented:
327
+ * - verified: the base revision matches the verified workspace HEAD
328
+ * - unverified: the base revision could not be verified against workspace state
329
+ * - mismatch: the base revision differs from the verified workspace HEAD
330
+ * - unattributed: no git revision could be resolved
337
331
  *
338
332
  * @param {string} root The workspace root.
339
- * @param {string|undefined} userBase The user-provided base (optional).
340
- * @returns {{revision: string, attributed: boolean, provenance: string}}
333
+ * @param {string} [userBase] Optional user-provided base revision.
334
+ * @returns {{revision: string, identity: string, provenance: string,
335
+ * identityVerified: boolean, identityMismatch: boolean}}
341
336
  */
342
337
  function resolveBaseRevision(root, userBase) {
338
+ let workspaceHead = null;
339
+ let isDirty = false;
340
+ try {
341
+ const provenance = resolveProvenance(root);
342
+ if (provenance && provenance.commit && provenance.commit.length > 0) {
343
+ workspaceHead = provenance.commit;
344
+ isDirty = !!provenance.dirty;
345
+ }
346
+ } catch {
347
+ // Provenance unavailable — will use "(unknown)" below
348
+ }
349
+
343
350
  if (typeof userBase === "string" && userBase.length > 0) {
351
+ // User-provided base — verify against workspace HEAD
352
+ if (workspaceHead) {
353
+ if (isDirty) {
354
+ // Dirty tree: cannot verify relationship
355
+ return {
356
+ revision: userBase,
357
+ identity: "unverified",
358
+ identityVerified: false,
359
+ identityMismatch: false,
360
+ provenance: `user-provided (HEAD is ${workspaceHead}, dirty — identity unverifiable)`,
361
+ };
362
+ }
363
+ if (userBase === workspaceHead) {
364
+ return {
365
+ revision: userBase,
366
+ identity: "verified",
367
+ identityVerified: true,
368
+ identityMismatch: false,
369
+ provenance: `user-provided (matches HEAD ${workspaceHead})`,
370
+ };
371
+ }
372
+ // User-provided base does not match HEAD
373
+ return {
374
+ revision: userBase,
375
+ identity: "mismatch",
376
+ identityVerified: false,
377
+ identityMismatch: true,
378
+ provenance: `user-provided (HEAD is ${workspaceHead}, requested ${userBase})`,
379
+ };
380
+ }
381
+
382
+ // No workspace HEAD to verify against
344
383
  return {
345
384
  revision: userBase,
346
- attributed: true,
347
- provenance: "user-provided",
385
+ identity: "unverified",
386
+ identityVerified: false,
387
+ identityMismatch: false,
388
+ provenance: "user-provided (could not verify — no git HEAD available)",
348
389
  };
349
390
  }
350
391
 
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
392
+ // Auto-resolve from git
393
+ if (workspaceHead) {
394
+ const suffix = isDirty ? " (dirty)" : "";
395
+ return {
396
+ revision: workspaceHead,
397
+ identity: isDirty ? "unverified" : "verified",
398
+ identityVerified: !isDirty,
399
+ identityMismatch: false,
400
+ provenance: `auto-resolved: git commit ${workspaceHead}${suffix}`,
401
+ };
367
402
  }
368
403
 
369
404
  return {
370
405
  revision: "(unattributed workspace)",
371
- attributed: false,
406
+ identity: "unattributed",
407
+ identityVerified: false,
408
+ identityMismatch: false,
372
409
  provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
373
410
  };
374
411
  }
@@ -432,12 +469,8 @@ export function evaluateScenario(
432
469
  }
433
470
 
434
471
  // 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
- );
472
+ const currentDecisionImpact = buildDecisionImpact(root, currentConstraintImpact, config);
473
+ const scenarioDecisionImpact = buildDecisionImpact(root, scenarioConstraintImpact, config);
441
474
 
442
475
  // Step 6: Build evolution alignment for both sides
443
476
  const currentResolved = currentDecisionImpact
@@ -447,13 +480,13 @@ export function evaluateScenario(
447
480
  ? scenarioDecisionImpact.decisions.map((d) => d.id)
448
481
  : [];
449
482
 
450
- const currentEvolution = buildScenarioEvolutionAlignment(
483
+ const currentEvolution = buildEvolutionAlignment(
451
484
  projectName,
452
485
  currentImpact,
453
486
  currentConstraintImpact,
454
487
  currentResolved,
455
488
  );
456
- const scenarioEvolution = buildScenarioEvolutionAlignment(
489
+ const scenarioEvolution = buildEvolutionAlignment(
457
490
  projectName,
458
491
  scenarioImpact,
459
492
  scenarioConstraintImpact,
@@ -471,9 +504,14 @@ export function evaluateScenario(
471
504
  constraintImpact: scenarioConstraintImpact,
472
505
  decisionImpact: scenarioDecisionImpact,
473
506
  };
474
- const delta = computeDelta(currentState, scenarioState);
475
-
476
- // Step 8: Build evidence chain
507
+ const delta = computeDelta(currentState, scenarioState, {
508
+ baseRevision: base.revision,
509
+ changesApplied: applied.length,
510
+ changesRefused: refused.length,
511
+ // Governance was NOT re-evaluated — filtering is not evaluation
512
+ findingsChanged: "not_evaluated",
513
+ debtChanged: "not_evaluated",
514
+ });
477
515
  const evidenceChain = {
478
516
  baseRevision: base.revision,
479
517
  appliedChanges: applied,
@@ -484,6 +522,9 @@ export function evaluateScenario(
484
522
  dependentsRemoved: delta.dependentsRemoved,
485
523
  constraintsChanged: delta.constraintsChanged,
486
524
  decisionsChanged: delta.decisionsChanged,
525
+ structuralDelta: delta.structuralDelta,
526
+ governanceDelta: delta.governanceDelta,
527
+ evidenceDelta: delta.evidenceDelta,
487
528
  },
488
529
  };
489
530
 
@@ -492,11 +533,24 @@ export function evaluateScenario(
492
533
  let scenarioFindings = null;
493
534
  let scenarioDebt = null;
494
535
  if (availableFindings) {
495
- // Re-filter findings for the hypothetical graph's affected projects
536
+ // Build set of edges that were removed in this scenario
537
+ const removedEdges = new Set();
538
+ for (const change of scenarioInput.changes) {
539
+ if (change.type === "dependency_removed") {
540
+ removedEdges.add(`${change.source}|${change.target}`);
541
+ }
542
+ }
543
+
544
+ // Re-filter findings for the hypothetical graph's affected projects,
545
+ // excluding findings whose edge was removed
496
546
  const affectedSet = new Set(scenarioAffectedProjects);
497
547
  scenarioFindings = availableFindings.filter((f) => {
548
+ // Check if this finding's edge was removed
498
549
  const source = f.source ?? f.project ?? "";
499
550
  const target = f.target ?? "";
551
+ if (removedEdges.has(`${source}|${target}`)) {
552
+ return false; // edge no longer exists in hypothetical graph
553
+ }
500
554
  return affectedSet.has(source) || affectedSet.has(target);
501
555
  });
502
556
  }
@@ -537,11 +591,113 @@ export function evaluateScenario(
537
591
  notes.push(`changes that could not be applied: ${refused.join("; ")}`);
538
592
  }
539
593
  // 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;
594
+ //
595
+ // Governance filtering (re-applying precomputed findings/debt to the
596
+ // hypothetical graph) is NOT governance re-evaluation. True re-evaluation
597
+ // would run the full check pipeline against the hypothetical graph.
598
+ // When we only filter, governance is NOT_EVALUATED.
599
+ const findingsFiltered = availableFindings !== null;
600
+ const debtFiltered = availableDebt !== null;
601
+
602
+ // Filtering is NOT re-evaluation (the header above), so the status is
603
+ // NOT_EVALUATED on both paths — no re-evaluation pipeline exists to pass.
604
+ // Telling a consumer "evaluated" for a filter is the mislabel this refuses.
605
+ const findingsStatus = evaluationStatus({
606
+ evaluated: false,
607
+ notEvaluated: true,
608
+ });
609
+ const debtStatus = evaluationStatus({
610
+ evaluated: false,
611
+ notEvaluated: true,
612
+ });
613
+
614
+ // Build governance completeness
615
+ const governanceCompleteness = buildGovernanceCompleteness({
616
+ findingsStatus,
617
+ debtStatus,
618
+ findingsCount: scenarioFindings?.length ?? 0,
619
+ debtCount: scenarioDebt?.length ?? 0,
620
+ });
621
+
622
+ // Determine mutation coverage completeness
623
+ const totalChanges = scenarioInput.changes.length;
624
+ const appliedCount = applied.length;
625
+ const refusedCount = refused.length;
626
+ const mutationCoverageComplete = totalChanges === appliedCount && refusedCount === 0;
627
+
628
+ // Build the scenario's domain statuses FIRST — the Evidence-Complete
629
+ // contract below derives its hidden-gap gate from them, so a domain that
630
+ // is NOT_EVALUATED without a note flips that gate, and a domain that
631
+ // skips with a stated reason does not.
632
+ // structural — always evaluated (scenario builds a complete graph).
633
+ // constraint, boundary, decision — require a boundary config; decision
634
+ // follows the config exactly as the canonical face reads it, because
635
+ // the scenario runs decision impact (buildDecisionImpact above) when
636
+ // one is present. The condition this replaces, `config.decisionRefs`,
637
+ // named a field no workspace can declare — it held the decision domain
638
+ // at NOT_EVALUATED forever, a permanent hidden gap on every configured
639
+ // workspace.
640
+ // findings, debt — never re-evaluated in a scenario; the notes say so,
641
+ // which is what keeps them disclosed and out of the hidden-gap count.
642
+ // evidence — always evaluated (we build the EC contract).
643
+ const hasConfig = config !== null;
644
+ const configGapNote =
645
+ "No boundary config — constraint, boundary and decision rules not evaluated";
646
+ const configGated = () =>
647
+ hasConfig ? createDomain(EVALUATED) : createDomain(NOT_EVALUATED, configGapNote);
648
+ const scenarioDomains = {
649
+ structural: createDomain(EVALUATED),
650
+ constraint: configGated(),
651
+ boundary: configGated(),
652
+ decision: configGated(),
653
+ findings:
654
+ governanceCompleteness.findings.status === NOT_EVALUATED
655
+ ? createDomain(NOT_EVALUATED, "Findings not re-evaluated in scenario")
656
+ : governanceCompleteness.findings,
657
+ debt:
658
+ governanceCompleteness.debt.status === NOT_EVALUATED
659
+ ? createDomain(NOT_EVALUATED, "Debt not re-evaluated in scenario")
660
+ : governanceCompleteness.debt,
661
+ evidence: createDomain(EVALUATED),
662
+ };
663
+
664
+ // Hidden gaps: NOT_EVALUATED domains without a stated reason — the same
665
+ // derivation `deriveEvidenceGates` runs for the canonical face, so neither
666
+ // face can pass the gate on a literal while the other fails on facts.
667
+ let scenarioHiddenGapCount = 0;
668
+ for (const domainStatus of Object.values(scenarioDomains)) {
669
+ if (domainStatus.status === NOT_EVALUATED && !domainStatus.note) {
670
+ scenarioHiddenGapCount++;
671
+ }
672
+ }
673
+
674
+ // Derive evidence gates for scenario evaluation and build Evidence-Complete contract.
675
+ // Scenario mutation is deterministic: same inputs → same outputs (pure graph clone + apply).
676
+ // surfaceParity: the scenario applied all requested changes (refused===0), so the
677
+ // hypothetical surface is internally consistent — no surface drift from the plan.
678
+ const surfaceParity = refusedCount === 0 ? 1 : 0;
679
+
680
+ const evidenceComplete = buildEvidenceComplete({
681
+ domainCoverage: currentDecisionImpact !== null ? 1 : 0,
682
+ claimEvidenceCoverage: config !== null ? 1 : 0,
683
+ causalCoverage: currentConstraintImpact !== null ? 1 : 0,
684
+ provenanceCoverage: decisionProvenanceCoverage(currentDecisionImpact?.decisions),
685
+ mutationCoverage: mutationCoverageComplete ? 1 : 0,
686
+ surfaceParity,
687
+ hiddenGapCount: scenarioHiddenGapCount,
688
+ falseCompleteCount: 0,
689
+ baseIdentityValid: base.identityVerified,
690
+ deterministic: true,
691
+ contractType: EVALUATION_CONTRACT_TYPES.SCENARIO,
692
+ });
693
+ const scenarioCompleteness = buildScenarioCompleteness({
694
+ changesComplete: mutationCoverageComplete,
695
+ baseIdentityVerified: base.identityVerified,
696
+ mutationCoverageComplete,
697
+ governance: governanceCompleteness,
698
+ evidenceComplete,
699
+ domains: scenarioDomains,
700
+ });
545
701
 
546
702
  return {
547
703
  virtual: true,
@@ -576,14 +732,17 @@ export function evaluateScenario(
576
732
  ...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
577
733
  },
578
734
  governanceImpact: {
579
- findingsReEvaluated: availableFindings !== null,
580
- debtReEvaluated: availableDebt !== null,
581
- governanceComplete,
735
+ findingsFiltered,
736
+ debtFiltered,
737
+ governanceComplete: false,
582
738
  scenarioFindingsCount: scenarioFindings?.length ?? 0,
583
739
  scenarioDebtCount: scenarioDebt?.length ?? 0,
740
+ findingsStatus,
741
+ debtStatus,
584
742
  },
585
743
  delta,
586
- complete: isComplete,
744
+ completeness: scenarioCompleteness,
745
+ complete: scenarioCompleteness.overallComplete,
587
746
  notes,
588
747
  };
589
748
  }
@@ -637,6 +796,12 @@ export function parseScenarioInput(jsonString) {
637
796
 
638
797
  return {
639
798
  base: typeof parsed.base === "string" ? parsed.base : undefined,
640
- changes: changes.map((c) => ({ type: c.type, source: c.source, target: c.target })),
799
+ changes: changes.map((c) => {
800
+ const change = { type: c.type, source: c.source, target: c.target };
801
+ if (c.edgeType && typeof c.edgeType === "string") {
802
+ change.edgeType = c.edgeType;
803
+ }
804
+ return change;
805
+ }),
641
806
  };
642
807
  }