@ecoma-io/archkeep 0.18.0 → 0.19.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.
@@ -0,0 +1,515 @@
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
+
40
+ // ---------------------------------------------------------------------------
41
+ // Scenario types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** The supported scenario change types. */
45
+ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Input schema types
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * @typedef {object} DependencyChange
53
+ * @property {"dependency_added"|"dependency_removed"} type
54
+ * @property {string} source The source project of the dependency.
55
+ * @property {string} target The target project of the dependency.
56
+ */
57
+
58
+ /**
59
+ * @typedef {object} ScenarioInput
60
+ * @property {string} [base] Optional git revision for attribution.
61
+ * @property {DependencyChange[]} changes The hypothetical changes to evaluate.
62
+ */
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Output types
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * @typedef {object} ScenarioEvaluation
70
+ * @property {boolean} virtual Always true — a scenario is never authoritative.
71
+ * @property {boolean} notAuthoritative Always true — mirrors `virtual`.
72
+ * @property {string} project The target project being evaluated.
73
+ * @property {object} base The base graph information.
74
+ * @property {string[]} changes The change descriptions that were applied.
75
+ * @property {string[]|undefined} refused Changes that could not be applied, if any.
76
+ * @property {object} current The current impact for the target project.
77
+ * @property {object} scenario The would-be impact after applying the changes.
78
+ * @property {object} delta What would change.
79
+ * @property {boolean} complete Whether the evaluation could be completed.
80
+ * @property {string[]} notes Caveats about the evaluation.
81
+ */
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Graph manipulation
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Deep-clones the graph's nodes and dependencies for mutation.
89
+ *
90
+ * @param {object} graph The project graph: `{nodes, dependencies}`.
91
+ * @returns {{nodes: object, dependencies: object}}
92
+ */
93
+ function cloneGraph(graph) {
94
+ const nodes = { ...graph.nodes };
95
+ const dependencies = {};
96
+ for (const [source, edges] of Object.entries(graph.dependencies)) {
97
+ dependencies[source] = edges.map((e) => ({ ...e }));
98
+ }
99
+ return { nodes, dependencies };
100
+ }
101
+
102
+ /**
103
+ * Applies a scenario's changes to a graph, producing a would-be graph.
104
+ *
105
+ * @param {object} graph The base graph to apply changes to.
106
+ * @param {DependencyChange[]} changes The hypothetical changes.
107
+ * @returns {{graph: object, applied: string[], refused: string[]}}
108
+ */
109
+ function applyChanges(graph, changes) {
110
+ const cloned = cloneGraph(graph);
111
+ const applied = [];
112
+ const refused = [];
113
+
114
+ for (const change of changes) {
115
+ if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
116
+ refused.push(`unsupported change type: "${change.type}"`);
117
+ continue;
118
+ }
119
+
120
+ if (change.type === "dependency_added") {
121
+ // Validate that source and target exist in the graph
122
+ if (!Object.hasOwn(cloned.nodes, change.source)) {
123
+ refused.push(`cannot add dependency: source project "${change.source}" not in graph`);
124
+ continue;
125
+ }
126
+ if (!Object.hasOwn(cloned.nodes, change.target)) {
127
+ refused.push(`cannot add dependency: target project "${change.target}" not in graph`);
128
+ continue;
129
+ }
130
+
131
+ // Check if edge already exists
132
+ const existing = cloned.dependencies[change.source] ?? [];
133
+ if (existing.some((e) => e.target === change.target)) {
134
+ applied.push(`dependency already exists: ${change.source} → ${change.target}`);
135
+ continue;
136
+ }
137
+
138
+ // Add the edge
139
+ if (!cloned.dependencies[change.source]) {
140
+ cloned.dependencies[change.source] = [];
141
+ }
142
+ cloned.dependencies[change.source].push({
143
+ target: change.target,
144
+ type: "static",
145
+ source: change.source,
146
+ });
147
+ applied.push(`added dependency: ${change.source} → ${change.target}`);
148
+ }
149
+
150
+ if (change.type === "dependency_removed") {
151
+ const existing = cloned.dependencies[change.source] ?? [];
152
+ const idx = existing.findIndex((e) => e.target === change.target);
153
+ if (idx === -1) {
154
+ refused.push(
155
+ `cannot remove dependency: no edge from "${change.source}" to "${change.target}"`,
156
+ );
157
+ continue;
158
+ }
159
+ existing.splice(idx, 1);
160
+ applied.push(`removed dependency: ${change.source} → ${change.target}`);
161
+ }
162
+ }
163
+
164
+ return { graph: cloned, applied, refused };
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Decision impact (reuses impact-statement's buildDecisionImpact)
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Builds decision impact for the scenario's would-be state.
173
+ *
174
+ * @param {string} root Workspace root path.
175
+ * @param {object[]} constraintImpact Per-dependent constraint analysis.
176
+ * @param {object} config The loaded boundary config.
177
+ * @returns {{decisions: object[], unresolvedDecisionRefs: string[]}|null}
178
+ */
179
+ function buildScenarioDecisionImpact(root, constraintImpact, config) {
180
+ if (!constraintImpact || !config?.depConstraints) {
181
+ return { decisions: [], unresolvedDecisionRefs: [] };
182
+ }
183
+
184
+ // Collect unique decisionRefs ONLY from constraint rows that are actually
185
+ // AFFECTED by the scenario change — rows that govern edges from impacted
186
+ // dependents. A decisionRef in the config is not enough.
187
+ const seenRefs = new Set();
188
+ const affectedRefs = [];
189
+
190
+ // Build a set of all constraint rows that appear in constraintImpact,
191
+ // using identity matching (the rows are the actual config row objects).
192
+ const activeRows = new Set(constraintImpact.flatMap((entry) => entry.constraintRows));
193
+
194
+ for (const row of config.depConstraints) {
195
+ if (!row.decisionRef) continue;
196
+ if (activeRows.has(row) && !seenRefs.has(row.decisionRef)) {
197
+ seenRefs.add(row.decisionRef);
198
+ affectedRefs.push(row.decisionRef);
199
+ }
200
+ }
201
+
202
+ if (affectedRefs.length === 0) {
203
+ return { decisions: [], unresolvedDecisionRefs: [] };
204
+ }
205
+
206
+ let adrContext;
207
+ try {
208
+ adrContext = readAdrContext(root);
209
+ } catch {
210
+ return { decisions: [], unresolvedDecisionRefs: [...affectedRefs].sort() };
211
+ }
212
+
213
+ const { byId, knownFitness } = adrContext;
214
+ const unresolvedDecisionRefs = [];
215
+ const decisions = [];
216
+
217
+ for (const ref of affectedRefs) {
218
+ const resolution = resolveDecisionRef(byId, knownFitness, ref);
219
+ if (resolution === "unknown") {
220
+ unresolvedDecisionRefs.push(ref);
221
+ continue;
222
+ }
223
+ if (resolution === "fitness") {
224
+ decisions.push({ id: ref, kind: "fitness", resolution: "known" });
225
+ continue;
226
+ }
227
+ const record = byId.get(stripAdrPrefix(ref));
228
+ decisions.push({
229
+ id: record.id,
230
+ kind: "adr",
231
+ status: record.status,
232
+ hasAuthority: hasAuthority(record.status),
233
+ });
234
+ }
235
+
236
+ return {
237
+ decisions: decisions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
238
+ unresolvedDecisionRefs: [...new Set(unresolvedDecisionRefs)].sort(),
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Builds evolution alignment for the scenario.
244
+ *
245
+ * @param {string} projectName The target project.
246
+ *
247
+ * @param {object[]} [constraintImpact]
248
+ * @param {string[]} [resolvedDecisions]
249
+ * @returns {{projects: string[], boundaries: string[], constraints: string[], decisions: string[]}}
250
+ */
251
+ function buildScenarioEvolutionAlignment(projectName, impact, constraintImpact, resolvedDecisions) {
252
+ const affectedProjects = [projectName, ...impact.dependents];
253
+ const affectedConstraints = [];
254
+ const affectedBoundaries = [];
255
+
256
+ if (constraintImpact) {
257
+ for (const entry of constraintImpact) {
258
+ // Collect edge identities for each affected boundary
259
+ for (const edge of entry.edges) {
260
+ const edgeId = `${entry.project}>${edge.target}:${edge.type}`;
261
+ if (!affectedBoundaries.includes(edgeId)) {
262
+ affectedBoundaries.push(edgeId);
263
+ }
264
+ }
265
+ // Collect constraint row labels using same format as buildEvolutionAlignment
266
+ for (const row of entry.constraintRows) {
267
+ const label = isComboDepConstraint(row)
268
+ ? `allSourceTags:${row.allSourceTags.join(",")}`
269
+ : `sourceTag:${row.sourceTag}`;
270
+ if (!affectedConstraints.includes(label)) {
271
+ affectedConstraints.push(label);
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ return {
278
+ projects: [...new Set(affectedProjects)].sort(),
279
+ boundaries: affectedBoundaries.sort(),
280
+ constraints: affectedConstraints.sort(),
281
+ decisions: resolvedDecisions ? [...new Set(resolvedDecisions)].sort() : [],
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Computes the delta between current and scenario.
287
+ *
288
+ * @param {object} current Current impact.
289
+ * @param {object} scenario Scenario impact.
290
+ * @returns {{dependentsAdded: string[], dependentsRemoved: string[],
291
+ * constraintsChanged: boolean, decisionsChanged: boolean}}
292
+ */
293
+ function computeDelta(current, scenario) {
294
+ const currentDeps = new Set(current.impact.dependents ?? []);
295
+ const scenarioDeps = new Set(scenario.impact.dependents ?? []);
296
+
297
+ const dependentsAdded = [...scenarioDeps].filter((d) => !currentDeps.has(d)).sort();
298
+ const dependentsRemoved = [...currentDeps].filter((d) => !scenarioDeps.has(d)).sort();
299
+
300
+ const constraintsChanged =
301
+ JSON.stringify(current.constraintImpact ?? []) !==
302
+ JSON.stringify(scenario.constraintImpact ?? []);
303
+
304
+ const decisionsChanged =
305
+ JSON.stringify(current.decisionImpact ?? []) !== JSON.stringify(scenario.decisionImpact ?? []);
306
+
307
+ return {
308
+ dependentsAdded,
309
+ dependentsRemoved,
310
+ constraintsChanged,
311
+ decisionsChanged,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Evaluates a scenario against the current workspace.
317
+ *
318
+ * @param {string} projectName The target project.
319
+ * @param {object} commandContext The resolved command context.
320
+ * @param {ScenarioInput} scenarioInput The scenario description.
321
+ * @param {object|null} [config] The loaded boundary config.
322
+ * @returns {ScenarioEvaluation}
323
+ */
324
+ export function evaluateScenario(projectName, commandContext, scenarioInput, config = null) {
325
+ const { root, graph } = commandContext;
326
+
327
+ // Step 1: Compute current impact
328
+ const currentImpact = computeImpact(projectName, graph);
329
+
330
+ let currentConstraintImpact = null;
331
+ if (config && config.depConstraints) {
332
+ currentConstraintImpact = computeImpactConstraints(
333
+ projectName,
334
+ currentImpact.dependents,
335
+ graph.nodes,
336
+ graph.dependencies,
337
+ config.depConstraints,
338
+ );
339
+ }
340
+
341
+ // Step 2: Apply scenario changes to the graph
342
+ const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
343
+
344
+ // Step 3: Compute scenario impact
345
+ const scenarioImpact = computeImpact(projectName, scenarioGraph);
346
+
347
+ let scenarioConstraintImpact = null;
348
+ if (config && config.depConstraints) {
349
+ scenarioConstraintImpact = computeImpactConstraints(
350
+ projectName,
351
+ scenarioImpact.dependents,
352
+ scenarioGraph.nodes,
353
+ scenarioGraph.dependencies,
354
+ config.depConstraints,
355
+ );
356
+ }
357
+
358
+ // Step 4: Build decision impact for both sides
359
+ const currentDecisionImpact = buildScenarioDecisionImpact(root, currentConstraintImpact, config);
360
+ const scenarioDecisionImpact = buildScenarioDecisionImpact(
361
+ root,
362
+ scenarioConstraintImpact,
363
+ config,
364
+ );
365
+
366
+ // Step 5: Build evolution alignment for both sides
367
+ const currentResolved = currentDecisionImpact
368
+ ? currentDecisionImpact.decisions.map((d) => d.id)
369
+ : [];
370
+ const scenarioResolved = scenarioDecisionImpact
371
+ ? scenarioDecisionImpact.decisions.map((d) => d.id)
372
+ : [];
373
+
374
+ const currentEvolution = buildScenarioEvolutionAlignment(
375
+ projectName,
376
+ currentImpact,
377
+ currentConstraintImpact,
378
+ currentResolved,
379
+ );
380
+ const scenarioEvolution = buildScenarioEvolutionAlignment(
381
+ projectName,
382
+ scenarioImpact,
383
+ scenarioConstraintImpact,
384
+ scenarioResolved,
385
+ );
386
+
387
+ // Step 6: Compute delta
388
+ const currentState = {
389
+ impact: currentImpact,
390
+ constraintImpact: currentConstraintImpact,
391
+ decisionImpact: currentDecisionImpact,
392
+ };
393
+ const scenarioState = {
394
+ impact: scenarioImpact,
395
+ constraintImpact: scenarioConstraintImpact,
396
+ decisionImpact: scenarioDecisionImpact,
397
+ };
398
+ const delta = computeDelta(currentState, scenarioState);
399
+
400
+ // Step 7: Build notes — coverage and completeness
401
+ const notes = [
402
+ "virtual evaluation — not authoritative",
403
+ "this scenario has not been committed; run `check` for the real verdict",
404
+ ];
405
+ if (config && config.depConstraints) {
406
+ notes.push(
407
+ "constraint impact covers only depConstraints (3 of 15 violation types). " +
408
+ "A project with no violations here may still violate other rules.",
409
+ );
410
+ }
411
+ notes.push(
412
+ "finding and debt impact are not yet evaluated. " +
413
+ "The scenario covers dependency structure and constraint violations only.",
414
+ );
415
+ if (refused.length > 0) {
416
+ notes.push(`changes that could not be applied: ${refused.join("; ")}`);
417
+ }
418
+ // Step 8: Assemble — determine provenance and completeness semantics
419
+ // Determine provenance: base is attributable only when a real revision was provided
420
+ const baseRevision = scenarioInput.base ?? "(current workspace)";
421
+ const isAttributed = typeof scenarioInput.base === "string" && scenarioInput.base.length > 0;
422
+
423
+ // Complete means all changes were applied AND all required evaluations completed
424
+ const isComplete = refused.length === 0;
425
+
426
+ return {
427
+ virtual: true,
428
+ notAuthoritative: true,
429
+ project: projectName,
430
+ base: {
431
+ revision: baseRevision,
432
+ attributed: isAttributed,
433
+ },
434
+ changes: applied,
435
+ refused: refused.length > 0 ? refused : undefined,
436
+ current: {
437
+ impact: {
438
+ project: currentImpact.project,
439
+ direct: currentImpact.direct,
440
+ transitive: currentImpact.transitive,
441
+ dependents: currentImpact.dependents,
442
+ },
443
+ constraintImpact: currentConstraintImpact,
444
+ decisionImpact: currentDecisionImpact,
445
+ evolutionAlignment: currentEvolution,
446
+ },
447
+ scenario: {
448
+ impact: {
449
+ project: scenarioImpact.project,
450
+ direct: scenarioImpact.direct,
451
+ transitive: scenarioImpact.transitive,
452
+ dependents: scenarioImpact.dependents,
453
+ },
454
+ constraintImpact: scenarioConstraintImpact,
455
+ decisionImpact: scenarioDecisionImpact,
456
+ evolutionAlignment: scenarioEvolution,
457
+ },
458
+ delta,
459
+ complete: isComplete,
460
+ notes,
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Validates and parses a scenario input from a JSON string.
466
+ *
467
+ * @param {string} jsonString The raw JSON string.
468
+ * @returns {ScenarioInput}
469
+ * @throws {Error} When the input is invalid.
470
+ */
471
+ export function parseScenarioInput(jsonString) {
472
+ let parsed;
473
+ try {
474
+ parsed = JSON.parse(jsonString);
475
+ } catch (cause) {
476
+ throw new Error(`scenario: invalid JSON — ${cause.message}`, { cause });
477
+ }
478
+
479
+ if (!parsed || typeof parsed !== "object") {
480
+ throw new Error("scenario: input must be a JSON object");
481
+ }
482
+
483
+ const changes = parsed.changes;
484
+ if (!Array.isArray(changes)) {
485
+ throw new Error("scenario: 'changes' must be an array");
486
+ }
487
+
488
+ if (changes.length === 0) {
489
+ throw new Error("scenario: 'changes' must contain at least one change");
490
+ }
491
+
492
+ for (let i = 0; i < changes.length; i++) {
493
+ const change = changes[i];
494
+ if (!change || typeof change !== "object") {
495
+ throw new Error(`scenario: changes[${i}] must be an object`);
496
+ }
497
+ if (!SCENARIO_CHANGE_TYPES.includes(change.type)) {
498
+ throw new Error(
499
+ `scenario: changes[${i}].type "${change.type}" is not supported — ` +
500
+ `supported types: ${SCENARIO_CHANGE_TYPES.join(", ")}`,
501
+ );
502
+ }
503
+ if (typeof change.source !== "string" || change.source.trim() === "") {
504
+ throw new Error(`scenario: changes[${i}].source must be a non-empty string`);
505
+ }
506
+ if (typeof change.target !== "string" || change.target.trim() === "") {
507
+ throw new Error(`scenario: changes[${i}].target must be a non-empty string`);
508
+ }
509
+ }
510
+
511
+ return {
512
+ base: typeof parsed.base === "string" ? parsed.base : undefined,
513
+ changes: changes.map((c) => ({ type: c.type, source: c.source, target: c.target })),
514
+ };
515
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The `scenario` command: evaluate a hypothetical change against the current
3
+ * workspace and report the current-versus-scenario comparison.
4
+ *
5
+ * A scenario is a virtual, read-only evaluation. It never mutates the
6
+ * workspace, never writes to canonical history, and never emits an
7
+ * `EvolutionEvent`. Every output field carries a `virtual: true` /
8
+ * `notAuthoritative` marker.
9
+ *
10
+ * @module
11
+ */
12
+ import { resolveProvenance } from "./provenance.mjs";
13
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
14
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
15
+ import { evaluateScenario, parseScenarioInput } from "./scenario-evaluation.mjs";
16
+ export { parseScenarioInput } from "./scenario-evaluation.mjs";
17
+
18
+ /**
19
+ * Runs the `scenario` command: parses the scenario input, evaluates it, and
20
+ * returns the comparison.
21
+ *
22
+ * @param {string} projectName The target project.
23
+ * @param {string} scenarioJson The scenario description as JSON.
24
+ * @param {object} commandContext From `resolveCommandContext`.
25
+ * @param {object} [config] The loaded boundary config.
26
+ * @returns {{status: string, scenario: object, coverage: object, report: {text: string, json: string}}}
27
+ */
28
+ export function scenarioCommand(projectName, scenarioJson, commandContext, config = null) {
29
+ const { root, provider, marker, graph, pluginGap } = commandContext;
30
+
31
+ // Descriptive commands refuse when the graph is known to be incomplete.
32
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
33
+ throw new Error(
34
+ `archkeep: refusing to evaluate a scenario for an Nx workspace where this plugin is ` +
35
+ `not registered but polyglot manifests exist under project roots ` +
36
+ `(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
37
+ `so the scenario would silently under-represent the real architecture. ` +
38
+ `Register the plugin in nx.json: ` +
39
+ `"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
40
+ `if they are not in use.`,
41
+ );
42
+ }
43
+
44
+ // Parse the scenario input
45
+ const scenarioInput = parseScenarioInput(scenarioJson);
46
+
47
+ // Check coverage
48
+ const notAnalyzed = commandContext.analysis.failures
49
+ .filter(isWholeFileFailure)
50
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
51
+
52
+ if (notAnalyzed.length > 0) {
53
+ throw new Error(
54
+ `archkeep: the graph has incomplete coverage — ${notAnalyzed.length} file` +
55
+ `${notAnalyzed.length === 1 ? "" : "s"} could not be analyzed, so the scenario may ` +
56
+ `under-represent the real architecture. Fix the unanalyzed files and re-run.`,
57
+ );
58
+ }
59
+
60
+ // Evaluate
61
+ const scenario = evaluateScenario(projectName, commandContext, scenarioInput, config);
62
+
63
+ const coverage = {
64
+ complete: scenario.complete,
65
+ projects: Object.keys(graph.nodes).length,
66
+ analyzedFiles: commandContext.analysis.analyzed,
67
+ imports: commandContext.analysis.imports.length,
68
+ notAnalyzed: [],
69
+ blindSpots: commandContext.analysis.failures
70
+ .filter((f) => !isWholeFileFailure(f))
71
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
72
+ notes: [
73
+ "scenario evaluation is virtual and not authoritative — run `check` for the real verdict",
74
+ "per-edge verdicts cover only depConstraints (3 of 15 violation types)",
75
+ ],
76
+ };
77
+
78
+ const context = { root, provider, marker, provenance: resolveProvenance(root) };
79
+
80
+ const result = {
81
+ virtual: scenario.virtual,
82
+ notAuthoritative: scenario.notAuthoritative,
83
+ complete: scenario.complete,
84
+ project: scenario.project,
85
+ base: scenario.base,
86
+ changes: scenario.changes,
87
+ refused: scenario.refused,
88
+ current: scenario.current,
89
+ scenario: scenario.scenario,
90
+ delta: scenario.delta,
91
+ notes: scenario.notes,
92
+ };
93
+
94
+ const envelope = jsonEnvelope({
95
+ command: "scenario",
96
+ context,
97
+ status: "ok",
98
+ exitCode: 0,
99
+ coverage,
100
+ result,
101
+ });
102
+
103
+ const text = formatScenarioReport(scenario);
104
+
105
+ return {
106
+ status: "ok",
107
+ scenario: result,
108
+ coverage,
109
+ report: {
110
+ text,
111
+ json: renderJson(envelope),
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Formats a scenario evaluation as terminal text.
118
+ *
119
+ * @param {object} scenario The scenario evaluation result.
120
+ * @returns {string}
121
+ */
122
+ function formatScenarioReport(scenario) {
123
+ const lines = [];
124
+
125
+ lines.push(`Scenario evaluation for "${scenario.project}"`);
126
+ lines.push(`${"=".repeat(50)}`);
127
+ lines.push(`Virtual: ${scenario.virtual} | Not authoritative: ${scenario.notAuthoritative}`);
128
+ lines.push("");
129
+
130
+ if (scenario.changes.length > 0) {
131
+ lines.push("Changes applied:");
132
+ for (const change of scenario.changes) {
133
+ lines.push(` ${change}`);
134
+ }
135
+ }
136
+
137
+ if (scenario.refused && scenario.refused.length > 0) {
138
+ lines.push("Changes refused:");
139
+ for (const ref of scenario.refused) {
140
+ lines.push(` ✖ ${ref}`);
141
+ }
142
+ }
143
+
144
+ lines.push("");
145
+ lines.push("Current impact:");
146
+ lines.push(` Direct: ${scenario.current.impact.direct.length} project(s)`);
147
+ lines.push(` Transitive: ${scenario.current.impact.transitive.length} project(s)`);
148
+ lines.push(` Dependents: ${scenario.current.impact.dependents.length} project(s)`);
149
+ lines.push("");
150
+
151
+ lines.push("Scenario impact:");
152
+ lines.push(` Direct: ${scenario.scenario.impact.direct.length} project(s)`);
153
+ lines.push(` Transitive: ${scenario.scenario.impact.transitive.length} project(s)`);
154
+ lines.push(` Dependents: ${scenario.scenario.impact.dependents.length} project(s)`);
155
+ lines.push("");
156
+
157
+ lines.push("Delta:");
158
+ const delta = scenario.delta;
159
+ if (delta.dependentsAdded.length > 0) {
160
+ lines.push(` Dependents added: ${delta.dependentsAdded.join(", ")}`);
161
+ }
162
+ if (delta.dependentsRemoved.length > 0) {
163
+ lines.push(` Dependents removed: ${delta.dependentsRemoved.join(", ")}`);
164
+ }
165
+ if (delta.dependentsAdded.length === 0 && delta.dependentsRemoved.length === 0) {
166
+ lines.push(" No change to dependent set");
167
+ }
168
+ if (delta.constraintsChanged) {
169
+ lines.push(" Constraint impact: CHANGED");
170
+ }
171
+ if (delta.decisionsChanged) {
172
+ lines.push(" Decision impact: CHANGED");
173
+ }
174
+ lines.push("");
175
+
176
+ if (scenario.notes.length > 0) {
177
+ lines.push("Notes:");
178
+ for (const note of scenario.notes) {
179
+ lines.push(` ${note}`);
180
+ }
181
+ }
182
+
183
+ return lines.join("\n");
184
+ }