@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.
- package/cli.mjs +156 -66
- package/package.json +1 -1
- package/src/analysis/contract.md +32 -5
- package/src/analysis/source-util.mjs +107 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/commands/change.mjs +59 -28
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +708 -0
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +184 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +30 -10
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +691 -0
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +14 -5
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +29 -15
- package/src/commands/impact-statement.mjs +31 -409
- package/src/commands/impact.mjs +18 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/provenance-command.mjs +33 -2
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +363 -198
- package/src/commands/scenario.mjs +32 -21
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +62 -9
- package/src/governance/provenance-graph.mjs +479 -0
- package/src/intent/intent-manifest.json +83 -39
- package/src/report/json.mjs +32 -5
- package/src/report/provenance-text.mjs +30 -7
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- 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 {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
77
|
-
* @property {
|
|
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.
|
|
91
|
-
* @property {boolean} governanceImpact.
|
|
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 {
|
|
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 =
|
|
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
|
-
//
|
|
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(
|
|
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:
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
324
|
+
* Resolves the base revision for a scenario, with explicit identity verification.
|
|
332
325
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
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
|
|
340
|
-
* @returns {{revision: 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
|
-
|
|
347
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 =
|
|
436
|
-
const scenarioDecisionImpact =
|
|
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 =
|
|
483
|
+
const currentEvolution = buildEvolutionAlignment(
|
|
451
484
|
projectName,
|
|
452
485
|
currentImpact,
|
|
453
486
|
currentConstraintImpact,
|
|
454
487
|
currentResolved,
|
|
455
488
|
);
|
|
456
|
-
const scenarioEvolution =
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
}
|