@ecoma-io/archkeep 0.18.1 → 0.20.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/README.md +1 -3
- package/cli.mjs +149 -3
- package/commands.mjs +2 -0
- package/package.json +1 -1
- package/src/analysis/kotlin.mjs +60 -0
- package/src/analysis/python.mjs +124 -73
- package/src/commands/discover.mjs +40 -0
- package/src/commands/impact-statement.mjs +534 -0
- package/src/commands/impact.mjs +9 -0
- package/src/commands/scenario-evaluation.mjs +642 -0
- package/src/commands/scenario.mjs +186 -0
- package/src/report/evidence.mjs +13 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario Evaluation: the second architecture-intelligence capability
|
|
3
|
+
* (`docs/doctrine/scenario-evaluation.md`).
|
|
4
|
+
*
|
|
5
|
+
* Given a hypothetical change description (the "scenario"), applies it to a
|
|
6
|
+
* real workspace graph and re-runs the deterministic impact-analysis path to
|
|
7
|
+
* produce a current-versus-scenario comparison. Every output field carries a
|
|
8
|
+
* `virtual: true` / `notAuthoritative` marker — a scenario is never a real
|
|
9
|
+
* verdict and never enters canonical history.
|
|
10
|
+
*
|
|
11
|
+
* ## What it evaluates
|
|
12
|
+
*
|
|
13
|
+
* For the MVP, a scenario describes **dependency changes**:
|
|
14
|
+
*
|
|
15
|
+
* - `dependency_added`: adds an edge from `source` to `target`.
|
|
16
|
+
* - `dependency_removed`: removes an edge from `source` to `target`.
|
|
17
|
+
*
|
|
18
|
+
* Each scenario is evaluated against a **base graph** (the current workspace
|
|
19
|
+
* graph). The scenario's would-be graph is derived by applying the changes,
|
|
20
|
+
* then the deterministic impact path is re-run. The result is compared against
|
|
21
|
+
* the current impact to produce a delta.
|
|
22
|
+
*
|
|
23
|
+
* ## Design constraints
|
|
24
|
+
*
|
|
25
|
+
* - Read-only: no workspace mutation, no canonical history write.
|
|
26
|
+
* - Deterministic: two runs over the same base and scenario produce identical
|
|
27
|
+
* output.
|
|
28
|
+
* - Reuses existing primitives: `computeImpact`, `computeImpactConstraints`.
|
|
29
|
+
* - Every consequence is labelled `virtual: true` / `notAuthoritative`.
|
|
30
|
+
* - States its own limits: any unevaluated consequence is named.
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
import { computeImpact } from "./impact.mjs";
|
|
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";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Scenario types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** The supported scenario change types. */
|
|
46
|
+
export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]);
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Input schema types
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} DependencyChange
|
|
54
|
+
* @property {"dependency_added"|"dependency_removed"} type
|
|
55
|
+
* @property {string} source The source project of the dependency.
|
|
56
|
+
* @property {string} target The target project of the dependency.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} ScenarioInput
|
|
61
|
+
* @property {string} [base] Optional git revision for attribution. When not
|
|
62
|
+
* provided, resolved from `git rev-parse HEAD`.
|
|
63
|
+
* @property {DependencyChange[]} changes The hypothetical changes to evaluate.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Output types
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {object} ScenarioEvaluation
|
|
71
|
+
* @property {boolean} virtual Always true — a scenario is never authoritative.
|
|
72
|
+
* @property {boolean} notAuthoritative Always true — mirrors `virtual`.
|
|
73
|
+
* @property {string} project The target project being evaluated.
|
|
74
|
+
* @property {object} base The base graph information.
|
|
75
|
+
* @property {string} base.revision The git revision or snapshot identity.
|
|
76
|
+
* @property {boolean} base.attributed Whether the base is a real, verifiable revision.
|
|
77
|
+
* @property {string} base.provenance How the base was determined (user-provided, auto-resolved, or unverifiable).
|
|
78
|
+
* @property {string[]} changes The change descriptions that were applied.
|
|
79
|
+
* @property {string[]|undefined} refused Changes that could not be applied, if any.
|
|
80
|
+
* @property {object} current The current impact for the target project.
|
|
81
|
+
* @property {object} scenario The would-be impact after applying the changes.
|
|
82
|
+
* @property {object} delta What would change.
|
|
83
|
+
* @property {object} [evidenceChain] The provenance chain: base → changes → re-evaluated → delta.
|
|
84
|
+
* @property {string} evidenceChain.baseRevision The revision the scenario started from.
|
|
85
|
+
* @property {string[]} evidenceChain.appliedChanges The changes applied to the base.
|
|
86
|
+
* @property {string} evidenceChain.currentState The state before applying changes ("current").
|
|
87
|
+
* @property {string} evidenceChain.scenarioState The state after applying changes ("scenario").
|
|
88
|
+
* @property {object} evidenceChain.delta The computed differences.
|
|
89
|
+
* @property {object} [governanceImpact] Governance re-evaluation results.
|
|
90
|
+
* @property {boolean} governanceImpact.findingsReEvaluated Whether findings were re-evaluated.
|
|
91
|
+
* @property {boolean} governanceImpact.debtReEvaluated Whether debt was re-evaluated.
|
|
92
|
+
* @property {boolean} governanceImpact.governanceComplete Whether all governance data was provided.
|
|
93
|
+
* @property {number} governanceImpact.scenarioFindingsCount Number of findings in the scenario state.
|
|
94
|
+
* @property {number} governanceImpact.scenarioDebtCount Number of debt entries in the scenario state.
|
|
95
|
+
* @property {boolean} complete Whether the evaluation could be fully completed.
|
|
96
|
+
* @property {string[]} notes Caveats about the evaluation.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Graph manipulation
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deep-clones the graph's nodes and dependencies for mutation.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} graph The project graph: `{nodes, dependencies}`.
|
|
107
|
+
* @returns {{nodes: object, dependencies: object}}
|
|
108
|
+
*/
|
|
109
|
+
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
|
+
}
|
|
115
|
+
return { nodes, dependencies };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Applies a scenario's changes to a graph, producing a would-be graph.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} graph The base graph to apply changes to.
|
|
122
|
+
* @param {DependencyChange[]} changes The hypothetical changes.
|
|
123
|
+
* @returns {{graph: object, applied: string[], refused: string[]}}
|
|
124
|
+
*/
|
|
125
|
+
function applyChanges(graph, changes) {
|
|
126
|
+
const cloned = cloneGraph(graph);
|
|
127
|
+
const applied = [];
|
|
128
|
+
const refused = [];
|
|
129
|
+
|
|
130
|
+
for (const change of changes) {
|
|
131
|
+
if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
|
|
132
|
+
refused.push(`unsupported change type: "${change.type}"`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (change.type === "dependency_added") {
|
|
137
|
+
// Validate that source and target exist in the graph
|
|
138
|
+
if (!Object.hasOwn(cloned.nodes, change.source)) {
|
|
139
|
+
refused.push(`cannot add dependency: source project "${change.source}" not in graph`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!Object.hasOwn(cloned.nodes, change.target)) {
|
|
143
|
+
refused.push(`cannot add dependency: target project "${change.target}" not in graph`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if edge already exists
|
|
148
|
+
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}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Add the edge
|
|
155
|
+
if (!cloned.dependencies[change.source]) {
|
|
156
|
+
cloned.dependencies[change.source] = [];
|
|
157
|
+
}
|
|
158
|
+
cloned.dependencies[change.source].push({
|
|
159
|
+
target: change.target,
|
|
160
|
+
type: "static",
|
|
161
|
+
source: change.source,
|
|
162
|
+
});
|
|
163
|
+
applied.push(`added dependency: ${change.source} → ${change.target}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (change.type === "dependency_removed") {
|
|
167
|
+
const existing = cloned.dependencies[change.source] ?? [];
|
|
168
|
+
const idx = existing.findIndex((e) => e.target === change.target);
|
|
169
|
+
if (idx === -1) {
|
|
170
|
+
refused.push(
|
|
171
|
+
`cannot remove dependency: no edge from "${change.source}" to "${change.target}"`,
|
|
172
|
+
);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
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
|
+
|
|
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;
|
|
242
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
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
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Computes the delta between current and scenario.
|
|
303
|
+
*
|
|
304
|
+
* @param {object} current Current impact.
|
|
305
|
+
* @param {object} scenario Scenario impact.
|
|
306
|
+
* @returns {{dependentsAdded: string[], dependentsRemoved: string[],
|
|
307
|
+
* constraintsChanged: boolean, decisionsChanged: boolean}}
|
|
308
|
+
*/
|
|
309
|
+
function computeDelta(current, scenario) {
|
|
310
|
+
const currentDeps = new Set(current.impact.dependents ?? []);
|
|
311
|
+
const scenarioDeps = new Set(scenario.impact.dependents ?? []);
|
|
312
|
+
|
|
313
|
+
const dependentsAdded = [...scenarioDeps].filter((d) => !currentDeps.has(d)).sort();
|
|
314
|
+
const dependentsRemoved = [...currentDeps].filter((d) => !scenarioDeps.has(d)).sort();
|
|
315
|
+
|
|
316
|
+
const constraintsChanged =
|
|
317
|
+
JSON.stringify(current.constraintImpact ?? []) !==
|
|
318
|
+
JSON.stringify(scenario.constraintImpact ?? []);
|
|
319
|
+
|
|
320
|
+
const decisionsChanged =
|
|
321
|
+
JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? []);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
dependentsAdded,
|
|
325
|
+
dependentsRemoved,
|
|
326
|
+
constraintsChanged,
|
|
327
|
+
decisionsChanged,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolves the base revision for a scenario evaluation.
|
|
332
|
+
*
|
|
333
|
+
* When the user provides a `base` string, it is used as-is and marked as
|
|
334
|
+
* attributed. When no base is provided, we attempt to resolve from
|
|
335
|
+
* `git rev-parse HEAD`. If that fails, we report the gap rather than
|
|
336
|
+
* fabricating a revision.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} root The workspace root.
|
|
339
|
+
* @param {string|undefined} userBase The user-provided base (optional).
|
|
340
|
+
* @returns {{revision: string, attributed: boolean, provenance: string}}
|
|
341
|
+
*/
|
|
342
|
+
function resolveBaseRevision(root, userBase) {
|
|
343
|
+
if (typeof userBase === "string" && userBase.length > 0) {
|
|
344
|
+
return {
|
|
345
|
+
revision: userBase,
|
|
346
|
+
attributed: true,
|
|
347
|
+
provenance: "user-provided",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Attempt to resolve from git
|
|
352
|
+
try {
|
|
353
|
+
const revision = execSync("git rev-parse HEAD", {
|
|
354
|
+
cwd: root,
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
timeout: 5000,
|
|
357
|
+
}).trim();
|
|
358
|
+
if (revision && revision.length === 40) {
|
|
359
|
+
return {
|
|
360
|
+
revision,
|
|
361
|
+
attributed: true,
|
|
362
|
+
provenance: "auto-resolved: git rev-parse HEAD",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Fall through to unverifiable
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
revision: "(unattributed workspace)",
|
|
371
|
+
attributed: false,
|
|
372
|
+
provenance: "unverifiable — git rev-parse HEAD failed or not a git repository",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Evaluates a scenario against the current workspace.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} projectName The target project.
|
|
380
|
+
* @param {object} commandContext The resolved command context.
|
|
381
|
+
* @param {ScenarioInput} scenarioInput The scenario description.
|
|
382
|
+
* @param {object|null} [config] The loaded boundary config.
|
|
383
|
+
* @param {object} [options] Optional data for governance integration.
|
|
384
|
+
* @param {object[]|null} [options.findings] Pre-computed findings for
|
|
385
|
+
* governance re-evaluation on the hypothetical graph.
|
|
386
|
+
* @param {object[]|null} [options.debt] Pre-computed debt entries for
|
|
387
|
+
* governance re-evaluation.
|
|
388
|
+
* @returns {ScenarioEvaluation}
|
|
389
|
+
*/
|
|
390
|
+
export function evaluateScenario(
|
|
391
|
+
projectName,
|
|
392
|
+
commandContext,
|
|
393
|
+
scenarioInput,
|
|
394
|
+
config = null,
|
|
395
|
+
options = {},
|
|
396
|
+
) {
|
|
397
|
+
const { root, graph } = commandContext;
|
|
398
|
+
const { findings: availableFindings = null, debt: availableDebt = null } = options;
|
|
399
|
+
|
|
400
|
+
// Step 1: Resolve base revision (real attribution)
|
|
401
|
+
const base = resolveBaseRevision(root, scenarioInput.base);
|
|
402
|
+
|
|
403
|
+
// Step 2: Compute current impact
|
|
404
|
+
const currentImpact = computeImpact(projectName, graph);
|
|
405
|
+
|
|
406
|
+
let currentConstraintImpact = null;
|
|
407
|
+
if (config && config.depConstraints) {
|
|
408
|
+
currentConstraintImpact = computeImpactConstraints(
|
|
409
|
+
projectName,
|
|
410
|
+
currentImpact.dependents,
|
|
411
|
+
graph.nodes,
|
|
412
|
+
graph.dependencies,
|
|
413
|
+
config.depConstraints,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Step 3: Apply scenario changes to the graph
|
|
418
|
+
const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
|
|
419
|
+
|
|
420
|
+
// Step 4: Compute scenario impact
|
|
421
|
+
const scenarioImpact = computeImpact(projectName, scenarioGraph);
|
|
422
|
+
|
|
423
|
+
let scenarioConstraintImpact = null;
|
|
424
|
+
if (config && config.depConstraints) {
|
|
425
|
+
scenarioConstraintImpact = computeImpactConstraints(
|
|
426
|
+
projectName,
|
|
427
|
+
scenarioImpact.dependents,
|
|
428
|
+
scenarioGraph.nodes,
|
|
429
|
+
scenarioGraph.dependencies,
|
|
430
|
+
config.depConstraints,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 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
|
+
);
|
|
441
|
+
|
|
442
|
+
// Step 6: Build evolution alignment for both sides
|
|
443
|
+
const currentResolved = currentDecisionImpact
|
|
444
|
+
? currentDecisionImpact.decisions.map((d) => d.id)
|
|
445
|
+
: [];
|
|
446
|
+
const scenarioResolved = scenarioDecisionImpact
|
|
447
|
+
? scenarioDecisionImpact.decisions.map((d) => d.id)
|
|
448
|
+
: [];
|
|
449
|
+
|
|
450
|
+
const currentEvolution = buildScenarioEvolutionAlignment(
|
|
451
|
+
projectName,
|
|
452
|
+
currentImpact,
|
|
453
|
+
currentConstraintImpact,
|
|
454
|
+
currentResolved,
|
|
455
|
+
);
|
|
456
|
+
const scenarioEvolution = buildScenarioEvolutionAlignment(
|
|
457
|
+
projectName,
|
|
458
|
+
scenarioImpact,
|
|
459
|
+
scenarioConstraintImpact,
|
|
460
|
+
scenarioResolved,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// Step 7: Compute delta
|
|
464
|
+
const currentState = {
|
|
465
|
+
impact: currentImpact,
|
|
466
|
+
constraintImpact: currentConstraintImpact,
|
|
467
|
+
decisionImpact: currentDecisionImpact,
|
|
468
|
+
};
|
|
469
|
+
const scenarioState = {
|
|
470
|
+
impact: scenarioImpact,
|
|
471
|
+
constraintImpact: scenarioConstraintImpact,
|
|
472
|
+
decisionImpact: scenarioDecisionImpact,
|
|
473
|
+
};
|
|
474
|
+
const delta = computeDelta(currentState, scenarioState);
|
|
475
|
+
|
|
476
|
+
// Step 8: Build evidence chain
|
|
477
|
+
const evidenceChain = {
|
|
478
|
+
baseRevision: base.revision,
|
|
479
|
+
appliedChanges: applied,
|
|
480
|
+
currentState: "current",
|
|
481
|
+
scenarioState: "scenario",
|
|
482
|
+
delta: {
|
|
483
|
+
dependentsAdded: delta.dependentsAdded,
|
|
484
|
+
dependentsRemoved: delta.dependentsRemoved,
|
|
485
|
+
constraintsChanged: delta.constraintsChanged,
|
|
486
|
+
decisionsChanged: delta.decisionsChanged,
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// Step 9: Evaluate governance impact on scenario (hypothetical re-evaluation)
|
|
491
|
+
const scenarioAffectedProjects = [projectName, ...scenarioImpact.dependents];
|
|
492
|
+
let scenarioFindings = null;
|
|
493
|
+
let scenarioDebt = null;
|
|
494
|
+
if (availableFindings) {
|
|
495
|
+
// Re-filter findings for the hypothetical graph's affected projects
|
|
496
|
+
const affectedSet = new Set(scenarioAffectedProjects);
|
|
497
|
+
scenarioFindings = availableFindings.filter((f) => {
|
|
498
|
+
const source = f.source ?? f.project ?? "";
|
|
499
|
+
const target = f.target ?? "";
|
|
500
|
+
return affectedSet.has(source) || affectedSet.has(target);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (availableDebt) {
|
|
504
|
+
const affectedSet = new Set(scenarioAffectedProjects);
|
|
505
|
+
scenarioDebt = availableDebt.filter((d) => {
|
|
506
|
+
if (d.kind === "drift" || d.kind === "unresolved") {
|
|
507
|
+
return affectedSet.has(d.source ?? "");
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Step 10: Build notes — coverage and completeness
|
|
514
|
+
const notes = [
|
|
515
|
+
"virtual evaluation — not authoritative",
|
|
516
|
+
"this scenario has not been committed; run `check` for the real verdict",
|
|
517
|
+
];
|
|
518
|
+
if (config && config.depConstraints) {
|
|
519
|
+
notes.push(
|
|
520
|
+
"constraint impact covers only depConstraints (3 of 15 violation types). " +
|
|
521
|
+
"A project with no violations here may still violate other rules.",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (!availableFindings) {
|
|
525
|
+
notes.push(
|
|
526
|
+
"finding impact not re-evaluated — no findings data provided to scenario. " +
|
|
527
|
+
"Pass findings data for complete governance re-evaluation.",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (!availableDebt) {
|
|
531
|
+
notes.push(
|
|
532
|
+
"debt impact not re-evaluated — no debt data provided to scenario. " +
|
|
533
|
+
"Pass debt data for complete governance re-evaluation.",
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (refused.length > 0) {
|
|
537
|
+
notes.push(`changes that could not be applied: ${refused.join("; ")}`);
|
|
538
|
+
}
|
|
539
|
+
// Step 11: Assemble — determine provenance and completeness semantics
|
|
540
|
+
// `complete` reflects whether the core evaluation (graph + constraints)
|
|
541
|
+
// completed. `governanceComplete` tracks whether governance dimensions
|
|
542
|
+
// were fully evaluated.
|
|
543
|
+
const governanceComplete = availableFindings !== null && availableDebt !== null;
|
|
544
|
+
const isComplete = refused.length === 0;
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
virtual: true,
|
|
548
|
+
notAuthoritative: true,
|
|
549
|
+
project: projectName,
|
|
550
|
+
base,
|
|
551
|
+
changes: applied,
|
|
552
|
+
refused: refused.length > 0 ? refused : undefined,
|
|
553
|
+
evidenceChain,
|
|
554
|
+
current: {
|
|
555
|
+
impact: {
|
|
556
|
+
project: currentImpact.project,
|
|
557
|
+
direct: currentImpact.direct,
|
|
558
|
+
transitive: currentImpact.transitive,
|
|
559
|
+
dependents: currentImpact.dependents,
|
|
560
|
+
},
|
|
561
|
+
constraintImpact: currentConstraintImpact,
|
|
562
|
+
decisionImpact: currentDecisionImpact,
|
|
563
|
+
evolutionAlignment: currentEvolution,
|
|
564
|
+
},
|
|
565
|
+
scenario: {
|
|
566
|
+
impact: {
|
|
567
|
+
project: scenarioImpact.project,
|
|
568
|
+
direct: scenarioImpact.direct,
|
|
569
|
+
transitive: scenarioImpact.transitive,
|
|
570
|
+
dependents: scenarioImpact.dependents,
|
|
571
|
+
},
|
|
572
|
+
constraintImpact: scenarioConstraintImpact,
|
|
573
|
+
decisionImpact: scenarioDecisionImpact,
|
|
574
|
+
evolutionAlignment: scenarioEvolution,
|
|
575
|
+
...(scenarioFindings !== null ? { findings: scenarioFindings } : {}),
|
|
576
|
+
...(scenarioDebt !== null ? { debt: scenarioDebt } : {}),
|
|
577
|
+
},
|
|
578
|
+
governanceImpact: {
|
|
579
|
+
findingsReEvaluated: availableFindings !== null,
|
|
580
|
+
debtReEvaluated: availableDebt !== null,
|
|
581
|
+
governanceComplete,
|
|
582
|
+
scenarioFindingsCount: scenarioFindings?.length ?? 0,
|
|
583
|
+
scenarioDebtCount: scenarioDebt?.length ?? 0,
|
|
584
|
+
},
|
|
585
|
+
delta,
|
|
586
|
+
complete: isComplete,
|
|
587
|
+
notes,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Validates and parses a scenario input from a JSON string.
|
|
593
|
+
*
|
|
594
|
+
* @param {string} jsonString The raw JSON string.
|
|
595
|
+
* @returns {ScenarioInput}
|
|
596
|
+
* @throws {Error} When the input is invalid.
|
|
597
|
+
*/
|
|
598
|
+
export function parseScenarioInput(jsonString) {
|
|
599
|
+
let parsed;
|
|
600
|
+
try {
|
|
601
|
+
parsed = JSON.parse(jsonString);
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
throw new Error(`scenario: invalid JSON — ${cause.message}`, { cause });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!parsed || typeof parsed !== "object") {
|
|
607
|
+
throw new Error("scenario: input must be a JSON object");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const changes = parsed.changes;
|
|
611
|
+
if (!Array.isArray(changes)) {
|
|
612
|
+
throw new Error("scenario: 'changes' must be an array");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (changes.length === 0) {
|
|
616
|
+
throw new Error("scenario: 'changes' must contain at least one change");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for (let i = 0; i < changes.length; i++) {
|
|
620
|
+
const change = changes[i];
|
|
621
|
+
if (!change || typeof change !== "object") {
|
|
622
|
+
throw new Error(`scenario: changes[${i}] must be an object`);
|
|
623
|
+
}
|
|
624
|
+
if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`scenario: changes[${i}].type "${change.type}" is not supported — ` +
|
|
627
|
+
`supported types: ${SCENARIO_CHANGE_TYPES.join(", ")}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (typeof change.source !== "string" || change.source.trim() === "") {
|
|
631
|
+
throw new Error(`scenario: changes[${i}].source must be a non-empty string`);
|
|
632
|
+
}
|
|
633
|
+
if (typeof change.target !== "string" || change.target.trim() === "") {
|
|
634
|
+
throw new Error(`scenario: changes[${i}].target must be a non-empty string`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return {
|
|
639
|
+
base: typeof parsed.base === "string" ? parsed.base : undefined,
|
|
640
|
+
changes: changes.map((c) => ({ type: c.type, source: c.source, target: c.target })),
|
|
641
|
+
};
|
|
642
|
+
}
|