infernoflow 0.23.0 → 0.27.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/dist/bin/infernoflow.mjs +38 -2
- package/dist/lib/commands/claudeMd.mjs +14 -3
- package/dist/lib/commands/dashboard.mjs +312 -3
- package/dist/lib/commands/graph.mjs +337 -0
- package/dist/lib/commands/impact.mjs +325 -0
- package/dist/lib/commands/scan.mjs +45 -26
- package/dist/lib/commands/stability.mjs +293 -0
- package/dist/lib/commands/why.mjs +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow graph
|
|
3
|
+
*
|
|
4
|
+
* Builds a capability dependency graph from scan.json.
|
|
5
|
+
* Shows which capabilities call which — so changing one reveals its downstream impact.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow graph Print full dependency tree
|
|
9
|
+
* infernoflow graph --cap auth-login Show deps for one capability (up + down)
|
|
10
|
+
* infernoflow graph --json Machine-readable graph.json to stdout
|
|
11
|
+
* infernoflow graph --check Warn if frozen/stable caps have new dependents
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
17
|
+
|
|
18
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function loadJson(p) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
22
|
+
catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getLevel(cap) {
|
|
26
|
+
return cap?.stability || "experimental";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LEVEL_ICON = { frozen: "🧊", stable: "〰️ ", experimental: "🌊" };
|
|
30
|
+
const LEVEL_COLOR = { frozen: red, stable: yellow, experimental: green };
|
|
31
|
+
|
|
32
|
+
// ── graph builder ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build edges: capA → capB if any function in capA calls a function in capB.
|
|
36
|
+
*
|
|
37
|
+
* Strategy:
|
|
38
|
+
* 1. Build a function-name → capId index from scan data
|
|
39
|
+
* 2. For each cap, check its calls[] against the index
|
|
40
|
+
* 3. If a call matches a function in another cap → edge
|
|
41
|
+
*/
|
|
42
|
+
function buildGraph(scanCaps, allCaps) {
|
|
43
|
+
// capId → { id, name, stability, functions[], calls[], services[], dbCalls[], httpCalls[] }
|
|
44
|
+
const nodes = {};
|
|
45
|
+
// capId → Set<capId> (edges: this cap calls that cap)
|
|
46
|
+
const edges = {};
|
|
47
|
+
// capId → Set<capId> (reverse: this cap is called by those caps)
|
|
48
|
+
const reverse = {};
|
|
49
|
+
|
|
50
|
+
// Build function → capId index
|
|
51
|
+
const funcIndex = {}; // functionName → capId
|
|
52
|
+
for (const entry of scanCaps) {
|
|
53
|
+
const capFull = allCaps.find(c => c.id === entry.id) || {};
|
|
54
|
+
nodes[entry.id] = {
|
|
55
|
+
id: entry.id,
|
|
56
|
+
name: entry.name || capFull.name || capFull.title || entry.id,
|
|
57
|
+
stability: capFull.stability || "experimental",
|
|
58
|
+
functions: entry.codeAnalysis?.functions || [],
|
|
59
|
+
calls: entry.codeAnalysis?.calls || [],
|
|
60
|
+
services: entry.codeAnalysis?.services || [],
|
|
61
|
+
dbCalls: entry.codeAnalysis?.dbCalls || [],
|
|
62
|
+
httpCalls: entry.codeAnalysis?.httpCalls || [],
|
|
63
|
+
};
|
|
64
|
+
edges[entry.id] = new Set();
|
|
65
|
+
reverse[entry.id] = new Set();
|
|
66
|
+
|
|
67
|
+
for (const fn of (entry.codeAnalysis?.functions || [])) {
|
|
68
|
+
const bare = fn.replace(/\(\)$/, "");
|
|
69
|
+
funcIndex[bare] = entry.id;
|
|
70
|
+
funcIndex[bare.toLowerCase()] = entry.id;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build edges from calls[]
|
|
75
|
+
for (const [capId, node] of Object.entries(nodes)) {
|
|
76
|
+
for (const call of node.calls) {
|
|
77
|
+
const bare = call.replace(/\(\)$/, "");
|
|
78
|
+
const target = funcIndex[bare] || funcIndex[bare.toLowerCase()];
|
|
79
|
+
if (target && target !== capId) {
|
|
80
|
+
edges[capId].add(target);
|
|
81
|
+
reverse[target].add(capId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Serialise Sets to arrays
|
|
87
|
+
const serialisedEdges = {};
|
|
88
|
+
const serialisedReverse = {};
|
|
89
|
+
for (const id of Object.keys(nodes)) {
|
|
90
|
+
serialisedEdges[id] = [...edges[id]];
|
|
91
|
+
serialisedReverse[id] = [...reverse[id]];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { nodes, edges: serialisedEdges, reverse: serialisedReverse };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── terminal reporters ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function printFullGraph(graph) {
|
|
100
|
+
const { nodes, edges, reverse } = graph;
|
|
101
|
+
const ids = Object.keys(nodes).sort();
|
|
102
|
+
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(bold(" Capability Dependency Graph"));
|
|
105
|
+
console.log(gray(" ────────────────────────────────────────────────────────────"));
|
|
106
|
+
console.log();
|
|
107
|
+
|
|
108
|
+
let hasDeps = false;
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const node = nodes[id];
|
|
111
|
+
const deps = edges[id] || [];
|
|
112
|
+
const callers = reverse[id] || [];
|
|
113
|
+
const icon = LEVEL_ICON[node.stability] || "🌊";
|
|
114
|
+
const color = LEVEL_COLOR[node.stability] || green;
|
|
115
|
+
|
|
116
|
+
if (deps.length === 0 && callers.length === 0) continue;
|
|
117
|
+
hasDeps = true;
|
|
118
|
+
|
|
119
|
+
console.log(` ${icon} ${bold(color(id))}`);
|
|
120
|
+
|
|
121
|
+
if (deps.length > 0) {
|
|
122
|
+
console.log(gray(" calls →"));
|
|
123
|
+
for (const dep of deps) {
|
|
124
|
+
const depNode = nodes[dep];
|
|
125
|
+
const depIcon = LEVEL_ICON[depNode?.stability] || "🌊";
|
|
126
|
+
console.log(gray(` ${depIcon} ${dep}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (callers.length > 0) {
|
|
130
|
+
console.log(gray(" called by ←"));
|
|
131
|
+
for (const caller of callers) {
|
|
132
|
+
const callerIcon = LEVEL_ICON[nodes[caller]?.stability] || "🌊";
|
|
133
|
+
console.log(gray(` ${callerIcon} ${caller}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!hasDeps) {
|
|
140
|
+
console.log(gray(" No inter-capability dependencies detected."));
|
|
141
|
+
console.log(gray(" Run `infernoflow scan` first to populate call data."));
|
|
142
|
+
console.log();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Summary stats
|
|
146
|
+
const totalEdges = Object.values(graph.edges).reduce((n, arr) => n + arr.length, 0);
|
|
147
|
+
console.log(gray(` ────────────────────────────────────────────────────────────`));
|
|
148
|
+
console.log(gray(` ${ids.length} capabilities · ${totalEdges} dependency edge(s)`));
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function printCapGraph(capId, graph) {
|
|
153
|
+
const { nodes, edges, reverse } = graph;
|
|
154
|
+
const node = nodes[capId];
|
|
155
|
+
if (!node) {
|
|
156
|
+
console.error(red(`✗ Capability "${capId}" not found in graph.`));
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const icon = LEVEL_ICON[node.stability] || "🌊";
|
|
161
|
+
const color = LEVEL_COLOR[node.stability] || green;
|
|
162
|
+
|
|
163
|
+
console.log();
|
|
164
|
+
console.log(bold(` ${icon} ${color(capId)}`) + gray(` (${node.stability})`));
|
|
165
|
+
if (node.services?.length) console.log(gray(` external: `) + cyan(node.services.join(", ")));
|
|
166
|
+
console.log();
|
|
167
|
+
|
|
168
|
+
const deps = edges[capId] || [];
|
|
169
|
+
const callers = reverse[capId] || [];
|
|
170
|
+
|
|
171
|
+
if (deps.length > 0) {
|
|
172
|
+
console.log(bold(" Calls (downstream dependencies):"));
|
|
173
|
+
for (const dep of deps) {
|
|
174
|
+
const d = nodes[dep];
|
|
175
|
+
const dColor = LEVEL_COLOR[d?.stability] || green;
|
|
176
|
+
const dIcon = LEVEL_ICON[d?.stability] || "🌊";
|
|
177
|
+
console.log(` ${dIcon} ${dColor(dep)}` + gray(d?.services?.length ? ` [${d.services.join(", ")}]` : ""));
|
|
178
|
+
}
|
|
179
|
+
console.log();
|
|
180
|
+
} else {
|
|
181
|
+
console.log(gray(" No downstream dependencies."));
|
|
182
|
+
console.log();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (callers.length > 0) {
|
|
186
|
+
console.log(bold(" Called by (upstream dependents):"));
|
|
187
|
+
for (const caller of callers) {
|
|
188
|
+
const c = nodes[caller];
|
|
189
|
+
const cColor = LEVEL_COLOR[c?.stability] || green;
|
|
190
|
+
const cIcon = LEVEL_ICON[c?.stability] || "🌊";
|
|
191
|
+
console.log(` ${cIcon} ${cColor(caller)}`);
|
|
192
|
+
}
|
|
193
|
+
console.log();
|
|
194
|
+
} else {
|
|
195
|
+
console.log(gray(" No capabilities call this one."));
|
|
196
|
+
console.log();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Impact warning for frozen/stable
|
|
200
|
+
if ((node.stability === "frozen" || node.stability === "stable") && callers.length > 0) {
|
|
201
|
+
const color2 = node.stability === "frozen" ? red : yellow;
|
|
202
|
+
console.log(color2(` ⚠ This capability is ${node.stability}. Changing it may break:`));
|
|
203
|
+
for (const caller of callers) console.log(color2(` • ${caller}`));
|
|
204
|
+
console.log();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── breaking change checker ───────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Compare previous graph.json with new graph to detect:
|
|
212
|
+
* - frozen/stable caps that have gained new callers (more dependents = higher risk)
|
|
213
|
+
* - frozen caps that have new outgoing deps (their internals changed)
|
|
214
|
+
*/
|
|
215
|
+
function checkBreakingChanges(prevGraph, newGraph) {
|
|
216
|
+
const warnings = [];
|
|
217
|
+
if (!prevGraph || !newGraph) return warnings;
|
|
218
|
+
|
|
219
|
+
for (const [capId, node] of Object.entries(newGraph.nodes)) {
|
|
220
|
+
if (node.stability === "experimental") continue;
|
|
221
|
+
|
|
222
|
+
const prevCallers = new Set(prevGraph.reverse?.[capId] || []);
|
|
223
|
+
const newCallers = new Set(newGraph.reverse[capId] || []);
|
|
224
|
+
const addedCallers = [...newCallers].filter(c => !prevCallers.has(c));
|
|
225
|
+
|
|
226
|
+
if (addedCallers.length > 0) {
|
|
227
|
+
warnings.push({
|
|
228
|
+
type: "new-dependents",
|
|
229
|
+
capId,
|
|
230
|
+
stability: node.stability,
|
|
231
|
+
detail: `${addedCallers.join(", ")} now depend on this`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (node.stability === "frozen") {
|
|
236
|
+
const prevDeps = new Set(prevGraph.edges?.[capId] || []);
|
|
237
|
+
const newDeps = new Set(newGraph.edges[capId] || []);
|
|
238
|
+
const addedDeps = [...newDeps].filter(d => !prevDeps.has(d));
|
|
239
|
+
const removedDeps = [...prevDeps].filter(d => !newDeps.has(d));
|
|
240
|
+
|
|
241
|
+
if (addedDeps.length > 0 || removedDeps.length > 0) {
|
|
242
|
+
warnings.push({
|
|
243
|
+
type: "frozen-internals-changed",
|
|
244
|
+
capId,
|
|
245
|
+
stability: node.stability,
|
|
246
|
+
detail: [
|
|
247
|
+
addedDeps.length ? `added calls: ${addedDeps.join(", ")}` : "",
|
|
248
|
+
removedDeps.length ? `removed calls: ${removedDeps.join(", ")}` : "",
|
|
249
|
+
].filter(Boolean).join("; "),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return warnings;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export async function graphCommand(rawArgs) {
|
|
261
|
+
const args = (rawArgs || []).slice(1); // skip command name
|
|
262
|
+
const jsonMode = args.includes("--json");
|
|
263
|
+
const checkMode = args.includes("--check");
|
|
264
|
+
const capIdx = args.indexOf("--cap");
|
|
265
|
+
const capFilter = capIdx !== -1 ? args[capIdx + 1] : null;
|
|
266
|
+
|
|
267
|
+
const cwd = process.cwd();
|
|
268
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
269
|
+
const scanPath = path.join(infernoDir, "scan.json");
|
|
270
|
+
const graphPath = path.join(infernoDir, "graph.json");
|
|
271
|
+
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
272
|
+
|
|
273
|
+
// Load scan data
|
|
274
|
+
const scan = loadJson(scanPath);
|
|
275
|
+
if (!scan) {
|
|
276
|
+
console.error(red("✗ inferno/scan.json not found — run `infernoflow scan` first."));
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Load capabilities (for stability info)
|
|
281
|
+
let allCaps = [];
|
|
282
|
+
const rawCaps = loadJson(capsPath);
|
|
283
|
+
if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
|
|
284
|
+
|
|
285
|
+
// Build graph
|
|
286
|
+
const scanCaps = scan.capabilities || [];
|
|
287
|
+
const graph = buildGraph(scanCaps, allCaps);
|
|
288
|
+
|
|
289
|
+
// Check for breaking changes vs saved graph
|
|
290
|
+
const prevGraph = loadJson(graphPath);
|
|
291
|
+
const breakingWarnings = checkMode || true ? checkBreakingChanges(prevGraph, graph) : [];
|
|
292
|
+
|
|
293
|
+
// Save graph.json
|
|
294
|
+
const graphData = {
|
|
295
|
+
builtAt: new Date().toISOString(),
|
|
296
|
+
capabilities: Object.keys(graph.nodes).length,
|
|
297
|
+
edges: Object.values(graph.edges).reduce((n, arr) => n + arr.length, 0),
|
|
298
|
+
nodes: graph.nodes,
|
|
299
|
+
deps: graph.edges,
|
|
300
|
+
dependents: graph.reverse,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (!jsonMode) {
|
|
304
|
+
fs.writeFileSync(graphPath, JSON.stringify(graphData, null, 2));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Output
|
|
308
|
+
if (jsonMode) {
|
|
309
|
+
console.log(JSON.stringify(graphData, null, 2));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (capFilter) {
|
|
314
|
+
printCapGraph(capFilter, graph);
|
|
315
|
+
} else {
|
|
316
|
+
printFullGraph(graph);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Breaking change warnings
|
|
320
|
+
if (breakingWarnings.length > 0) {
|
|
321
|
+
console.log(yellow(" ⚠ Dependency changes detected:"));
|
|
322
|
+
for (const w of breakingWarnings) {
|
|
323
|
+
const icon = w.stability === "frozen" ? red("🧊") : yellow("〰️ ");
|
|
324
|
+
console.log(` ${icon} ${bold(w.capId)} — ${w.detail}`);
|
|
325
|
+
}
|
|
326
|
+
console.log();
|
|
327
|
+
if (checkMode) process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!jsonMode) console.log(gray(` Graph saved → inferno/graph.json`));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── exported utility for other commands ──────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export function loadGraph(infernoDir) {
|
|
336
|
+
return loadJson(path.join(infernoDir, "graph.json"));
|
|
337
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow impact
|
|
3
|
+
*
|
|
4
|
+
* Before you touch a capability — see the blast radius.
|
|
5
|
+
*
|
|
6
|
+
* Given a capability ID, answers:
|
|
7
|
+
* • Which caps directly depend on this one?
|
|
8
|
+
* • Which caps transitively depend on it?
|
|
9
|
+
* • What scenarios would be affected?
|
|
10
|
+
* • What is the overall risk level? (low / medium / high / critical)
|
|
11
|
+
* • Are any frozen/stable caps in the blast zone?
|
|
12
|
+
*
|
|
13
|
+
* Pure graph traversal — no AI needed. Fast and deterministic.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* infernoflow impact auth-login
|
|
17
|
+
* infernoflow impact auth-login --depth 5
|
|
18
|
+
* infernoflow impact auth-login --json
|
|
19
|
+
* infernoflow impact auth-login --check Exit 1 if risk is HIGH or CRITICAL
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
25
|
+
|
|
26
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function loadJson(p) {
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
30
|
+
catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LEVEL_ICON = { frozen: "🧊", stable: "〰️ ", experimental: "🌊" };
|
|
34
|
+
const LEVEL_COLOR = { frozen: red, stable: yellow, experimental: green };
|
|
35
|
+
|
|
36
|
+
function stability(cap) {
|
|
37
|
+
return cap?.stability || "experimental";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── blast radius (BFS on reverse graph) ──────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Walk the reverse dependency graph (dependents) starting from capId.
|
|
44
|
+
* Returns: { direct: Set<string>, transitive: Set<string> }
|
|
45
|
+
*/
|
|
46
|
+
function blastRadius(capId, dependents, maxDepth = 10) {
|
|
47
|
+
const direct = new Set(dependents[capId] || []);
|
|
48
|
+
const transitive = new Set();
|
|
49
|
+
const queue = [...direct].map(id => ({ id, depth: 1 }));
|
|
50
|
+
const visited = new Set([capId, ...direct]);
|
|
51
|
+
|
|
52
|
+
while (queue.length > 0) {
|
|
53
|
+
const { id, depth } = queue.shift();
|
|
54
|
+
if (depth >= maxDepth) continue;
|
|
55
|
+
for (const dep of (dependents[id] || [])) {
|
|
56
|
+
if (!visited.has(dep)) {
|
|
57
|
+
visited.add(dep);
|
|
58
|
+
transitive.add(dep);
|
|
59
|
+
queue.push({ id: dep, depth: depth + 1 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { direct, transitive };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── scenario finder ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function loadScenarios(infernoDir) {
|
|
70
|
+
const scenariosDir = path.join(infernoDir, "scenarios");
|
|
71
|
+
if (!fs.existsSync(scenariosDir)) return [];
|
|
72
|
+
|
|
73
|
+
const scenarios = [];
|
|
74
|
+
for (const f of fs.readdirSync(scenariosDir)) {
|
|
75
|
+
if (!f.endsWith(".json")) continue;
|
|
76
|
+
try {
|
|
77
|
+
const s = JSON.parse(fs.readFileSync(path.join(scenariosDir, f), "utf8"));
|
|
78
|
+
scenarios.push(s);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
return scenarios;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scenariosForCap(capId, scenarios) {
|
|
85
|
+
return scenarios.filter(s => {
|
|
86
|
+
const covered = s.capabilitiesCovered || s.capabilities || [];
|
|
87
|
+
return covered.some(c => c.toLowerCase() === capId.toLowerCase());
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── risk calculator ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Risk levels:
|
|
95
|
+
* critical — the target cap itself is frozen AND has dependents
|
|
96
|
+
* high — a frozen cap is in the blast zone
|
|
97
|
+
* medium — a stable cap is in the blast zone
|
|
98
|
+
* low — all dependents are experimental
|
|
99
|
+
*/
|
|
100
|
+
function computeRisk(targetCap, allInBlast, allCaps) {
|
|
101
|
+
const targetLevel = stability(targetCap);
|
|
102
|
+
|
|
103
|
+
if (targetLevel === "frozen" && allInBlast.size > 0) {
|
|
104
|
+
return "critical";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const id of allInBlast) {
|
|
108
|
+
const cap = allCaps.find(c => c.id === id);
|
|
109
|
+
if (stability(cap) === "frozen") return "high";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const id of allInBlast) {
|
|
113
|
+
const cap = allCaps.find(c => c.id === id);
|
|
114
|
+
if (stability(cap) === "stable") return "medium";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return "low";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const RISK_COLOR = {
|
|
121
|
+
critical: red,
|
|
122
|
+
high: red,
|
|
123
|
+
medium: yellow,
|
|
124
|
+
low: green,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const RISK_ICON = {
|
|
128
|
+
critical: "🔴",
|
|
129
|
+
high: "🔴",
|
|
130
|
+
medium: "🟡",
|
|
131
|
+
low: "🟢",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const RISK_ADVICE = {
|
|
135
|
+
critical: "This capability is FROZEN — any change is high-risk. Requires explicit approval.",
|
|
136
|
+
high: "A frozen capability depends on this — test thoroughly before merging.",
|
|
137
|
+
medium: "A stable capability is in the blast zone — prefer additive changes only.",
|
|
138
|
+
low: "All dependents are experimental — safe to iterate freely.",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ── printer ───────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function printImpact({ capId, targetCap, direct, transitive, affectedScenarios, risk, allCaps, deps }) {
|
|
144
|
+
const targetLevel = stability(targetCap);
|
|
145
|
+
const targetIcon = LEVEL_ICON[targetLevel] || "🌊";
|
|
146
|
+
const targetColor = LEVEL_COLOR[targetLevel] || green;
|
|
147
|
+
const riskColor = RISK_COLOR[risk];
|
|
148
|
+
const riskIcon = RISK_ICON[risk];
|
|
149
|
+
|
|
150
|
+
console.log();
|
|
151
|
+
console.log(bold(` ${targetIcon} ${targetColor(capId)}`));
|
|
152
|
+
if (targetCap?.name || targetCap?.title) {
|
|
153
|
+
console.log(gray(` ${targetCap.name || targetCap.title}`));
|
|
154
|
+
}
|
|
155
|
+
console.log(gray(` stability: `) + targetColor(targetLevel));
|
|
156
|
+
console.log();
|
|
157
|
+
|
|
158
|
+
// What this cap calls (downstream)
|
|
159
|
+
if (deps.length > 0) {
|
|
160
|
+
console.log(gray(" This cap calls:"));
|
|
161
|
+
for (const dep of deps) {
|
|
162
|
+
const d = allCaps.find(c => c.id === dep);
|
|
163
|
+
const icon = LEVEL_ICON[stability(d)] || "🌊";
|
|
164
|
+
const color = LEVEL_COLOR[stability(d)] || green;
|
|
165
|
+
console.log(` ${icon} ${color(dep)}`);
|
|
166
|
+
}
|
|
167
|
+
console.log();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Blast radius
|
|
171
|
+
if (direct.size === 0) {
|
|
172
|
+
console.log(gray(" No capabilities depend on this one."));
|
|
173
|
+
console.log(gray(" ✔ Safe to change freely."));
|
|
174
|
+
console.log();
|
|
175
|
+
} else {
|
|
176
|
+
console.log(bold(" Direct dependents (will be immediately affected):"));
|
|
177
|
+
for (const id of direct) {
|
|
178
|
+
const cap = allCaps.find(c => c.id === id);
|
|
179
|
+
const level = stability(cap);
|
|
180
|
+
const icon = LEVEL_ICON[level] || "🌊";
|
|
181
|
+
const color = LEVEL_COLOR[level] || green;
|
|
182
|
+
console.log(` ${icon} ${color(id)}`);
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
|
|
186
|
+
if (transitive.size > 0) {
|
|
187
|
+
console.log(bold(" Transitive dependents (indirectly affected):"));
|
|
188
|
+
for (const id of transitive) {
|
|
189
|
+
const cap = allCaps.find(c => c.id === id);
|
|
190
|
+
const level = stability(cap);
|
|
191
|
+
const icon = LEVEL_ICON[level] || "🌊";
|
|
192
|
+
const color = LEVEL_COLOR[level] || green;
|
|
193
|
+
console.log(` ${icon} ${color(id)}`);
|
|
194
|
+
}
|
|
195
|
+
console.log();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Affected scenarios
|
|
200
|
+
if (affectedScenarios.length > 0) {
|
|
201
|
+
console.log(bold(" Scenarios at risk:"));
|
|
202
|
+
for (const s of affectedScenarios) {
|
|
203
|
+
console.log(` ${yellow("⚠")} ${s.scenarioId || s.description || "(unnamed)"}`);
|
|
204
|
+
if (s.description) console.log(gray(` ${s.description}`));
|
|
205
|
+
}
|
|
206
|
+
console.log();
|
|
207
|
+
} else if (direct.size > 0) {
|
|
208
|
+
console.log(gray(" No scenarios cover the affected capabilities."));
|
|
209
|
+
console.log(gray(" Consider adding scenarios before making this change."));
|
|
210
|
+
console.log();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Risk banner
|
|
214
|
+
console.log(` ${riskIcon} Risk level: ${bold(riskColor(risk.toUpperCase()))}`);
|
|
215
|
+
console.log(` ${gray(RISK_ADVICE[risk])}`);
|
|
216
|
+
console.log();
|
|
217
|
+
|
|
218
|
+
// Summary numbers
|
|
219
|
+
const total = direct.size + transitive.size;
|
|
220
|
+
if (total > 0) {
|
|
221
|
+
console.log(gray(
|
|
222
|
+
` ── ${direct.size} direct · ${transitive.size} transitive · ${total} total affected · ${affectedScenarios.length} scenario(s) at risk`
|
|
223
|
+
));
|
|
224
|
+
console.log();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export async function impactCommand(rawArgs) {
|
|
231
|
+
const args = (rawArgs || []).slice(1); // skip command name
|
|
232
|
+
const jsonMode = args.includes("--json");
|
|
233
|
+
const checkMode = args.includes("--check");
|
|
234
|
+
const depthIdx = args.indexOf("--depth");
|
|
235
|
+
const maxDepth = depthIdx !== -1 ? parseInt(args[depthIdx + 1], 10) || 10 : 10;
|
|
236
|
+
|
|
237
|
+
const capId = args.find((a, i) => !a.startsWith("--") && (depthIdx === -1 || i !== depthIdx + 1));
|
|
238
|
+
|
|
239
|
+
if (!capId) {
|
|
240
|
+
console.error(red("✗ Usage: infernoflow impact <capability-id> [--depth N] [--json] [--check]"));
|
|
241
|
+
console.error(gray(" Example: infernoflow impact user-auth"));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const cwd = process.cwd();
|
|
246
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
247
|
+
|
|
248
|
+
// Load graph
|
|
249
|
+
const graph = loadJson(path.join(infernoDir, "graph.json"));
|
|
250
|
+
if (!graph) {
|
|
251
|
+
console.error(red("✗ inferno/graph.json not found — run `infernoflow graph` first."));
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Load capabilities
|
|
256
|
+
let allCaps = [];
|
|
257
|
+
const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
|
|
258
|
+
if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
|
|
259
|
+
|
|
260
|
+
// Validate cap exists
|
|
261
|
+
const targetCap = allCaps.find(c => c.id === capId);
|
|
262
|
+
if (!targetCap) {
|
|
263
|
+
console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
|
|
264
|
+
console.error(gray(" Run: infernoflow stability — to list all capability IDs"));
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Blast radius
|
|
269
|
+
const { direct, transitive } = blastRadius(capId, graph.dependents || {}, maxDepth);
|
|
270
|
+
const allInBlast = new Set([...direct, ...transitive]);
|
|
271
|
+
|
|
272
|
+
// Dependencies (what this cap calls)
|
|
273
|
+
const deps = graph.deps?.[capId] || [];
|
|
274
|
+
|
|
275
|
+
// Scenarios
|
|
276
|
+
const scenarios = loadScenarios(infernoDir);
|
|
277
|
+
const targetScenarios = scenariosForCap(capId, scenarios);
|
|
278
|
+
// Collect scenarios for all affected caps too
|
|
279
|
+
const affectedScenarioSet = new Map();
|
|
280
|
+
for (const s of targetScenarios) {
|
|
281
|
+
affectedScenarioSet.set(s.scenarioId || s.description, s);
|
|
282
|
+
}
|
|
283
|
+
for (const id of allInBlast) {
|
|
284
|
+
for (const s of scenariosForCap(id, scenarios)) {
|
|
285
|
+
affectedScenarioSet.set(s.scenarioId || s.description, s);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const affectedScenarios = [...affectedScenarioSet.values()];
|
|
289
|
+
|
|
290
|
+
// Risk
|
|
291
|
+
const risk = computeRisk(targetCap, allInBlast, allCaps);
|
|
292
|
+
|
|
293
|
+
// JSON mode
|
|
294
|
+
if (jsonMode) {
|
|
295
|
+
const out = {
|
|
296
|
+
capId,
|
|
297
|
+
name: targetCap.name || targetCap.title,
|
|
298
|
+
stability: stability(targetCap),
|
|
299
|
+
risk,
|
|
300
|
+
direct: [...direct],
|
|
301
|
+
transitive: [...transitive],
|
|
302
|
+
deps,
|
|
303
|
+
affectedScenarios: affectedScenarios.map(s => s.scenarioId || s.description),
|
|
304
|
+
summary: {
|
|
305
|
+
directCount: direct.size,
|
|
306
|
+
transitiveCount: transitive.size,
|
|
307
|
+
totalAffected: allInBlast.size,
|
|
308
|
+
scenariosAtRisk: affectedScenarios.length,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
console.log(JSON.stringify(out, null, 2));
|
|
312
|
+
if (checkMode && (risk === "high" || risk === "critical")) process.exit(1);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log(gray(`\n infernoflow impact → ${bold(capId)}`));
|
|
317
|
+
console.log(gray(" ──────────────────────────────────────────────────────────────"));
|
|
318
|
+
|
|
319
|
+
printImpact({ capId, targetCap, direct, transitive, affectedScenarios, risk, allCaps, deps });
|
|
320
|
+
|
|
321
|
+
if (checkMode && (risk === "high" || risk === "critical")) {
|
|
322
|
+
console.log(red(" ✗ --check failed: risk level is " + risk.toUpperCase()));
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
}
|