infernoflow 0.21.0 → 0.22.1
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 +22 -0
- package/dist/lib/ai/providerRouter.mjs +227 -64
- package/dist/lib/commands/coverage.mjs +282 -0
- package/dist/lib/commands/doctor.mjs +284 -0
- package/dist/lib/commands/review.mjs +238 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -51,6 +51,9 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
51
51
|
health: "Compute a 0–100 health score across coverage, docs, freshness, completeness, drift",
|
|
52
52
|
vibe: "Vibe coding mode — watches files, auto-syncs contract, regenerates context on every save",
|
|
53
53
|
adopt: "Interactive wizard to adopt infernoflow in an existing project (detect → review → wire up)",
|
|
54
|
+
doctor: "Diagnose your infernoflow setup — checks Node, git, contract, AI providers, MCP, hooks",
|
|
55
|
+
coverage: "Map test files to capabilities — show which caps have test coverage and which don't",
|
|
56
|
+
review: "AI-powered capability impact review for staged or recent git changes",
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
const COMMAND_HANDLERS = {
|
|
@@ -95,6 +98,9 @@ const COMMAND_HANDLERS = {
|
|
|
95
98
|
health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
|
|
96
99
|
vibe: async (args) => (await import("../lib/commands/vibe.mjs")).vibeCommand(args),
|
|
97
100
|
adopt: async (args) => (await import("../lib/commands/adoptWizard.mjs")).adoptWizardCommand(args),
|
|
101
|
+
doctor: async (args) => (await import("../lib/commands/doctor.mjs")).doctorCommand(args),
|
|
102
|
+
coverage: async (args) => (await import("../lib/commands/coverage.mjs")).coverageCommand(args),
|
|
103
|
+
review: async (args) => (await import("../lib/commands/review.mjs")).reviewCommand(args),
|
|
98
104
|
};
|
|
99
105
|
|
|
100
106
|
function formatCommandsHelp() {
|
|
@@ -339,6 +345,22 @@ ${formatCommandsHelp()}
|
|
|
339
345
|
--interval <secs> Watch interval in seconds (default: 30)
|
|
340
346
|
--json Machine-readable score + breakdown
|
|
341
347
|
|
|
348
|
+
${bold("doctor options:")}
|
|
349
|
+
--fix Auto-fix common issues (installs hooks, runs init, etc.)
|
|
350
|
+
--json Machine-readable list of pass/warn/fail results
|
|
351
|
+
|
|
352
|
+
${bold("coverage options:")}
|
|
353
|
+
--dir <path> Extra directory to scan for test files (repeatable)
|
|
354
|
+
--threshold <0-1> Minimum fuzzy-match score to count a test (default: 0.25)
|
|
355
|
+
--fail-below <pct> Exit 1 if coverage percentage is below this value (CI gate)
|
|
356
|
+
--json Machine-readable coverage breakdown
|
|
357
|
+
|
|
358
|
+
${bold("review options:")}
|
|
359
|
+
--unstaged Review all working-tree changes (not just staged)
|
|
360
|
+
--last Review last commit (git diff HEAD~1)
|
|
361
|
+
--dry-run Print the AI prompt only — no API call made
|
|
362
|
+
--json Machine-readable output (affectedCaps, summary, provider)
|
|
363
|
+
|
|
342
364
|
${bold("Machine output:")}
|
|
343
365
|
${gray("status --json")}
|
|
344
366
|
${gray("check --json")}
|
|
@@ -1,73 +1,236 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
reasonCodes,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow AI provider router
|
|
3
|
+
*
|
|
4
|
+
* Tries providers in order until one works:
|
|
5
|
+
* Tier 1 — VS Code Language Model API (vscode.lm — any Copilot model: Gemini, Claude, GPT)
|
|
6
|
+
* Tier 2 — Direct API: Anthropic, OpenAI, Google AI (Gemini), OpenRouter
|
|
7
|
+
* Tier 3 — Ollama (local, free, offline)
|
|
8
|
+
* Tier 4 — Prompt fallback (print prompt, no AI call)
|
|
9
|
+
*
|
|
10
|
+
* Config sources (in priority order):
|
|
11
|
+
* 1. Environment variables
|
|
12
|
+
* 2. inferno/integrations.json
|
|
13
|
+
* 3. Auto-detection (Ollama running locally, etc.)
|
|
14
|
+
*/
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import * as https from "node:https";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
|
|
21
|
+
// ── Config reader ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function readAiConfig(cwd) {
|
|
24
|
+
const p = path.join(cwd, "inferno", "integrations.json");
|
|
25
|
+
if (!fs.existsSync(p)) return {};
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function post(url, headers, body) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
35
|
+
const data = JSON.stringify(body);
|
|
36
|
+
|
|
37
|
+
const req = lib.request({
|
|
38
|
+
hostname: parsed.hostname,
|
|
39
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
40
|
+
path: parsed.pathname + (parsed.search || ""),
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), ...headers },
|
|
43
|
+
}, (res) => {
|
|
44
|
+
let raw = "";
|
|
45
|
+
res.on("data", d => (raw += d));
|
|
46
|
+
res.on("end", () => {
|
|
47
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
|
|
48
|
+
catch { resolve({ status: res.statusCode, body: raw }); }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
req.on("error", reject);
|
|
52
|
+
req.write(data);
|
|
53
|
+
req.end();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Tier 2: Direct API providers ─────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async function callAnthropic(prompt, config) {
|
|
60
|
+
const apiKey = process.env.ANTHROPIC_API_KEY || config.anthropic?.apiKey;
|
|
61
|
+
if (!apiKey) return null;
|
|
29
62
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
63
|
+
const model = config.anthropic?.model || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6";
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const res = await post(
|
|
67
|
+
"https://api.anthropic.com/v1/messages",
|
|
68
|
+
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
|
69
|
+
{
|
|
70
|
+
model,
|
|
71
|
+
max_tokens: 1024,
|
|
72
|
+
messages: [{ role: "user", content: prompt }],
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
if (res.status === 200 && res.body?.content?.[0]?.text) {
|
|
76
|
+
return { text: res.body.content[0].text, provider: "anthropic", model };
|
|
41
77
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
} catch {}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function callOpenAI(prompt, config) {
|
|
83
|
+
const apiKey = process.env.OPENAI_API_KEY || config.openai?.apiKey;
|
|
84
|
+
const endpoint = process.env.OPENAI_ENDPOINT || config.openai?.endpoint || "https://api.openai.com/v1/chat/completions";
|
|
85
|
+
if (!apiKey) return null;
|
|
86
|
+
|
|
87
|
+
const model = config.openai?.model || process.env.OPENAI_MODEL || "gpt-4o";
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const res = await post(
|
|
91
|
+
endpoint,
|
|
92
|
+
{ "Authorization": `Bearer ${apiKey}` },
|
|
93
|
+
{
|
|
94
|
+
model,
|
|
95
|
+
max_tokens: 1024,
|
|
96
|
+
messages: [{ role: "user", content: prompt }],
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
if (res.status === 200 && res.body?.choices?.[0]?.message?.content) {
|
|
100
|
+
return { text: res.body.choices[0].message.content, provider: "openai", model };
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function callGemini(prompt, config) {
|
|
107
|
+
const apiKey = process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY || config.gemini?.apiKey;
|
|
108
|
+
if (!apiKey) return null;
|
|
109
|
+
|
|
110
|
+
const model = config.gemini?.model || process.env.GEMINI_MODEL || "gemini-2.0-flash";
|
|
51
111
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
112
|
+
try {
|
|
113
|
+
const res = await post(
|
|
114
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
115
|
+
{},
|
|
116
|
+
{ contents: [{ parts: [{ text: prompt }] }] }
|
|
117
|
+
);
|
|
118
|
+
const text = res.body?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
119
|
+
if (res.status === 200 && text) {
|
|
120
|
+
return { text, provider: "gemini", model };
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function callOpenRouter(prompt, config) {
|
|
127
|
+
const apiKey = process.env.OPENROUTER_API_KEY || config.openrouter?.apiKey;
|
|
128
|
+
if (!apiKey) return null;
|
|
129
|
+
|
|
130
|
+
const model = config.openrouter?.model || process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4-6";
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const res = await post(
|
|
134
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
135
|
+
{ "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://infernoflow.dev" },
|
|
136
|
+
{
|
|
137
|
+
model,
|
|
138
|
+
messages: [{ role: "user", content: prompt }],
|
|
139
|
+
max_tokens: 1024,
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
if (res.status === 200 && res.body?.choices?.[0]?.message?.content) {
|
|
143
|
+
return { text: res.body.choices[0].message.content, provider: "openrouter", model };
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Tier 3: Ollama (local) ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async function callOllama(prompt, config) {
|
|
152
|
+
const host = process.env.OLLAMA_HOST || config.ollama?.host || "http://localhost:11434";
|
|
153
|
+
const model = process.env.OLLAMA_MODEL || config.ollama?.model || "llama3";
|
|
154
|
+
|
|
155
|
+
// Quick liveness check
|
|
156
|
+
try {
|
|
157
|
+
await new Promise((res, rej) => {
|
|
158
|
+
const u = new URL(host);
|
|
159
|
+
http.get({ hostname: u.hostname, port: u.port || 11434, path: "/api/tags", timeout: 1500 },
|
|
160
|
+
r => res(r)).on("error", rej);
|
|
161
|
+
});
|
|
162
|
+
} catch { return null; }
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const res = await post(
|
|
166
|
+
`${host}/api/generate`,
|
|
167
|
+
{},
|
|
168
|
+
{ model, prompt, stream: false }
|
|
169
|
+
);
|
|
170
|
+
if (res.status === 200 && res.body?.response) {
|
|
171
|
+
return { text: res.body.response, provider: "ollama", model };
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Main router ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Call the best available AI provider with a prompt.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} prompt - The full prompt text
|
|
183
|
+
* @param {object} opts
|
|
184
|
+
* opts.cwd - Project root (for reading integrations.json)
|
|
185
|
+
* opts.provider - Force a specific provider: anthropic|openai|gemini|openrouter|ollama|prompt
|
|
186
|
+
* opts.silent - Don't print "using provider X" message
|
|
187
|
+
* @returns {{ text: string, provider: string, model: string } | null}
|
|
188
|
+
* null means no provider was available → caller should use prompt fallback
|
|
189
|
+
*/
|
|
190
|
+
export async function callAI(prompt, opts = {}) {
|
|
191
|
+
const cwd = opts.cwd || process.cwd();
|
|
192
|
+
const config = readAiConfig(cwd);
|
|
193
|
+
const forced = (opts.provider || "auto").toLowerCase();
|
|
194
|
+
const silent = opts.silent ?? true;
|
|
195
|
+
|
|
196
|
+
const providers = [
|
|
197
|
+
// Tier 2 — direct API (Tier 1 vscode.lm is handled in the VS Code extension)
|
|
198
|
+
["anthropic", () => callAnthropic(prompt, config)],
|
|
199
|
+
["openai", () => callOpenAI(prompt, config)],
|
|
200
|
+
["gemini", () => callGemini(prompt, config)],
|
|
201
|
+
["openrouter", () => callOpenRouter(prompt, config)],
|
|
202
|
+
// Tier 3 — local
|
|
203
|
+
["ollama", () => callOllama(prompt, config)],
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
// If a specific provider is forced, only try that one
|
|
207
|
+
const toTry = forced === "auto" || forced === "prompt"
|
|
208
|
+
? providers
|
|
209
|
+
: providers.filter(([name]) => name === forced);
|
|
210
|
+
|
|
211
|
+
for (const [name, fn] of toTry) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await fn();
|
|
214
|
+
if (result) {
|
|
215
|
+
if (!silent) process.stderr.write(` [infernoflow ai] using ${name}:${result.model}\n`);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
} catch {}
|
|
62
219
|
}
|
|
63
220
|
|
|
64
|
-
|
|
221
|
+
return null; // No provider → fallback to prompt output
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Detect which providers are configured (for doctor command).
|
|
226
|
+
*/
|
|
227
|
+
export function detectAvailableProviders(cwd) {
|
|
228
|
+
const config = readAiConfig(cwd);
|
|
65
229
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
230
|
+
anthropic: !!(process.env.ANTHROPIC_API_KEY || config.anthropic?.apiKey),
|
|
231
|
+
openai: !!(process.env.OPENAI_API_KEY || config.openai?.apiKey),
|
|
232
|
+
gemini: !!(process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY || config.gemini?.apiKey),
|
|
233
|
+
openrouter: !!(process.env.OPENROUTER_API_KEY || config.openrouter?.apiKey),
|
|
234
|
+
ollama: false, // checked async — doctor runs its own check
|
|
71
235
|
};
|
|
72
236
|
}
|
|
73
|
-
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow coverage
|
|
3
|
+
*
|
|
4
|
+
* Maps test files to capabilities via fuzzy name matching.
|
|
5
|
+
* Shows which capabilities have test coverage and which don't.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow coverage Print coverage table
|
|
9
|
+
* infernoflow coverage --json Machine-readable output
|
|
10
|
+
* infernoflow coverage --dir src/ Extra dirs to scan (default: project root)
|
|
11
|
+
* infernoflow coverage --fail-below 50 Exit 1 if coverage < N%
|
|
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
|
+
// ─── test pattern extractors ─────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const TEST_PATTERNS = [
|
|
21
|
+
// Jest / Vitest — it("...", …) test("...", …) describe("...", …)
|
|
22
|
+
{ regex: /(?:it|test|describe)\s*\(\s*["'`]([^"'`]+)["'`]/g, lang: "js" },
|
|
23
|
+
// Pytest — def test_something
|
|
24
|
+
{ regex: /def\s+(test_[\w_]+)\s*\(/g, lang: "py" },
|
|
25
|
+
// RSpec — describe/it "..."
|
|
26
|
+
{ regex: /(?:describe|it)\s+["']([^"']+)["']/g, lang: "rb" },
|
|
27
|
+
// Go — func TestXxx(
|
|
28
|
+
{ regex: /func\s+(Test\w+)\s*\(/g, lang: "go" },
|
|
29
|
+
// Rust — #[test] fn xxx
|
|
30
|
+
{ regex: /#\[test\]\s*\n\s*(?:async\s+)?fn\s+(\w+)/g, lang: "rs" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const TEST_FILE_GLOBS = [
|
|
34
|
+
/\.(test|spec)\.[jt]sx?$/, // foo.test.ts
|
|
35
|
+
/__tests__/, // __tests__/foo.js
|
|
36
|
+
/\.test\.py$/, // test_foo.py
|
|
37
|
+
/^test_.*\.py$/, // test_foo.py (basename)
|
|
38
|
+
/_spec\.rb$/, // foo_spec.rb
|
|
39
|
+
/\/spec\//, // spec/ directory
|
|
40
|
+
/_test\.go$/, // foo_test.go
|
|
41
|
+
/_test\.rs$/, // foo_test.rs
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const SKIP_DIRS = new Set([
|
|
45
|
+
"node_modules", ".git", "dist", "build", "out", ".next",
|
|
46
|
+
"coverage", ".nyc_output", "__pycache__", ".pytest_cache",
|
|
47
|
+
"vendor", "tmp", ".turbo",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// ─── file walker ─────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function* walkFiles(dir) {
|
|
53
|
+
let entries;
|
|
54
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
55
|
+
catch { return; }
|
|
56
|
+
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
if (!SKIP_DIRS.has(e.name)) yield* walkFiles(path.join(dir, e.name));
|
|
60
|
+
} else if (e.isFile()) {
|
|
61
|
+
yield path.join(dir, e.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isTestFile(filePath) {
|
|
67
|
+
const basename = path.basename(filePath);
|
|
68
|
+
return TEST_FILE_GLOBS.some(re => re.test(filePath) || re.test(basename));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── test name extractor ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function extractTestNames(filePath) {
|
|
74
|
+
let src;
|
|
75
|
+
try { src = fs.readFileSync(filePath, "utf8"); }
|
|
76
|
+
catch { return []; }
|
|
77
|
+
|
|
78
|
+
const names = new Set();
|
|
79
|
+
for (const { regex } of TEST_PATTERNS) {
|
|
80
|
+
const r = new RegExp(regex.source, regex.flags);
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = r.exec(src)) !== null) {
|
|
83
|
+
names.add(m[1].trim());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...names];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── fuzzy matcher ───────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tokenise a string: split on spaces, hyphens, underscores, camelCase.
|
|
93
|
+
* Returns an array of lowercase tokens.
|
|
94
|
+
*/
|
|
95
|
+
function tokenise(str) {
|
|
96
|
+
return str
|
|
97
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase split
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.split(/[\s_\-/]+/)
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Jaccard-like overlap score between two token sets.
|
|
105
|
+
* Returns a value in [0, 1].
|
|
106
|
+
*/
|
|
107
|
+
function overlapScore(a, b) {
|
|
108
|
+
const setA = new Set(a);
|
|
109
|
+
const setB = new Set(b);
|
|
110
|
+
let common = 0;
|
|
111
|
+
for (const t of setA) if (setB.has(t)) common++;
|
|
112
|
+
const union = setA.size + setB.size - common;
|
|
113
|
+
return union === 0 ? 0 : common / union;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Best match score between a test name and a capability (id + name).
|
|
118
|
+
*/
|
|
119
|
+
function matchScore(testName, cap) {
|
|
120
|
+
const testTokens = tokenise(testName);
|
|
121
|
+
const idTokens = tokenise(cap.id || "");
|
|
122
|
+
const nameTokens = tokenise(cap.name || "");
|
|
123
|
+
return Math.max(
|
|
124
|
+
overlapScore(testTokens, idTokens),
|
|
125
|
+
overlapScore(testTokens, nameTokens),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── main scanner ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function scanTestFiles(dirs) {
|
|
132
|
+
const testFiles = [];
|
|
133
|
+
for (const dir of dirs) {
|
|
134
|
+
for (const f of walkFiles(dir)) {
|
|
135
|
+
if (isTestFile(f)) testFiles.push(f);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return testFiles;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildTestIndex(testFiles) {
|
|
142
|
+
// Returns: Map<testName, filePath>
|
|
143
|
+
const index = new Map();
|
|
144
|
+
for (const f of testFiles) {
|
|
145
|
+
for (const name of extractTestNames(f)) {
|
|
146
|
+
if (!index.has(name)) index.set(name, f);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return index;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Returns: Map<capId, { matched: [{testName, file, score}], score: number }> */
|
|
153
|
+
function mapTestsToCaps(capabilities, testIndex, threshold = 0.25) {
|
|
154
|
+
const result = new Map();
|
|
155
|
+
|
|
156
|
+
for (const cap of capabilities) {
|
|
157
|
+
const hits = [];
|
|
158
|
+
for (const [testName, file] of testIndex) {
|
|
159
|
+
const score = matchScore(testName, cap);
|
|
160
|
+
if (score >= threshold) {
|
|
161
|
+
hits.push({ testName, file: path.relative(process.cwd(), file), score });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
hits.sort((a, b) => b.score - a.score);
|
|
165
|
+
result.set(cap.id, { cap, hits });
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── reporters ────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function bar(pct, width = 20) {
|
|
173
|
+
const filled = Math.round((pct / 100) * width);
|
|
174
|
+
const colour = pct >= 75 ? green : pct >= 40 ? yellow : red;
|
|
175
|
+
return colour("█".repeat(filled)) + gray("░".repeat(width - filled));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function printTable(coverageMap) {
|
|
179
|
+
const covered = [...coverageMap.values()].filter(v => v.hits.length > 0).length;
|
|
180
|
+
const total = coverageMap.size;
|
|
181
|
+
const pct = total === 0 ? 0 : Math.round((covered / total) * 100);
|
|
182
|
+
|
|
183
|
+
console.log();
|
|
184
|
+
console.log(bold(" Capability Coverage"));
|
|
185
|
+
console.log(gray(" ─────────────────────────────────────────────────────────────"));
|
|
186
|
+
console.log(
|
|
187
|
+
gray(" ") +
|
|
188
|
+
bold(cyan("Capability".padEnd(32))) +
|
|
189
|
+
bold(cyan("Tests".padEnd(8))) +
|
|
190
|
+
bold(cyan("Top match"))
|
|
191
|
+
);
|
|
192
|
+
console.log(gray(" ─────────────────────────────────────────────────────────────"));
|
|
193
|
+
|
|
194
|
+
for (const [, { cap, hits }] of coverageMap) {
|
|
195
|
+
const status = hits.length > 0 ? green("✔") : red("✗");
|
|
196
|
+
const topName = hits[0] ? gray(` ${hits[0].testName.slice(0, 42)}`) : "";
|
|
197
|
+
const count = hits.length === 0 ? red("0") : green(String(hits.length));
|
|
198
|
+
console.log(
|
|
199
|
+
` ${status} ${cap.id.padEnd(30)} ${count.padEnd(6)} ${topName}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(gray(" ─────────────────────────────────────────────────────────────"));
|
|
204
|
+
console.log();
|
|
205
|
+
console.log(` ${bar(pct)} ${bold(pct + "%")} (${covered}/${total} capabilities covered)`);
|
|
206
|
+
console.log();
|
|
207
|
+
|
|
208
|
+
if (total > 0 && covered < total) {
|
|
209
|
+
const uncovered = [...coverageMap.values()]
|
|
210
|
+
.filter(v => v.hits.length === 0)
|
|
211
|
+
.map(v => v.cap.id);
|
|
212
|
+
console.log(yellow(` ⚠ Uncovered: ${uncovered.join(", ")}`));
|
|
213
|
+
console.log();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── entry point ─────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
export async function coverageCommand(rawArgs) {
|
|
220
|
+
const args = rawArgs || [];
|
|
221
|
+
const jsonMode = args.includes("--json");
|
|
222
|
+
const dirIdx = args.indexOf("--dir");
|
|
223
|
+
const extraDirs = dirIdx !== -1 ? [args[dirIdx + 1]] : [];
|
|
224
|
+
const failIdx = args.indexOf("--fail-below");
|
|
225
|
+
const failBelow = failIdx !== -1 ? Number(args[failIdx + 1]) : null;
|
|
226
|
+
const threshold = (() => {
|
|
227
|
+
const i = args.indexOf("--threshold");
|
|
228
|
+
return i !== -1 ? Number(args[i + 1]) : 0.25;
|
|
229
|
+
})();
|
|
230
|
+
|
|
231
|
+
const cwd = process.cwd();
|
|
232
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
233
|
+
|
|
234
|
+
// Load capabilities
|
|
235
|
+
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
236
|
+
if (!fs.existsSync(capsPath)) {
|
|
237
|
+
console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
let capabilities;
|
|
241
|
+
try { capabilities = JSON.parse(fs.readFileSync(capsPath, "utf8")); }
|
|
242
|
+
catch (e) { console.error(red("✗ Failed to parse capabilities.json: " + e.message)); process.exit(1); }
|
|
243
|
+
|
|
244
|
+
if (!Array.isArray(capabilities) || capabilities.length === 0) {
|
|
245
|
+
console.log(yellow("No capabilities found."));
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Scan test files
|
|
250
|
+
const scanDirs = [cwd, ...extraDirs];
|
|
251
|
+
if (!jsonMode) process.stdout.write(gray(" Scanning test files…"));
|
|
252
|
+
const testFiles = scanTestFiles(scanDirs);
|
|
253
|
+
if (!jsonMode) process.stdout.write(`\r Found ${testFiles.length} test file(s). \n`);
|
|
254
|
+
|
|
255
|
+
const testIndex = buildTestIndex(testFiles);
|
|
256
|
+
const coverageMap = mapTestsToCaps(capabilities, testIndex, threshold);
|
|
257
|
+
|
|
258
|
+
const covered = [...coverageMap.values()].filter(v => v.hits.length > 0).length;
|
|
259
|
+
const total = coverageMap.size;
|
|
260
|
+
const pct = total === 0 ? 0 : Math.round((covered / total) * 100);
|
|
261
|
+
|
|
262
|
+
if (jsonMode) {
|
|
263
|
+
const out = {
|
|
264
|
+
summary: { covered, total, pct, testFiles: testFiles.length },
|
|
265
|
+
capabilities: [...coverageMap.entries()].map(([id, { cap, hits }]) => ({
|
|
266
|
+
id,
|
|
267
|
+
name: cap.name,
|
|
268
|
+
covered: hits.length > 0,
|
|
269
|
+
testCount: hits.length,
|
|
270
|
+
topTests: hits.slice(0, 3).map(h => ({ name: h.testName, file: h.file, score: +h.score.toFixed(3) })),
|
|
271
|
+
})),
|
|
272
|
+
};
|
|
273
|
+
console.log(JSON.stringify(out, null, 2));
|
|
274
|
+
} else {
|
|
275
|
+
printTable(coverageMap);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (failBelow !== null && pct < failBelow) {
|
|
279
|
+
if (!jsonMode) console.error(red(`✗ Coverage ${pct}% is below threshold ${failBelow}%`));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|