infernoflow 0.33.0 โ 0.34.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 +208 -120
- package/dist/bin/infernoflow.mjs +271 -85
- package/dist/lib/adopters/angular.mjs +128 -1
- package/dist/lib/adopters/css.mjs +111 -1
- package/dist/lib/adopters/react.mjs +104 -1
- package/dist/lib/ai/ideDetection.mjs +31 -1
- package/dist/lib/ai/localProvider.mjs +88 -1
- package/dist/lib/ai/providerRouter.mjs +295 -2
- package/dist/lib/commands/adopt.mjs +869 -20
- package/dist/lib/commands/adoptWizard.mjs +320 -9
- package/dist/lib/commands/agent.mjs +191 -5
- package/dist/lib/commands/ai.mjs +407 -2
- package/dist/lib/commands/ask.mjs +299 -0
- package/dist/lib/commands/audit.mjs +300 -13
- package/dist/lib/commands/changelog.mjs +594 -26
- package/dist/lib/commands/check.mjs +184 -3
- package/dist/lib/commands/ci.mjs +208 -3
- package/dist/lib/commands/claudeMd.mjs +139 -28
- package/dist/lib/commands/cloud.mjs +521 -5
- package/dist/lib/commands/context.mjs +346 -34
- package/dist/lib/commands/coverage.mjs +282 -2
- package/dist/lib/commands/dashboard.mjs +635 -123
- package/dist/lib/commands/demo.mjs +465 -8
- package/dist/lib/commands/diff.mjs +274 -5
- package/dist/lib/commands/docGate.mjs +81 -2
- package/dist/lib/commands/doctor.mjs +321 -3
- package/dist/lib/commands/explain.mjs +438 -8
- package/dist/lib/commands/export.mjs +239 -10
- package/dist/lib/commands/generateSkills.mjs +163 -38
- package/dist/lib/commands/graph.mjs +378 -11
- package/dist/lib/commands/health.mjs +309 -2
- package/dist/lib/commands/impact.mjs +325 -2
- package/dist/lib/commands/implement.mjs +103 -7
- package/dist/lib/commands/init.mjs +545 -23
- package/dist/lib/commands/installCursorHooks.mjs +36 -1
- package/dist/lib/commands/installVsCodeCopilotHooks.mjs +37 -1
- package/dist/lib/commands/link.mjs +342 -2
- package/dist/lib/commands/log.mjs +164 -16
- package/dist/lib/commands/monorepo.mjs +428 -4
- package/dist/lib/commands/notify.mjs +258 -4
- package/dist/lib/commands/onboard.mjs +296 -4
- package/dist/lib/commands/prComment.mjs +361 -2
- package/dist/lib/commands/prImpact.mjs +157 -2
- package/dist/lib/commands/publish.mjs +316 -15
- package/dist/lib/commands/recap.mjs +359 -0
- package/dist/lib/commands/report.mjs +272 -28
- package/dist/lib/commands/review.mjs +223 -9
- package/dist/lib/commands/run.mjs +336 -8
- package/dist/lib/commands/scaffold.mjs +419 -54
- package/dist/lib/commands/scan.mjs +1118 -5
- package/dist/lib/commands/scout.mjs +291 -2
- package/dist/lib/commands/setup.mjs +310 -5
- package/dist/lib/commands/share.mjs +196 -13
- package/dist/lib/commands/snapshot.mjs +383 -3
- package/dist/lib/commands/stability.mjs +293 -2
- package/dist/lib/commands/stats.mjs +402 -0
- package/dist/lib/commands/status.mjs +172 -4
- package/dist/lib/commands/suggest.mjs +563 -21
- package/dist/lib/commands/switch.mjs +310 -0
- package/dist/lib/commands/syncAuto.mjs +96 -1
- package/dist/lib/commands/synthesize.mjs +228 -10
- package/dist/lib/commands/teamSync.mjs +388 -2
- package/dist/lib/commands/test.mjs +363 -6
- package/dist/lib/commands/theme.mjs +195 -18
- package/dist/lib/commands/upgrade.mjs +153 -0
- package/dist/lib/commands/version.mjs +282 -2
- package/dist/lib/commands/vibe.mjs +357 -7
- package/dist/lib/commands/watch.mjs +203 -4
- package/dist/lib/commands/why.mjs +358 -4
- package/dist/lib/cursorHooksInstall.mjs +60 -1
- package/dist/lib/draftToolingInstall.mjs +68 -7
- package/dist/lib/git/detect-drift.mjs +208 -4
- package/dist/lib/learning/adapt.mjs +101 -6
- package/dist/lib/learning/observe.mjs +119 -1
- package/dist/lib/learning/patternDetector.mjs +298 -1
- package/dist/lib/learning/profile.mjs +279 -2
- package/dist/lib/learning/skillSynthesizer.mjs +145 -24
- package/dist/lib/templates/index.mjs +131 -1
- package/dist/lib/theme/scanner.mjs +343 -4
- package/dist/lib/ui/errors.mjs +142 -1
- package/dist/lib/ui/output.mjs +72 -6
- package/dist/lib/ui/prompts.mjs +147 -6
- package/dist/lib/vsCodeCopilotHooksInstall.mjs +42 -1
- package/package.json +1 -1
|
@@ -1,2 +1,309 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow health
|
|
3
|
+
*
|
|
4
|
+
* Computes a weighted 0โ100 health score for the capability contract.
|
|
5
|
+
* Breaks it down across five dimensions so you know exactly where to improve.
|
|
6
|
+
*
|
|
7
|
+
* Dimensions:
|
|
8
|
+
* Coverage % of capabilities that have descriptions (weight 25)
|
|
9
|
+
* Documentation % with at least one scenario/test (weight 20)
|
|
10
|
+
* Freshness How recently the contract was updated (weight 20)
|
|
11
|
+
* Completeness Version field, owner, tags present (weight 15)
|
|
12
|
+
* Drift risk Open issues from `infernoflow check` (weight 20)
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow health Print score + breakdown
|
|
16
|
+
* infernoflow health --json Machine-readable
|
|
17
|
+
* infernoflow health --fail-below 70 Exit 1 if score < 70 (CI gate)
|
|
18
|
+
* infernoflow health --watch Re-run every 30s (for terminals)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
25
|
+
|
|
26
|
+
const WEIGHTS = {
|
|
27
|
+
coverage: 25,
|
|
28
|
+
documentation: 20,
|
|
29
|
+
freshness: 20,
|
|
30
|
+
completeness: 15,
|
|
31
|
+
drift: 20,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// โโ Readers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
35
|
+
|
|
36
|
+
function readContract(infernoDir) {
|
|
37
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
38
|
+
const p = path.join(infernoDir, f);
|
|
39
|
+
if (!fs.existsSync(p)) continue;
|
|
40
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readScenarios(infernoDir) {
|
|
46
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
47
|
+
if (!fs.existsSync(scenDir)) return [];
|
|
48
|
+
return fs.readdirSync(scenDir)
|
|
49
|
+
.filter(f => f.endsWith(".json"))
|
|
50
|
+
.flatMap(f => {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(fs.readFileSync(path.join(scenDir, f), "utf8"));
|
|
53
|
+
return data.capability ? [data.capability] : (data.capabilities || []);
|
|
54
|
+
} catch { return []; }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runCheck(cwd) {
|
|
59
|
+
try {
|
|
60
|
+
const out = execSync("npx infernoflow check --json", {
|
|
61
|
+
cwd, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
return JSON.parse(out);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
try { return JSON.parse(err.stdout || "{}"); } catch { return {}; }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lastModifiedDaysAgo(infernoDir) {
|
|
70
|
+
const files = ["contract.json", "capabilities.json"]
|
|
71
|
+
.map(f => path.join(infernoDir, f))
|
|
72
|
+
.filter(fs.existsSync);
|
|
73
|
+
if (!files.length) return 999;
|
|
74
|
+
const mtime = Math.max(...files.map(f => fs.statSync(f).mtimeMs));
|
|
75
|
+
return (Date.now() - mtime) / (1000 * 60 * 60 * 24);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// โโ Scorers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
79
|
+
|
|
80
|
+
function scoreCoverage(caps) {
|
|
81
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
82
|
+
const withDesc = caps.filter(c => c.description && c.description.length > 5).length;
|
|
83
|
+
const pct = Math.round((withDesc / caps.length) * 100);
|
|
84
|
+
return {
|
|
85
|
+
score: pct,
|
|
86
|
+
detail: `${withDesc}/${caps.length} capabilities have descriptions`,
|
|
87
|
+
pct,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scoreDocumentation(caps, scenarioCaps) {
|
|
92
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
93
|
+
const scenSet = new Set(scenarioCaps.map(s => String(s).toLowerCase()));
|
|
94
|
+
const withScen = caps.filter(c => scenSet.has(c.id.toLowerCase())).length;
|
|
95
|
+
const pct = Math.round((withScen / caps.length) * 100);
|
|
96
|
+
return {
|
|
97
|
+
score: Math.min(100, pct + (scenSet.size === 0 ? 0 : 10)), // bonus for having any scenarios
|
|
98
|
+
detail: `${withScen}/${caps.length} capabilities have test scenarios`,
|
|
99
|
+
pct,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scoreFreshness(daysAgo) {
|
|
104
|
+
let score;
|
|
105
|
+
let label;
|
|
106
|
+
if (daysAgo <= 1) { score = 100; label = "updated today"; }
|
|
107
|
+
else if (daysAgo <= 3) { score = 95; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
108
|
+
else if (daysAgo <= 7) { score = 85; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
109
|
+
else if (daysAgo <= 14) { score = 70; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
110
|
+
else if (daysAgo <= 30) { score = 50; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
111
|
+
else if (daysAgo <= 60) { score = 30; label = `updated ${Math.round(daysAgo)}d ago โ stale`; }
|
|
112
|
+
else { score = 10; label = `not updated in ${Math.round(daysAgo)}d โ very stale`; }
|
|
113
|
+
return { score, detail: label };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scoreCompleteness(caps, contract) {
|
|
117
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
118
|
+
const hasVersion = !!(contract?.version || contract?.contractVersion);
|
|
119
|
+
const withTags = caps.filter(c => c.tags?.length).length;
|
|
120
|
+
const withOwner = caps.filter(c => c.owner).length;
|
|
121
|
+
const withSince = caps.filter(c => c.since).length;
|
|
122
|
+
|
|
123
|
+
const tagPct = Math.round((withTags / caps.length) * 100);
|
|
124
|
+
const ownerPct = Math.round((withOwner / caps.length) * 100);
|
|
125
|
+
const sincePct = Math.round((withSince / caps.length) * 100);
|
|
126
|
+
|
|
127
|
+
const score = Math.round(
|
|
128
|
+
(hasVersion ? 20 : 0) +
|
|
129
|
+
tagPct * 0.3 +
|
|
130
|
+
ownerPct * 0.3 +
|
|
131
|
+
sincePct * 0.2
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
score: Math.min(100, score),
|
|
136
|
+
detail: `version: ${hasVersion ? "โ" : "โ"}, tags: ${tagPct}%, owner: ${ownerPct}%, since: ${sincePct}%`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scoreDrift(checkResult) {
|
|
141
|
+
const issues = checkResult?.issues || [];
|
|
142
|
+
const warnings = issues.filter(i => (i.severity || i.level || "error") === "warning").length;
|
|
143
|
+
const errors = issues.filter(i => (i.severity || i.level || "error") === "error").length;
|
|
144
|
+
const status = checkResult?.status;
|
|
145
|
+
|
|
146
|
+
if (status === "ok" || (!errors && !warnings)) return { score: 100, detail: "No issues found" };
|
|
147
|
+
|
|
148
|
+
const score = Math.max(0, 100 - (errors * 20) - (warnings * 8));
|
|
149
|
+
return {
|
|
150
|
+
score,
|
|
151
|
+
detail: `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// โโ Aggregate โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
156
|
+
|
|
157
|
+
function computeHealth(infernoDir, cwd) {
|
|
158
|
+
const contract = readContract(infernoDir);
|
|
159
|
+
const caps = (contract?.capabilities || []).map(c =>
|
|
160
|
+
typeof c === "string" ? { id: c, description: "", tags: [], owner: "", since: "" } : c
|
|
161
|
+
);
|
|
162
|
+
const scenarioCaps = readScenarios(infernoDir);
|
|
163
|
+
const daysAgo = lastModifiedDaysAgo(infernoDir);
|
|
164
|
+
const checkResult = runCheck(cwd);
|
|
165
|
+
|
|
166
|
+
const dimensions = {
|
|
167
|
+
coverage: scoreCoverage(caps),
|
|
168
|
+
documentation: scoreDocumentation(caps, scenarioCaps),
|
|
169
|
+
freshness: scoreFreshness(daysAgo),
|
|
170
|
+
completeness: scoreCompleteness(caps, contract),
|
|
171
|
+
drift: scoreDrift(checkResult),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const totalScore = Math.round(
|
|
175
|
+
Object.entries(dimensions).reduce((sum, [key, dim]) => {
|
|
176
|
+
return sum + (dim.score * WEIGHTS[key]) / 100;
|
|
177
|
+
}, 0)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return { totalScore, dimensions, caps, daysAgo, checkResult };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// โโ Renderer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
184
|
+
|
|
185
|
+
function scoreColor(score) {
|
|
186
|
+
if (score >= 80) return green;
|
|
187
|
+
if (score >= 60) return yellow;
|
|
188
|
+
return red;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scoreGrade(score) {
|
|
192
|
+
if (score >= 90) return "A";
|
|
193
|
+
if (score >= 80) return "B";
|
|
194
|
+
if (score >= 70) return "C";
|
|
195
|
+
if (score >= 60) return "D";
|
|
196
|
+
return "F";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function barOf(score, width = 30) {
|
|
200
|
+
const filled = Math.round((score / 100) * width);
|
|
201
|
+
const empty = width - filled;
|
|
202
|
+
return "โ".repeat(filled) + "โ".repeat(empty);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function printReport(totalScore, dimensions) {
|
|
206
|
+
const col = scoreColor(totalScore);
|
|
207
|
+
const grade = scoreGrade(totalScore);
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(` ${bold("๐ฅ infernoflow health score")}`);
|
|
211
|
+
console.log();
|
|
212
|
+
console.log(` ${col(bold(String(totalScore)))} / 100 ${col(bold(grade))} ${col(barOf(totalScore))}`);
|
|
213
|
+
console.log();
|
|
214
|
+
|
|
215
|
+
const dimNames = {
|
|
216
|
+
coverage: "Coverage ",
|
|
217
|
+
documentation: "Docs/Tests ",
|
|
218
|
+
freshness: "Freshness ",
|
|
219
|
+
completeness: "Completeness ",
|
|
220
|
+
drift: "Drift risk ",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const [key, dim] of Object.entries(dimensions)) {
|
|
224
|
+
const w = WEIGHTS[key];
|
|
225
|
+
const sc = dim.score;
|
|
226
|
+
const c = scoreColor(sc);
|
|
227
|
+
const bar = barOf(sc, 20);
|
|
228
|
+
const weighted = Math.round((sc * w) / 100);
|
|
229
|
+
console.log(
|
|
230
|
+
` ${bold(dimNames[key])} ${c(String(sc).padStart(3))} ${c(bar)} ${gray(`ร${w}% = ${weighted}pts ${dim.detail}`)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
console.log();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printTips(totalScore, dimensions) {
|
|
237
|
+
const tips = [];
|
|
238
|
+
|
|
239
|
+
if (dimensions.coverage.score < 70)
|
|
240
|
+
tips.push("Add descriptions to your capabilities in contract.json");
|
|
241
|
+
if (dimensions.documentation.score < 60)
|
|
242
|
+
tips.push("Create scenario files in inferno/scenarios/ for each capability");
|
|
243
|
+
if (dimensions.freshness.score < 70)
|
|
244
|
+
tips.push("Run `infernoflow suggest` to sync recent changes to the contract");
|
|
245
|
+
if (dimensions.completeness.score < 60)
|
|
246
|
+
tips.push("Add version, tags, owner, and since fields to your capabilities");
|
|
247
|
+
if (dimensions.drift.score < 80)
|
|
248
|
+
tips.push("Run `infernoflow check` and fix the reported issues");
|
|
249
|
+
|
|
250
|
+
if (tips.length) {
|
|
251
|
+
console.log(` ${bold("Tips to improve:")}`);
|
|
252
|
+
tips.forEach(t => console.log(` ${yellow("ยท")} ${t}`));
|
|
253
|
+
console.log();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// โโ Entry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
258
|
+
|
|
259
|
+
export async function healthCommand(rawArgs) {
|
|
260
|
+
const args = rawArgs.slice(1);
|
|
261
|
+
const jsonMode = args.includes("--json");
|
|
262
|
+
const watchMode = args.includes("--watch");
|
|
263
|
+
const cwd = process.cwd();
|
|
264
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
265
|
+
|
|
266
|
+
if (!fs.existsSync(infernoDir)) {
|
|
267
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
268
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
269
|
+
else warn(msg);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const failBelowIdx = args.indexOf("--fail-below");
|
|
274
|
+
const failBelow = failBelowIdx !== -1 ? parseInt(args[failBelowIdx + 1], 10) : null;
|
|
275
|
+
|
|
276
|
+
const intervalIdx = args.indexOf("--interval");
|
|
277
|
+
const intervalSecs = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1], 10) : 30;
|
|
278
|
+
|
|
279
|
+
const runOnce = () => {
|
|
280
|
+
const { totalScore, dimensions } = computeHealth(infernoDir, cwd);
|
|
281
|
+
|
|
282
|
+
if (jsonMode) {
|
|
283
|
+
const dimFlat = Object.fromEntries(
|
|
284
|
+
Object.entries(dimensions).map(([k, v]) => [k, { score: v.score, detail: v.detail, weight: WEIGHTS[k] }])
|
|
285
|
+
);
|
|
286
|
+
console.log(JSON.stringify({ ok: true, score: totalScore, grade: scoreGrade(totalScore), dimensions: dimFlat }));
|
|
287
|
+
} else {
|
|
288
|
+
if (watchMode) process.stdout.write("\x1Bc"); // clear screen
|
|
289
|
+
printReport(totalScore, dimensions);
|
|
290
|
+
if (!watchMode) printTips(totalScore, dimensions);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (failBelow !== null && totalScore < failBelow) {
|
|
294
|
+
if (!jsonMode) console.error(red(` โ Score ${totalScore} is below threshold ${failBelow} โ failing.\n`));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return totalScore;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
if (watchMode) {
|
|
302
|
+
if (!jsonMode) info(`Watching health score every ${intervalSecs}s โ press Ctrl+C to stop`);
|
|
303
|
+
runOnce();
|
|
304
|
+
setInterval(runOnce, intervalSecs * 1000);
|
|
305
|
+
await new Promise(() => {});
|
|
306
|
+
} else {
|
|
307
|
+
runOnce();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -1,2 +1,325 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
}
|