@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.
@@ -0,0 +1,186 @@
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
+ governanceImpact: scenario.governanceImpact,
91
+ evidenceChain: scenario.evidenceChain,
92
+ delta: scenario.delta,
93
+ notes: scenario.notes,
94
+ };
95
+
96
+ const envelope = jsonEnvelope({
97
+ command: "scenario",
98
+ context,
99
+ status: "ok",
100
+ exitCode: 0,
101
+ coverage,
102
+ result,
103
+ });
104
+
105
+ const text = formatScenarioReport(scenario);
106
+
107
+ return {
108
+ status: "ok",
109
+ scenario: result,
110
+ coverage,
111
+ report: {
112
+ text,
113
+ json: renderJson(envelope),
114
+ },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Formats a scenario evaluation as terminal text.
120
+ *
121
+ * @param {object} scenario The scenario evaluation result.
122
+ * @returns {string}
123
+ */
124
+ function formatScenarioReport(scenario) {
125
+ const lines = [];
126
+
127
+ lines.push(`Scenario evaluation for "${scenario.project}"`);
128
+ lines.push(`${"=".repeat(50)}`);
129
+ lines.push(`Virtual: ${scenario.virtual} | Not authoritative: ${scenario.notAuthoritative}`);
130
+ lines.push("");
131
+
132
+ if (scenario.changes.length > 0) {
133
+ lines.push("Changes applied:");
134
+ for (const change of scenario.changes) {
135
+ lines.push(` ${change}`);
136
+ }
137
+ }
138
+
139
+ if (scenario.refused && scenario.refused.length > 0) {
140
+ lines.push("Changes refused:");
141
+ for (const ref of scenario.refused) {
142
+ lines.push(` ✖ ${ref}`);
143
+ }
144
+ }
145
+
146
+ lines.push("");
147
+ lines.push("Current impact:");
148
+ lines.push(` Direct: ${scenario.current.impact.direct.length} project(s)`);
149
+ lines.push(` Transitive: ${scenario.current.impact.transitive.length} project(s)`);
150
+ lines.push(` Dependents: ${scenario.current.impact.dependents.length} project(s)`);
151
+ lines.push("");
152
+
153
+ lines.push("Scenario impact:");
154
+ lines.push(` Direct: ${scenario.scenario.impact.direct.length} project(s)`);
155
+ lines.push(` Transitive: ${scenario.scenario.impact.transitive.length} project(s)`);
156
+ lines.push(` Dependents: ${scenario.scenario.impact.dependents.length} project(s)`);
157
+ lines.push("");
158
+
159
+ lines.push("Delta:");
160
+ const delta = scenario.delta;
161
+ if (delta.dependentsAdded.length > 0) {
162
+ lines.push(` Dependents added: ${delta.dependentsAdded.join(", ")}`);
163
+ }
164
+ if (delta.dependentsRemoved.length > 0) {
165
+ lines.push(` Dependents removed: ${delta.dependentsRemoved.join(", ")}`);
166
+ }
167
+ if (delta.dependentsAdded.length === 0 && delta.dependentsRemoved.length === 0) {
168
+ lines.push(" No change to dependent set");
169
+ }
170
+ if (delta.constraintsChanged) {
171
+ lines.push(" Constraint impact: CHANGED");
172
+ }
173
+ if (delta.decisionsChanged) {
174
+ lines.push(" Decision impact: CHANGED");
175
+ }
176
+ lines.push("");
177
+
178
+ if (scenario.notes.length > 0) {
179
+ lines.push("Notes:");
180
+ for (const note of scenario.notes) {
181
+ lines.push(` ${note}`);
182
+ }
183
+ }
184
+
185
+ return lines.join("\n");
186
+ }
@@ -97,6 +97,19 @@ export function buildDecision(run) {
97
97
  );
98
98
  }
99
99
 
100
+ // The `findings` count is the cardinal evidence number — a non-negative
101
+ // integer that the I1–I5 invariants all rely on. A missing, non-numeric, or
102
+ // negative value would silently falsify every comparison (`undefined > 0` is
103
+ // `false`), producing a clean verdict over a run whose counts were never set
104
+ // or are logically impossible — the exact silent direction this module exists
105
+ // to refuse.
106
+ if (typeof run.findings !== "number" || !Number.isFinite(run.findings) || run.findings < 0) {
107
+ throw new Error(
108
+ `archkeep: refusing to build a decision where findings is ${JSON.stringify(run.findings)} ` +
109
+ `— findings must be a non-negative number, or the verdict invariants cannot be enforced. ` +
110
+ `This is a bug in the command that built the decision.`,
111
+ );
112
+ }
100
113
  if (verdict === "pass") {
101
114
  if (run.coverageComplete !== true) {
102
115
  throw new Error(