@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.
- package/package.json +1 -1
- package/src/commands/completeness.mjs +601 -0
- package/src/commands/evaluation-primitives.mjs +499 -0
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +5 -4
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/scenario-evaluation.mjs +284 -196
- package/src/commands/scenario.mjs +4 -3
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/report/provenance-text.mjs +30 -7
|
@@ -33,10 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { computeImpact } from "./impact.mjs";
|
|
35
35
|
import { computeImpactConstraints } from "./edge-constraints.mjs";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
|
|
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 {
|
|
77
|
-
* @property {
|
|
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 {
|
|
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 =
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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:
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
315
|
+
* Resolves the base revision for a scenario, with explicit identity verification.
|
|
332
316
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
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
|
|
340
|
-
* @returns {{revision: 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
|
-
|
|
347
|
-
|
|
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
|
-
//
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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 =
|
|
436
|
-
const scenarioDecisionImpact =
|
|
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 =
|
|
474
|
+
const currentEvolution = buildEvolutionAlignment(
|
|
451
475
|
projectName,
|
|
452
476
|
currentImpact,
|
|
453
477
|
currentConstraintImpact,
|
|
454
478
|
currentResolved,
|
|
455
479
|
);
|
|
456
|
-
const scenarioEvolution =
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
580
|
-
debtReEvaluated
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|