infernoflow 0.20.0 → 0.22.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 +46 -0
- package/dist/lib/ai/providerRouter.mjs +227 -64
- package/dist/lib/commands/adoptWizard.mjs +320 -0
- package/dist/lib/commands/coverage.mjs +282 -0
- package/dist/lib/commands/doctor.mjs +284 -0
- package/dist/lib/commands/init.mjs +70 -0
- package/dist/lib/commands/review.mjs +238 -0
- package/dist/lib/commands/vibe.mjs +365 -0
- package/dist/lib/templates/index.mjs +131 -0
- package/package.json +3 -2
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow doctor
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive setup diagnostic — like `brew doctor`.
|
|
5
|
+
* Checks every component of the infernoflow setup and tells you
|
|
6
|
+
* exactly what's wrong and how to fix it.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow doctor Print full diagnostic report
|
|
10
|
+
* infernoflow doctor --fix Auto-fix common issues
|
|
11
|
+
* infernoflow doctor --json Machine-readable output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as http from "node:http";
|
|
18
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
19
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
20
|
+
import { detectAvailableProviders } from "../ai/providerRouter.mjs";
|
|
21
|
+
|
|
22
|
+
// ── Check runners ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function check(label, fn) {
|
|
25
|
+
try {
|
|
26
|
+
const result = fn();
|
|
27
|
+
return { label, ...result };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return { label, status: "error", message: err.message, fix: null };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pass(message, detail) { return { status: "pass", message, detail: detail || null, fix: null }; }
|
|
34
|
+
function warn(message, fix) { return { status: "warn", message, detail: null, fix: fix || null }; }
|
|
35
|
+
function fail(message, fix) { return { status: "fail", message, detail: null, fix: fix || null }; }
|
|
36
|
+
|
|
37
|
+
// ── Individual checks ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function checkNodeVersion() {
|
|
40
|
+
const v = process.version;
|
|
41
|
+
const major = parseInt(v.slice(1).split(".")[0], 10);
|
|
42
|
+
if (major >= 20) return pass(`Node.js ${v}`, "Node 20+ recommended");
|
|
43
|
+
if (major >= 18) return pass(`Node.js ${v}`);
|
|
44
|
+
return fail(`Node.js ${v} — infernoflow requires Node 18+`, "Install Node 20 from nodejs.org");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkCli() {
|
|
48
|
+
try {
|
|
49
|
+
const r = spawnSync("infernoflow", ["--version"], { encoding: "utf8", timeout: 5000 });
|
|
50
|
+
if (r.status === 0) return pass(`infernoflow v${r.stdout.trim()} installed`);
|
|
51
|
+
return fail("infernoflow CLI not found on PATH", "npm install -g infernoflow");
|
|
52
|
+
} catch {
|
|
53
|
+
return fail("infernoflow CLI not found on PATH", "npm install -g infernoflow");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checkGitRepo(cwd) {
|
|
58
|
+
try {
|
|
59
|
+
execSync("git rev-parse --git-dir", { cwd, stdio: "ignore" });
|
|
60
|
+
return pass("Git repository detected");
|
|
61
|
+
} catch {
|
|
62
|
+
return fail("Not a git repository", "git init && git add . && git commit -m 'init'");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkInfernoDir(cwd) {
|
|
67
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
68
|
+
if (!fs.existsSync(infernoDir)) return fail("inferno/ not found", "infernoflow init");
|
|
69
|
+
return pass("inferno/ directory exists");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkContract(cwd) {
|
|
73
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
74
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
75
|
+
const p = path.join(infernoDir, f);
|
|
76
|
+
if (!fs.existsSync(p)) continue;
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
79
|
+
const caps = (data.capabilities || []).length;
|
|
80
|
+
return pass(`${f} valid — ${caps} capabilities`);
|
|
81
|
+
} catch {
|
|
82
|
+
return fail(`${f} contains invalid JSON`, `Fix the JSON syntax in inferno/${f}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return fail("No contract.json or capabilities.json", "infernoflow init");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkScenarios(cwd) {
|
|
89
|
+
const scenDir = path.join(cwd, "inferno", "scenarios");
|
|
90
|
+
if (!fs.existsSync(scenDir)) return warn("No scenarios/ directory", "infernoflow init");
|
|
91
|
+
const files = fs.readdirSync(scenDir).filter(f => f.endsWith(".json"));
|
|
92
|
+
if (!files.length) return warn("scenarios/ is empty", "Add scenario files or run infernoflow suggest");
|
|
93
|
+
return pass(`${files.length} scenario file${files.length !== 1 ? "s" : ""} found`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkChangelog(cwd) {
|
|
97
|
+
const p = path.join(cwd, "inferno", "CHANGELOG.md");
|
|
98
|
+
if (!fs.existsSync(p)) return warn("No inferno/CHANGELOG.md", "infernoflow init");
|
|
99
|
+
return pass("inferno/CHANGELOG.md exists");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkContextMd(cwd) {
|
|
103
|
+
const p = path.join(cwd, "inferno", "CONTEXT.md");
|
|
104
|
+
if (!fs.existsSync(p)) return warn("No CONTEXT.md generated", "infernoflow context");
|
|
105
|
+
const age = (Date.now() - fs.statSync(p).mtimeMs) / (1000 * 60 * 60 * 24);
|
|
106
|
+
if (age > 7) return warn(`CONTEXT.md is ${Math.round(age)} days old — may be stale`, "infernoflow context");
|
|
107
|
+
return pass(`CONTEXT.md present (${Math.round(age)}d old)`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkGitHooks(cwd) {
|
|
111
|
+
const hooksDir = path.join(cwd, ".git", "hooks");
|
|
112
|
+
const postCommit = path.join(hooksDir, "post-commit");
|
|
113
|
+
const prePush = path.join(hooksDir, "pre-push");
|
|
114
|
+
const hasPost = fs.existsSync(postCommit) && fs.readFileSync(postCommit, "utf8").includes("infernoflow");
|
|
115
|
+
const hasPre = fs.existsSync(prePush) && fs.readFileSync(prePush, "utf8").includes("infernoflow");
|
|
116
|
+
if (hasPost && hasPre) return pass("Git hooks installed (post-commit + pre-push)");
|
|
117
|
+
if (hasPost || hasPre) return warn("Partial git hooks installed", "infernoflow setup --yes");
|
|
118
|
+
return warn("Git hooks not installed", "infernoflow setup --yes");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkMcp(cwd) {
|
|
122
|
+
// Check for MCP server in cursor config or .mcp.json
|
|
123
|
+
const checks = [
|
|
124
|
+
path.join(cwd, ".cursor", "mcp.json"),
|
|
125
|
+
path.join(cwd, ".mcp.json"),
|
|
126
|
+
path.join(os.homedir(), ".cursor", "mcp.json"),
|
|
127
|
+
path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
128
|
+
path.join(os.homedir(), "AppData", "Roaming", "Claude", "claude_desktop_config.json"),
|
|
129
|
+
];
|
|
130
|
+
for (const p of checks) {
|
|
131
|
+
if (!fs.existsSync(p)) continue;
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
134
|
+
const servers = data.mcpServers || data.mcp_servers || {};
|
|
135
|
+
if (Object.keys(servers).some(k => k.toLowerCase().includes("inferno"))) {
|
|
136
|
+
return pass(`MCP server configured in ${path.basename(p)}`);
|
|
137
|
+
}
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
return warn("MCP server not configured", "infernoflow setup --yes (adds to Cursor/Claude config)");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function checkAiProviders(cwd) {
|
|
144
|
+
const providers = detectAvailableProviders(cwd);
|
|
145
|
+
const available = Object.entries(providers).filter(([, v]) => v).map(([k]) => k);
|
|
146
|
+
|
|
147
|
+
if (available.length) return pass(`AI provider${available.length !== 1 ? "s" : ""}: ${available.join(", ")}`);
|
|
148
|
+
|
|
149
|
+
return warn(
|
|
150
|
+
"No AI provider configured",
|
|
151
|
+
"Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_AI_API_KEY, or OPENROUTER_API_KEY\n" +
|
|
152
|
+
" Or install Ollama (ollama.com) for free local AI\n" +
|
|
153
|
+
" Or use VS Code with GitHub Copilot (zero config)"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function checkOllama() {
|
|
158
|
+
return new Promise(resolve => {
|
|
159
|
+
const req = http.get({ hostname: "localhost", port: 11434, path: "/api/tags", timeout: 1500 }, res => {
|
|
160
|
+
resolve(pass("Ollama running on localhost:11434"));
|
|
161
|
+
});
|
|
162
|
+
req.on("error", () => resolve({ status: "info", message: "Ollama not running (optional)", fix: "ollama serve", detail: null }));
|
|
163
|
+
req.on("timeout", () => { req.destroy(); resolve({ status: "info", message: "Ollama not running (optional)", fix: null, detail: null }); });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkCloudToken(cwd) {
|
|
168
|
+
const p = path.join(cwd, "inferno", "integrations.json");
|
|
169
|
+
if (!fs.existsSync(p)) return { status: "info", message: "Cloud sync not configured (optional)", fix: "infernoflow cloud init", detail: null };
|
|
170
|
+
try {
|
|
171
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
172
|
+
if (data.cloud?.token) return pass("Cloud sync configured");
|
|
173
|
+
return { status: "info", message: "Cloud sync not configured (optional)", fix: "infernoflow cloud init", detail: null };
|
|
174
|
+
} catch {
|
|
175
|
+
return { status: "info", message: "Cloud sync not configured (optional)", fix: null, detail: null };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Auto-fix ──────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function autoFix(results, cwd) {
|
|
182
|
+
const fixable = results.filter(r => r.status === "warn" && r.fix);
|
|
183
|
+
const fixed = [];
|
|
184
|
+
|
|
185
|
+
for (const r of fixable) {
|
|
186
|
+
const fix = r.fix;
|
|
187
|
+
if (fix.startsWith("infernoflow ")) {
|
|
188
|
+
const args = fix.slice("infernoflow ".length).split(" ");
|
|
189
|
+
const res = spawnSync("infernoflow", args, { cwd, encoding: "utf8", timeout: 30_000 });
|
|
190
|
+
if (res.status === 0) fixed.push(r.label);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return fixed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Renderer ──────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function icon(status) {
|
|
199
|
+
if (status === "pass") return green("✔");
|
|
200
|
+
if (status === "warn") return yellow("⚠");
|
|
201
|
+
if (status === "fail") return red("✗");
|
|
202
|
+
return gray("·");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function printReport(results, elapsed) {
|
|
206
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0, error: 0 };
|
|
207
|
+
for (const r of results) counts[r.status] = (counts[r.status] || 0) + 1;
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(` ${bold("🔥 infernoflow doctor")}`);
|
|
211
|
+
console.log();
|
|
212
|
+
|
|
213
|
+
const w = Math.max(...results.map(r => r.label.length)) + 2;
|
|
214
|
+
for (const r of results) {
|
|
215
|
+
console.log(` ${icon(r.status)} ${bold(r.label.padEnd(w))} ${r.message}`);
|
|
216
|
+
if (r.detail) console.log(` ${" ".repeat(w)} ${gray(r.detail)}`);
|
|
217
|
+
if (r.fix && (r.status === "warn" || r.status === "fail")) {
|
|
218
|
+
console.log(` ${" ".repeat(w)} ${cyan("fix:")} ${gray(r.fix)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log();
|
|
223
|
+
const overall = counts.fail > 0 ? red("issues found") : counts.warn > 0 ? yellow("warnings") : green("all good");
|
|
224
|
+
console.log(` ${overall} — ${green(String(counts.pass))} pass · ${yellow(String(counts.warn))} warn · ${red(String(counts.fail))} fail (${elapsed}ms)`);
|
|
225
|
+
console.log();
|
|
226
|
+
|
|
227
|
+
if (counts.warn > 0 || counts.fail > 0) {
|
|
228
|
+
console.log(` Run ${cyan("infernoflow doctor --fix")} to auto-fix warnings`);
|
|
229
|
+
console.log();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export async function doctorCommand(rawArgs) {
|
|
236
|
+
const args = rawArgs.slice(1);
|
|
237
|
+
const jsonMode = args.includes("--json");
|
|
238
|
+
const fixMode = args.includes("--fix");
|
|
239
|
+
const cwd = process.cwd();
|
|
240
|
+
const start = Date.now();
|
|
241
|
+
|
|
242
|
+
const results = [
|
|
243
|
+
check("Node.js version", () => checkNodeVersion()),
|
|
244
|
+
check("infernoflow CLI", () => checkCli()),
|
|
245
|
+
check("Git repository", () => checkGitRepo(cwd)),
|
|
246
|
+
check("inferno/ directory",() => checkInfernoDir(cwd)),
|
|
247
|
+
check("Contract file", () => checkContract(cwd)),
|
|
248
|
+
check("Scenarios", () => checkScenarios(cwd)),
|
|
249
|
+
check("Changelog", () => checkChangelog(cwd)),
|
|
250
|
+
check("CONTEXT.md", () => checkContextMd(cwd)),
|
|
251
|
+
check("Git hooks", () => checkGitHooks(cwd)),
|
|
252
|
+
check("MCP server", () => checkMcp(cwd)),
|
|
253
|
+
check("AI providers", () => checkAiProviders(cwd)),
|
|
254
|
+
check("Cloud sync", () => checkCloudToken(cwd)),
|
|
255
|
+
await checkOllama().then(r => ({ label: "Ollama (local AI)", ...r })),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const elapsed = Date.now() - start;
|
|
259
|
+
|
|
260
|
+
if (fixMode) {
|
|
261
|
+
const fixed = autoFix(results, cwd);
|
|
262
|
+
if (fixed.length) {
|
|
263
|
+
if (!jsonMode) {
|
|
264
|
+
console.log();
|
|
265
|
+
fixed.forEach(f => console.log(` ${green("✔")} Fixed: ${f}`));
|
|
266
|
+
console.log();
|
|
267
|
+
}
|
|
268
|
+
// Re-run checks after fixing
|
|
269
|
+
return doctorCommand(["doctor", "--json"]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (jsonMode) {
|
|
274
|
+
const counts = { pass: 0, warn: 0, fail: 0, info: 0 };
|
|
275
|
+
results.forEach(r => counts[r.status] = (counts[r.status] || 0) + 1);
|
|
276
|
+
console.log(JSON.stringify({ ok: counts.fail === 0, counts, results, elapsed }));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
printReport(results, elapsed);
|
|
281
|
+
|
|
282
|
+
const hasFail = results.some(r => r.status === "fail");
|
|
283
|
+
if (hasFail) process.exit(1);
|
|
284
|
+
}
|
|
@@ -155,6 +155,76 @@ export async function initCommand(args) {
|
|
|
155
155
|
const force = args.includes("--force") || args.includes("-f");
|
|
156
156
|
const yes = args.includes("--yes") || args.includes("-y");
|
|
157
157
|
const adopt = args.includes("--adopt");
|
|
158
|
+
|
|
159
|
+
// ── Template shortcut ──────────────────────────────────────────────────────
|
|
160
|
+
const templateIdx = args.indexOf("--template");
|
|
161
|
+
const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null;
|
|
162
|
+
|
|
163
|
+
if (templateName) {
|
|
164
|
+
let tmplMod;
|
|
165
|
+
try { tmplMod = await import("../templates/index.mjs"); } catch {}
|
|
166
|
+
const tmpl = tmplMod?.getTemplate(templateName);
|
|
167
|
+
if (!tmpl) {
|
|
168
|
+
const available = tmplMod ? tmplMod.listTemplates().map(t => t.name).join(", ") : "rest-api, nextjs, cli, graphql, monorepo";
|
|
169
|
+
warn(`Unknown template: ${templateName}. Available: ${available}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
174
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
175
|
+
if (!fs.existsSync(infernoDir)) fs.mkdirSync(infernoDir, { recursive: true });
|
|
176
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
177
|
+
|
|
178
|
+
const policyId = detectProjectName(cwd);
|
|
179
|
+
const caps = tmpl.capabilities;
|
|
180
|
+
|
|
181
|
+
// Write contract.json
|
|
182
|
+
fs.writeFileSync(path.join(infernoDir, "contract.json"), JSON.stringify({
|
|
183
|
+
policyId, policyVersion: 1,
|
|
184
|
+
capabilities: caps.map(c => c.id),
|
|
185
|
+
}, null, 2) + "\n");
|
|
186
|
+
|
|
187
|
+
// Write capabilities.json
|
|
188
|
+
fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
|
|
189
|
+
capabilities: caps.map(c => ({
|
|
190
|
+
id: c.id, description: c.description,
|
|
191
|
+
since: new Date().toISOString().slice(0, 10), source: `template:${templateName}`,
|
|
192
|
+
})),
|
|
193
|
+
}, null, 2) + "\n");
|
|
194
|
+
|
|
195
|
+
// Write one scenario per capability
|
|
196
|
+
for (const cap of caps) {
|
|
197
|
+
fs.writeFileSync(path.join(scenDir, `${cap.id}.json`), JSON.stringify({
|
|
198
|
+
id: `${cap.id}-happy-path`, capability: cap.id,
|
|
199
|
+
description: `Happy path for ${cap.description || cap.id}`,
|
|
200
|
+
steps: [
|
|
201
|
+
{ action: "invoke", target: cap.id, input: {} },
|
|
202
|
+
{ action: "assert", field: "status", value: "success" },
|
|
203
|
+
],
|
|
204
|
+
capabilitiesCovered: [cap.id],
|
|
205
|
+
}, null, 2) + "\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Write CHANGELOG.md
|
|
209
|
+
writeChangelog(path.join(infernoDir, "CHANGELOG.md"), policyId);
|
|
210
|
+
|
|
211
|
+
// Write CONTEXT.md hint
|
|
212
|
+
fs.writeFileSync(path.join(infernoDir, "CONTEXT.md"),
|
|
213
|
+
`# ${policyId} — infernoflow context\n\n> Template: ${templateName} — ${tmpl.description}\n\n## Hint\n${tmpl.contextHint}\n\n## Capabilities (${caps.length})\n${caps.map(c => `- \`${c.id}\`: ${c.description}`).join("\n")}\n`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (tmpl.scripts) {
|
|
217
|
+
info(`Suggested package.json scripts for this template:`);
|
|
218
|
+
Object.entries(tmpl.scripts).forEach(([k, v]) => console.log(` ${bold(k)}: ${gray(v)}`));
|
|
219
|
+
console.log();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
done(`Initialised from template ${bold(cyan(templateName))} — ${bold(String(caps.length))} capabilities`);
|
|
223
|
+
console.log();
|
|
224
|
+
info(`Run ${cyan("infernoflow vibe")} to start vibe coding mode`);
|
|
225
|
+
console.log();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
158
228
|
const cursorHooks = args.includes("--cursor-hooks");
|
|
159
229
|
const vscodeCopilotHooks = args.includes("--vscode-copilot-hooks");
|
|
160
230
|
const reportJson = args.includes("--report-json");
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow review
|
|
3
|
+
*
|
|
4
|
+
* AI-powered capability impact review for staged (or recent) git changes.
|
|
5
|
+
* Reads git diff, identifies which capabilities are affected, then asks
|
|
6
|
+
* your configured AI provider to write a capability impact summary.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow review Review staged changes (git diff --staged)
|
|
10
|
+
* infernoflow review --unstaged Review all working-tree changes
|
|
11
|
+
* infernoflow review --last Review last commit (git diff HEAD~1)
|
|
12
|
+
* infernoflow review --dry-run Print the AI prompt only — no API call
|
|
13
|
+
* infernoflow review --json Machine-readable output
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
20
|
+
|
|
21
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function runGit(cmd, cwd) {
|
|
24
|
+
try {
|
|
25
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadJson(filePath) {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Tokenise a string into lowercase words */
|
|
37
|
+
function tokenise(str) {
|
|
38
|
+
return str
|
|
39
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.split(/[\s_\-/.]+/)
|
|
42
|
+
.filter(t => t.length > 2);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Return capability IDs mentioned in or matched by the diff text */
|
|
46
|
+
function findAffectedCaps(diff, capabilities) {
|
|
47
|
+
const diffLower = diff.toLowerCase();
|
|
48
|
+
const affected = new Set();
|
|
49
|
+
|
|
50
|
+
for (const cap of capabilities) {
|
|
51
|
+
const tokens = [
|
|
52
|
+
...tokenise(cap.id || ""),
|
|
53
|
+
...tokenise(cap.name || ""),
|
|
54
|
+
...(cap.tags || []).flatMap(tokenise),
|
|
55
|
+
];
|
|
56
|
+
// Direct ID mention (e.g. "auth-login" appears in diff)
|
|
57
|
+
if (diffLower.includes((cap.id || "").toLowerCase())) {
|
|
58
|
+
affected.add(cap.id);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Token overlap — need ≥2 matching tokens to avoid false positives
|
|
62
|
+
const matches = tokens.filter(t => t.length > 3 && diffLower.includes(t));
|
|
63
|
+
if (matches.length >= 2) affected.add(cap.id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [...affected];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Trim diff to a reasonable size for the prompt */
|
|
70
|
+
function trimDiff(diff, maxChars = 8000) {
|
|
71
|
+
if (diff.length <= maxChars) return diff;
|
|
72
|
+
const half = Math.floor(maxChars / 2);
|
|
73
|
+
return diff.slice(0, half) + "\n\n[… diff truncated …]\n\n" + diff.slice(-half);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── prompt builder ───────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function buildPrompt(diff, affectedCaps, capabilities) {
|
|
79
|
+
const capDetails = capabilities
|
|
80
|
+
.filter(c => affectedCaps.includes(c.id))
|
|
81
|
+
.map(c => ` • ${c.id}: ${c.name}${c.description ? " — " + c.description : ""}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
|
|
84
|
+
const capList = affectedCaps.length > 0
|
|
85
|
+
? `Affected capabilities detected:\n${capDetails}`
|
|
86
|
+
: "No specific capabilities were matched — review the entire contract.";
|
|
87
|
+
|
|
88
|
+
return `You are a senior software architect reviewing a code change for capability drift.
|
|
89
|
+
|
|
90
|
+
${capList}
|
|
91
|
+
|
|
92
|
+
Git diff:
|
|
93
|
+
\`\`\`diff
|
|
94
|
+
${trimDiff(diff)}
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
Write a concise capability impact summary covering:
|
|
98
|
+
1. Which capabilities are changed, added, or removed
|
|
99
|
+
2. Whether the contract (capabilities.json) needs updating
|
|
100
|
+
3. Any risks or side-effects (breaking changes, auth/security concerns, API contract violations)
|
|
101
|
+
4. Recommended follow-up actions (one sentence each)
|
|
102
|
+
|
|
103
|
+
Keep the tone professional and brief. Use bullet points only where genuinely helpful.
|
|
104
|
+
Do NOT repeat the diff back.`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── reporters ────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
function printReport(affectedCaps, summary, capabilities, source) {
|
|
110
|
+
console.log();
|
|
111
|
+
console.log(bold(cyan(" ✦ Capability Impact Review")));
|
|
112
|
+
console.log(gray(` Source: ${source}`));
|
|
113
|
+
console.log();
|
|
114
|
+
|
|
115
|
+
if (affectedCaps.length === 0) {
|
|
116
|
+
console.log(yellow(" No capabilities directly matched — reviewing full diff."));
|
|
117
|
+
} else {
|
|
118
|
+
console.log(bold(" Affected capabilities:"));
|
|
119
|
+
for (const id of affectedCaps) {
|
|
120
|
+
const cap = capabilities.find(c => c.id === id);
|
|
121
|
+
console.log(` ${green("▸")} ${id}${cap ? gray(" — " + cap.name) : ""}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(bold(" AI Impact Summary"));
|
|
127
|
+
console.log(gray(" ─────────────────────────────────────────────────────────────"));
|
|
128
|
+
// Indent each line
|
|
129
|
+
for (const line of summary.split("\n")) {
|
|
130
|
+
console.log(" " + line);
|
|
131
|
+
}
|
|
132
|
+
console.log();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── entry point ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export async function reviewCommand(rawArgs) {
|
|
138
|
+
const args = rawArgs || [];
|
|
139
|
+
const dryRun = args.includes("--dry-run");
|
|
140
|
+
const jsonMode = args.includes("--json");
|
|
141
|
+
const unstaged = args.includes("--unstaged");
|
|
142
|
+
const lastCommit = args.includes("--last");
|
|
143
|
+
|
|
144
|
+
const cwd = process.cwd();
|
|
145
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
146
|
+
|
|
147
|
+
// Load capabilities
|
|
148
|
+
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
149
|
+
if (!fs.existsSync(capsPath)) {
|
|
150
|
+
console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const capabilities = loadJson(capsPath);
|
|
154
|
+
if (!Array.isArray(capabilities) || capabilities.length === 0) {
|
|
155
|
+
console.log(yellow("No capabilities found — nothing to review."));
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Get diff
|
|
160
|
+
let diffCmd, diffSource;
|
|
161
|
+
if (lastCommit) {
|
|
162
|
+
diffCmd = "git diff HEAD~1";
|
|
163
|
+
diffSource = "last commit (HEAD~1)";
|
|
164
|
+
} else if (unstaged) {
|
|
165
|
+
diffCmd = "git diff";
|
|
166
|
+
diffSource = "unstaged changes";
|
|
167
|
+
} else {
|
|
168
|
+
diffCmd = "git diff --staged";
|
|
169
|
+
diffSource = "staged changes";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let diff = runGit(diffCmd, cwd);
|
|
173
|
+
|
|
174
|
+
// Fallback: if staged is empty, try unstaged
|
|
175
|
+
if (!diff && !lastCommit && !unstaged) {
|
|
176
|
+
diff = runGit("git diff", cwd);
|
|
177
|
+
diffSource = "unstaged changes (no staged changes found)";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!diff) {
|
|
181
|
+
console.log(yellow("No changes found to review."));
|
|
182
|
+
console.log(gray(" Tip: stage some files first (`git add -p`) or use --last / --unstaged"));
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Identify affected capabilities
|
|
187
|
+
const affectedCaps = findAffectedCaps(diff, capabilities);
|
|
188
|
+
|
|
189
|
+
// Build prompt
|
|
190
|
+
const prompt = buildPrompt(diff, affectedCaps, capabilities);
|
|
191
|
+
|
|
192
|
+
if (dryRun) {
|
|
193
|
+
console.log(gray("── Prompt (--dry-run) ────────────────────────────────────────────────"));
|
|
194
|
+
console.log(prompt);
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Call AI
|
|
199
|
+
if (!jsonMode) process.stdout.write(gray(" Calling AI provider…"));
|
|
200
|
+
|
|
201
|
+
let aiResult = null;
|
|
202
|
+
try {
|
|
203
|
+
const { callAI } = await import("../ai/providerRouter.mjs");
|
|
204
|
+
aiResult = await callAI(prompt, { cwd, maxTokens: 600 });
|
|
205
|
+
} catch (e) {
|
|
206
|
+
// provider router import failure is non-fatal — we degrade gracefully
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!jsonMode) process.stdout.write("\r" + " ".repeat(30) + "\r");
|
|
210
|
+
|
|
211
|
+
if (!aiResult) {
|
|
212
|
+
console.log();
|
|
213
|
+
console.log(yellow(" ⚠ No AI provider available."));
|
|
214
|
+
console.log(gray(" Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or OPENROUTER_API_KEY,"));
|
|
215
|
+
console.log(gray(" or run Ollama locally. See `infernoflow doctor` for details."));
|
|
216
|
+
console.log();
|
|
217
|
+
console.log(bold(" Affected capabilities (unanswered):"));
|
|
218
|
+
for (const id of affectedCaps) console.log(` ▸ ${id}`);
|
|
219
|
+
console.log();
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const summary = aiResult.text || "(empty response)";
|
|
224
|
+
|
|
225
|
+
if (jsonMode) {
|
|
226
|
+
console.log(JSON.stringify({
|
|
227
|
+
source: diffSource,
|
|
228
|
+
provider: aiResult.provider,
|
|
229
|
+
model: aiResult.model,
|
|
230
|
+
affectedCapabilities: affectedCaps,
|
|
231
|
+
summary,
|
|
232
|
+
}, null, 2));
|
|
233
|
+
} else {
|
|
234
|
+
printReport(affectedCaps, summary, capabilities, diffSource);
|
|
235
|
+
console.log(gray(` Provider: ${aiResult.provider} Model: ${aiResult.model}`));
|
|
236
|
+
console.log();
|
|
237
|
+
}
|
|
238
|
+
}
|