infernoflow 0.28.0 → 0.30.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.
@@ -62,6 +62,9 @@ const COMMAND_DESCRIPTIONS = {
62
62
  why: "Given a file or function name — show which capability it serves, scenarios, stability, and git history",
63
63
  impact: "Blast radius analysis — see every cap, scenario, and risk level affected before you change anything",
64
64
  scaffold: "Generate a new capability — source skeleton, contract registration, and placeholder scenario in one command",
65
+ explain: "AI narrative about a capability — what it does, why it exists, what's risky, and what to test",
66
+ test: "Run registered scenarios for a capability — auto-generates a smoke harness if no test runner is configured",
67
+ ai: "Manage AI providers — setup, status, test connection (subcommands: setup | status | test | clear)",
65
68
  };
66
69
 
67
70
  const COMMAND_HANDLERS = {
@@ -117,6 +120,9 @@ const COMMAND_HANDLERS = {
117
120
  why: async (args) => (await import("../lib/commands/why.mjs")).whyCommand(args),
118
121
  impact: async (args) => (await import("../lib/commands/impact.mjs")).impactCommand(args),
119
122
  scaffold: async (args) => (await import("../lib/commands/scaffold.mjs")).scaffoldCommand(args),
123
+ explain: async (args) => (await import("../lib/commands/explain.mjs")).explainCommand(args),
124
+ test: async (args) => (await import("../lib/commands/test.mjs")).testCommand(args),
125
+ ai: async (args) => (await import("../lib/commands/ai.mjs")).aiCommand(args),
120
126
  };
121
127
 
122
128
  function formatCommandsHelp() {
@@ -415,6 +421,27 @@ ${formatCommandsHelp()}
415
421
  --dry-run Preview what would be generated without writing files
416
422
  --json Machine-readable output including generated code
417
423
 
424
+ ${bold("explain options:")}
425
+ infernoflow explain <cap-id> AI narrative: what it does, risk, what to test
426
+ --dry-run Print the AI prompt only — no API call made
427
+ --json Machine-readable output (narrative, stability, scenarios)
428
+
429
+ ${bold("test options:")}
430
+ infernoflow test Run all caps that have registered scenarios
431
+ infernoflow test <cap-id> Run scenarios for a specific capability
432
+ infernoflow test --all Run every capability (including those without scenarios)
433
+ --generate Print generated ad-hoc test file without running
434
+ --bail Stop on first failure
435
+ --verbose, -v Show runner output for each scenario
436
+ --json Machine-readable output (passed/failed/skipped counts)
437
+
438
+ ${bold("ai options:")}
439
+ infernoflow ai setup Interactive wizard — pick provider, enter API key, verify
440
+ infernoflow ai status Show all providers and which are configured
441
+ infernoflow ai test [provider] Send a test prompt and verify the connection
442
+ infernoflow ai clear <provider> Remove a provider's config from integrations.json
443
+ Supported providers: anthropic openai gemini openrouter ollama
444
+
418
445
  ${bold("Machine output:")}
419
446
  ${gray("status --json")}
420
447
  ${gray("check --json")}
@@ -0,0 +1,370 @@
1
+ /**
2
+ * infernoflow ai
3
+ *
4
+ * Manage AI provider configuration for infernoflow commands
5
+ * (explain, why, review, changelog, etc.)
6
+ *
7
+ * Subcommands:
8
+ * infernoflow ai setup Interactive guided setup
9
+ * infernoflow ai status Show configured providers and which is active
10
+ * infernoflow ai test [provider] Send a test prompt and show response
11
+ * infernoflow ai clear [provider] Remove a provider's API key from config
12
+ *
13
+ * Config is stored in inferno/integrations.json (project-scoped).
14
+ * API keys can also come from environment variables (checked first).
15
+ *
16
+ * Supported providers:
17
+ * anthropic ANTHROPIC_API_KEY claude-sonnet-4-6
18
+ * openai OPENAI_API_KEY gpt-4o
19
+ * gemini GOOGLE_AI_API_KEY gemini-2.0-flash
20
+ * openrouter OPENROUTER_API_KEY (any model)
21
+ * ollama (local, no key) llama3.2
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as https from "node:https";
27
+ import * as http from "node:http";
28
+ import * as readline from "node:readline";
29
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
30
+
31
+ // ── config helpers ────────────────────────────────────────────────────────────
32
+
33
+ function infernoDir(cwd) { return path.join(cwd, "inferno"); }
34
+
35
+ function loadConfig(cwd) {
36
+ const p = path.join(infernoDir(cwd), "integrations.json");
37
+ if (!fs.existsSync(p)) return {};
38
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
39
+ }
40
+
41
+ function saveConfig(cwd, config) {
42
+ const dir = infernoDir(cwd);
43
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(path.join(dir, "integrations.json"), JSON.stringify(config, null, 2) + "\n");
45
+ }
46
+
47
+ // ── provider definitions ──────────────────────────────────────────────────────
48
+
49
+ const PROVIDERS = [
50
+ {
51
+ id: "anthropic",
52
+ name: "Anthropic (Claude)",
53
+ envKey: "ANTHROPIC_API_KEY",
54
+ models: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"],
55
+ default: "claude-sonnet-4-6",
56
+ keyHint: "sk-ant-api03-…",
57
+ docsUrl: "https://console.anthropic.com/settings/keys",
58
+ },
59
+ {
60
+ id: "openai",
61
+ name: "OpenAI (GPT)",
62
+ envKey: "OPENAI_API_KEY",
63
+ models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
64
+ default: "gpt-4o",
65
+ keyHint: "sk-…",
66
+ docsUrl: "https://platform.openai.com/api-keys",
67
+ },
68
+ {
69
+ id: "gemini",
70
+ name: "Google Gemini",
71
+ envKey: "GOOGLE_AI_API_KEY",
72
+ models: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
73
+ default: "gemini-2.0-flash",
74
+ keyHint: "AIza…",
75
+ docsUrl: "https://aistudio.google.com/app/apikey",
76
+ },
77
+ {
78
+ id: "openrouter",
79
+ name: "OpenRouter",
80
+ envKey: "OPENROUTER_API_KEY",
81
+ models: ["anthropic/claude-sonnet-4-6", "openai/gpt-4o", "meta-llama/llama-3.1-8b-instruct:free"],
82
+ default: "anthropic/claude-sonnet-4-6",
83
+ keyHint: "sk-or-…",
84
+ docsUrl: "https://openrouter.ai/keys",
85
+ },
86
+ {
87
+ id: "ollama",
88
+ name: "Ollama (local)",
89
+ envKey: null,
90
+ models: ["llama3.2", "mistral", "codellama", "phi3"],
91
+ default: "llama3.2",
92
+ keyHint: null,
93
+ docsUrl: "https://ollama.com",
94
+ },
95
+ ];
96
+
97
+ // ── HTTP probe ────────────────────────────────────────────────────────────────
98
+
99
+ function httpGet(url) {
100
+ return new Promise((resolve) => {
101
+ const parsed = new URL(url);
102
+ const lib = parsed.protocol === "https:" ? https : http;
103
+ const req = lib.request({ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), path: parsed.pathname + (parsed.search || ""), method: "GET", timeout: 5000 }, (res) => {
104
+ let raw = "";
105
+ res.on("data", d => (raw += d));
106
+ res.on("end", () => { try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode, body: raw }); } });
107
+ });
108
+ req.on("error", () => resolve(null));
109
+ req.on("timeout", () => { req.destroy(); resolve(null); });
110
+ req.end();
111
+ });
112
+ }
113
+
114
+ // ── provider status check ─────────────────────────────────────────────────────
115
+
116
+ async function checkProviderStatus(providerId, config) {
117
+ const envMap = {
118
+ anthropic: process.env.ANTHROPIC_API_KEY,
119
+ openai: process.env.OPENAI_API_KEY,
120
+ gemini: process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY,
121
+ openrouter: process.env.OPENROUTER_API_KEY,
122
+ };
123
+
124
+ const fromEnv = envMap[providerId];
125
+ const fromConfig = config[providerId]?.apiKey;
126
+ const key = fromEnv || fromConfig;
127
+ const source = fromEnv ? "env" : fromConfig ? "integrations.json" : null;
128
+ const model = config[providerId]?.model || PROVIDERS.find(p => p.id === providerId)?.default;
129
+
130
+ if (providerId === "ollama") {
131
+ // Check if Ollama is running
132
+ const probe = await httpGet("http://localhost:11434/api/tags").catch(() => null);
133
+ if (probe?.status === 200) {
134
+ const models = probe.body?.models?.map(m => m.name) || [];
135
+ return { configured: true, source: "local", model: config.ollama?.model || "llama3.2", available: true, models };
136
+ }
137
+ return { configured: false, source: null, model: null, available: false };
138
+ }
139
+
140
+ return { configured: !!key, source, model, available: null, masked: key ? key.slice(0, 8) + "…" : null };
141
+ }
142
+
143
+ // ── ai test prompt ────────────────────────────────────────────────────────────
144
+
145
+ async function testProvider(providerId, config, cwd) {
146
+ try {
147
+ const { callAI } = await import("../ai/providerRouter.mjs");
148
+ const testPrompt = `Reply with exactly: "infernoflow AI test OK — ${providerId}"`;
149
+ const result = await callAI(testPrompt, cwd, providerId);
150
+ return result;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ // ── readline helper ───────────────────────────────────────────────────────────
157
+
158
+ function prompt(rl, question) {
159
+ return new Promise(resolve => rl.question(question, resolve));
160
+ }
161
+
162
+ // ── subcommands ───────────────────────────────────────────────────────────────
163
+
164
+ async function cmdStatus(cwd) {
165
+ const config = loadConfig(cwd);
166
+
167
+ console.log();
168
+ console.log(` ${bold("infernoflow ai")} ${gray("— provider status")}`);
169
+ console.log();
170
+
171
+ let anyConfigured = false;
172
+
173
+ for (const p of PROVIDERS) {
174
+ const status = await checkProviderStatus(p.id, config);
175
+ if (status.configured) anyConfigured = true;
176
+
177
+ const icon = status.configured ? green("✓") : gray("○");
178
+ const label = bold(p.name.padEnd(22));
179
+ const info = status.configured
180
+ ? `${green("configured")} ${gray(status.source)} ${gray("model: " + status.model)}${status.masked ? " " + gray(status.masked) : ""}`
181
+ : gray("not configured");
182
+ console.log(` ${icon} ${label} ${info}`);
183
+ }
184
+
185
+ console.log();
186
+
187
+ if (!anyConfigured) {
188
+ console.log(` ${yellow("No AI providers configured.")} Run: ${cyan("infernoflow ai setup")}`);
189
+ console.log(` ${gray("Without a provider, explain/why/review use structural fallbacks.")}`);
190
+ } else {
191
+ console.log(` ${gray("Run")} ${cyan("infernoflow ai test")} ${gray("to verify the active provider.")}`);
192
+ }
193
+ console.log();
194
+ }
195
+
196
+ async function cmdSetup(cwd) {
197
+ const config = loadConfig(cwd);
198
+
199
+ console.log();
200
+ console.log(` ${bold("🔥 infernoflow ai setup")}`);
201
+ console.log(` ${gray("Connect an AI provider for explain, why, review, and changelog.")}`);
202
+ console.log();
203
+ console.log(` Providers: ${PROVIDERS.map(p => bold(p.id)).join(" ")}`);
204
+ console.log();
205
+
206
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
207
+
208
+ try {
209
+ // Pick provider
210
+ const providerInput = await prompt(rl, ` Provider [anthropic]: `);
211
+ const providerId = providerInput.trim().toLowerCase() || "anthropic";
212
+ const provider = PROVIDERS.find(p => p.id === providerId);
213
+
214
+ if (!provider) {
215
+ console.log(red(` Unknown provider "${providerId}". Options: ${PROVIDERS.map(p => p.id).join(", ")}`));
216
+ return;
217
+ }
218
+
219
+ console.log();
220
+ console.log(` ${bold(provider.name)}`);
221
+
222
+ if (provider.docsUrl) {
223
+ console.log(` ${gray("Get API key:")} ${cyan(provider.docsUrl)}`);
224
+ }
225
+
226
+ if (providerId === "ollama") {
227
+ // Ollama — no key, just model + host
228
+ const hostInput = await prompt(rl, ` Ollama host [http://localhost:11434]: `);
229
+ const modelInput = await prompt(rl, ` Model [${provider.default}]: `);
230
+
231
+ config.ollama = {
232
+ host: hostInput.trim() || "http://localhost:11434",
233
+ model: modelInput.trim() || provider.default,
234
+ };
235
+ saveConfig(cwd, config);
236
+
237
+ console.log();
238
+ console.log(` ${green("✓")} Ollama configured. Testing connection…`);
239
+ const probe = await httpGet(`${config.ollama.host}/api/tags`).catch(() => null);
240
+ if (probe?.status === 200) {
241
+ console.log(` ${green("✓")} Ollama is running. You're all set.`);
242
+ } else {
243
+ console.log(` ${yellow("⚠")} Could not reach Ollama. Make sure it's running: ${cyan("ollama serve")}`);
244
+ }
245
+ } else {
246
+ // API key provider
247
+ const existingKey = process.env[provider.envKey] || config[providerId]?.apiKey;
248
+ const keyHint = existingKey ? `[${existingKey.slice(0, 8)}… (existing)] ` : `[${provider.keyHint}] `;
249
+ const keyInput = await prompt(rl, ` API key ${keyHint}: `);
250
+ const apiKey = keyInput.trim() || existingKey;
251
+
252
+ if (!apiKey) {
253
+ console.log(red(" No API key provided. Exiting."));
254
+ return;
255
+ }
256
+
257
+ const modelInput = await prompt(rl, ` Model [${provider.default}]: `);
258
+ const model = modelInput.trim() || provider.default;
259
+
260
+ config[providerId] = { apiKey, model };
261
+ saveConfig(cwd, config);
262
+
263
+ console.log();
264
+ console.log(` ${green("✓")} Saved to inferno/integrations.json`);
265
+ console.log();
266
+ process.stdout.write(` Testing connection… `);
267
+
268
+ const result = await testProvider(providerId, config, cwd);
269
+ if (result?.text) {
270
+ console.log(green("OK"));
271
+ console.log(` ${gray(result.text.trim().slice(0, 80))}`);
272
+ } else {
273
+ console.log(yellow("no response"));
274
+ console.log(` ${yellow("⚠")} Could not get a test response. Check your API key.`);
275
+ }
276
+ }
277
+
278
+ console.log();
279
+ console.log(` ${green("✓")} ${bold(provider.name)} is ready.`);
280
+ console.log(` ${gray("Commands that now use AI:")} explain why review changelog`);
281
+ console.log();
282
+
283
+ // gitignore reminder
284
+ const gitignorePath = path.join(cwd, ".gitignore");
285
+ if (fs.existsSync(gitignorePath)) {
286
+ const content = fs.readFileSync(gitignorePath, "utf8");
287
+ if (!content.includes("integrations.json")) {
288
+ console.log(` ${yellow("⚠")} Your API key is in inferno/integrations.json.`);
289
+ console.log(` ${gray("Add")} ${cyan("inferno/integrations.json")} ${gray("to .gitignore to avoid committing it.")}`);
290
+ console.log();
291
+ }
292
+ }
293
+
294
+ } finally {
295
+ rl.close();
296
+ }
297
+ }
298
+
299
+ async function cmdTest(args, cwd) {
300
+ const config = loadConfig(cwd);
301
+ const providerId = args.find(a => !a.startsWith("--")) || null;
302
+
303
+ console.log();
304
+ console.log(` ${bold("infernoflow ai test")}`);
305
+ console.log();
306
+
307
+ const toTest = providerId
308
+ ? PROVIDERS.filter(p => p.id === providerId)
309
+ : PROVIDERS;
310
+
311
+ for (const p of toTest) {
312
+ const status = await checkProviderStatus(p.id, config);
313
+ if (!status.configured) {
314
+ console.log(` ${gray("○")} ${bold(p.name.padEnd(22))} ${gray("not configured — skipping")}`);
315
+ continue;
316
+ }
317
+
318
+ process.stdout.write(` ${yellow("…")} ${bold(p.name.padEnd(22))} testing… `);
319
+ const result = await testProvider(p.id, config, cwd);
320
+ if (result?.text) {
321
+ console.log(green("OK") + gray(` (${result.model || p.id})`));
322
+ console.log(` ${gray(result.text.trim().slice(0, 80))}`);
323
+ } else {
324
+ console.log(red("FAIL"));
325
+ console.log(` ${red("No response — check API key or model name")}`);
326
+ }
327
+ }
328
+
329
+ console.log();
330
+ }
331
+
332
+ async function cmdClear(args, cwd) {
333
+ const config = loadConfig(cwd);
334
+ const providerId = args.find(a => !a.startsWith("--"));
335
+
336
+ if (!providerId) {
337
+ console.error(red("✗ Usage: infernoflow ai clear <provider>"));
338
+ console.error(gray(" Example: infernoflow ai clear openai"));
339
+ process.exit(1);
340
+ }
341
+
342
+ if (!config[providerId]) {
343
+ console.log(gray(` No config found for "${providerId}"`));
344
+ return;
345
+ }
346
+
347
+ delete config[providerId];
348
+ saveConfig(cwd, config);
349
+ console.log(green(` ✓ Cleared config for ${providerId}`));
350
+ }
351
+
352
+ // ── entry point ───────────────────────────────────────────────────────────────
353
+
354
+ export async function aiCommand(rawArgs) {
355
+ const args = (rawArgs || []).slice(1);
356
+ const sub = args.find(a => !a.startsWith("--")) || "status";
357
+ const subArgs = args.filter(a => a !== sub);
358
+ const cwd = process.cwd();
359
+
360
+ switch (sub) {
361
+ case "setup": return cmdSetup(cwd);
362
+ case "status": return cmdStatus(cwd);
363
+ case "test": return cmdTest(subArgs, cwd);
364
+ case "clear": return cmdClear(subArgs, cwd);
365
+ default:
366
+ console.error(red(`✗ Unknown subcommand: "${sub}"`));
367
+ console.error(gray(" Usage: infernoflow ai <setup|status|test|clear>"));
368
+ process.exit(1);
369
+ }
370
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * infernoflow explain
3
+ *
4
+ * AI-generated narrative about a capability — what it does, why it exists,
5
+ * what's risky about it, and what to test before changing it.
6
+ *
7
+ * Synthesises: stability level, git history, scenarios, callers, services,
8
+ * source files — then calls the AI provider for a 3-5 sentence human narrative.
9
+ *
10
+ * Usage:
11
+ * infernoflow explain user-auth
12
+ * infernoflow explain payment-process --dry-run (print prompt only)
13
+ * infernoflow explain user-auth --json
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 loadJson(p) {
24
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
25
+ catch { return null; }
26
+ }
27
+
28
+ function runGit(cmd, cwd) {
29
+ try {
30
+ return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe","pipe","pipe"] }).trim();
31
+ } catch { return ""; }
32
+ }
33
+
34
+ const LEVEL_ICON = { frozen: "🧊", stable: "〰️ ", experimental: "🌊" };
35
+ const LEVEL_COLOR = { frozen: red, stable: yellow, experimental: green };
36
+
37
+ function stability(cap) { return cap?.stability || "experimental"; }
38
+
39
+ // ── git helpers ───────────────────────────────────────────────────────────────
40
+
41
+ function getFirstCommit(filePath, cwd) {
42
+ if (!filePath) return null;
43
+ const rel = path.relative(cwd, path.resolve(cwd, filePath));
44
+ const log = runGit(
45
+ `git log --follow --format="%h|%aI|%ae|%s" -- ${JSON.stringify(rel)}`, cwd
46
+ );
47
+ if (!log) return null;
48
+ const lines = log.split("\n").filter(Boolean);
49
+ if (!lines.length) return null;
50
+ const [hash, date, author, ...subjectParts] = lines[lines.length - 1].split("|");
51
+ return {
52
+ hash: hash?.trim(),
53
+ date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
54
+ author: author?.trim(),
55
+ subject: subjectParts.join("|").trim(),
56
+ };
57
+ }
58
+
59
+ function getRecentHistory(filePath, cwd, limit = 5) {
60
+ if (!filePath) return [];
61
+ const rel = path.relative(cwd, path.resolve(cwd, filePath));
62
+ const log = runGit(
63
+ `git log --follow --format="%h|%aI|%ae|%s" -${limit} -- ${JSON.stringify(rel)}`, cwd
64
+ );
65
+ if (!log) return [];
66
+ return log.split("\n").filter(Boolean).map(line => {
67
+ const [hash, date, author, ...subjectParts] = line.split("|");
68
+ return {
69
+ hash: hash?.trim(),
70
+ date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
71
+ author: author?.trim(),
72
+ subject: subjectParts.join("|").trim(),
73
+ };
74
+ });
75
+ }
76
+
77
+ // ── scenario finder ───────────────────────────────────────────────────────────
78
+
79
+ function findScenarios(capId, infernoDir) {
80
+ const dir = path.join(infernoDir, "scenarios");
81
+ if (!fs.existsSync(dir)) return [];
82
+ const found = [];
83
+ for (const f of fs.readdirSync(dir)) {
84
+ if (!f.endsWith(".json")) continue;
85
+ try {
86
+ const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
87
+ const covered = s.capabilitiesCovered || s.capabilities || [];
88
+ if (covered.some(c => c.toLowerCase() === capId.toLowerCase())) {
89
+ found.push(s);
90
+ }
91
+ } catch {}
92
+ }
93
+ return found;
94
+ }
95
+
96
+ // ── prompt builder ────────────────────────────────────────────────────────────
97
+
98
+ function buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory) {
99
+ const level = stability(cap);
100
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
101
+ const functions = scanEntry?.codeAnalysis?.functions || [];
102
+ const services = scanEntry?.codeAnalysis?.services || [];
103
+ const throws_ = scanEntry?.codeAnalysis?.throws || [];
104
+ const calls = scanEntry?.codeAnalysis?.calls || [];
105
+ const deps = graph?.deps?.[capId] || [];
106
+ const dependents = graph?.dependents?.[capId] || [];
107
+
108
+ const lines = [
109
+ `You are a senior engineer writing a brief, plain-English explanation of a software capability for a teammate who is about to modify it.`,
110
+ ``,
111
+ `Write 3–5 sentences covering:`,
112
+ ` 1. What this capability does and why it exists`,
113
+ ` 2. The most important thing to know before changing it (stability, callers, risk)`,
114
+ ` 3. What to test or verify after any modification`,
115
+ ``,
116
+ `Be concrete and direct. Do not use bullet points. Do not repeat the capability ID verbatim in every sentence.`,
117
+ ``,
118
+ `=== Capability: ${capId} ===`,
119
+ `Name: ${cap.name || cap.title || capId}`,
120
+ `Description: ${cap.description || "(none provided)"}`,
121
+ `Stability: ${level}`,
122
+ ];
123
+
124
+ if (files.length) lines.push(`Source files: ${files.join(", ")}`);
125
+ if (functions.length) lines.push(`Functions: ${functions.join(", ")}`);
126
+ if (services.length) lines.push(`External services used: ${services.join(", ")}`);
127
+ if (throws_.length) lines.push(`Can throw: ${throws_.join(", ")}`);
128
+ if (calls.length) lines.push(`Internal calls: ${calls.join(", ")}`);
129
+
130
+ if (deps.length) {
131
+ const depDetails = deps.map(d => {
132
+ const dc = allCaps.find(c => c.id === d);
133
+ return `${d} (${stability(dc)})`;
134
+ });
135
+ lines.push(`Calls capabilities: ${depDetails.join(", ")}`);
136
+ }
137
+
138
+ if (dependents.length) {
139
+ const depDetails = dependents.map(d => {
140
+ const dc = allCaps.find(c => c.id === d);
141
+ return `${d} (${stability(dc)})`;
142
+ });
143
+ lines.push(`Called by capabilities: ${depDetails.join(", ")}`);
144
+ }
145
+
146
+ if (scenarios.length) {
147
+ lines.push(`Test scenarios: ${scenarios.map(s => s.scenarioId || s.description || "unnamed").join(", ")}`);
148
+ } else {
149
+ lines.push(`Test scenarios: none registered`);
150
+ }
151
+
152
+ if (firstCommit) {
153
+ lines.push(`First introduced: ${firstCommit.date} by ${firstCommit.author} — "${firstCommit.subject}"`);
154
+ }
155
+
156
+ if (recentHistory.length) {
157
+ lines.push(`Recent changes:`);
158
+ for (const h of recentHistory.slice(0, 3)) {
159
+ lines.push(` ${h.date} — ${h.subject}`);
160
+ }
161
+ }
162
+
163
+ if (level === "frozen") {
164
+ lines.push(`IMPORTANT: This capability is FROZEN. Any modification requires explicit approval.`);
165
+ } else if (level === "stable") {
166
+ lines.push(`NOTE: This capability is STABLE. Prefer additive changes; avoid breaking the public API.`);
167
+ }
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ // ── AI caller ─────────────────────────────────────────────────────────────────
173
+
174
+ async function callAI(prompt, cwd) {
175
+ try {
176
+ const { callAI: call } = await import("../ai/providerRouter.mjs");
177
+ return await call(prompt, cwd);
178
+ } catch {
179
+ // Provider not available — return a structured fallback
180
+ return null;
181
+ }
182
+ }
183
+
184
+ // ── fallback narrative (no AI) ────────────────────────────────────────────────
185
+
186
+ function buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios) {
187
+ const level = stability(cap);
188
+ const name = cap.name || cap.title || capId;
189
+ const services = scanEntry?.codeAnalysis?.services || [];
190
+ const dependents = graph?.dependents?.[capId] || [];
191
+ const deps = graph?.deps?.[capId] || [];
192
+
193
+ const parts = [];
194
+
195
+ // What it does
196
+ if (cap.description && cap.description !== "(none provided)") {
197
+ parts.push(`${name} — ${cap.description}.`);
198
+ } else {
199
+ parts.push(`${name} handles the ${capId} flow within this system.`);
200
+ }
201
+
202
+ // External services
203
+ if (services.length) {
204
+ parts.push(`It integrates with ${services.join(" and ")}.`);
205
+ }
206
+
207
+ // Dependencies
208
+ if (deps.length) {
209
+ parts.push(`It depends on: ${deps.join(", ")}.`);
210
+ }
211
+ if (dependents.length) {
212
+ const frozenDeps = dependents.filter(d => stability(allCaps.find(c => c.id === d)) === "frozen");
213
+ if (frozenDeps.length) {
214
+ parts.push(`⚠️ ${frozenDeps.join(", ")} depend${frozenDeps.length === 1 ? "s" : ""} on this — changing it may break frozen capabilities.`);
215
+ } else {
216
+ parts.push(`${dependents.join(", ")} depend${dependents.length === 1 ? "s" : ""} on this capability.`);
217
+ }
218
+ }
219
+
220
+ // Stability advice
221
+ if (level === "frozen") {
222
+ parts.push(`This capability is FROZEN — do not modify without explicit instruction.`);
223
+ } else if (level === "stable") {
224
+ parts.push(`This capability is stable — prefer additive changes and avoid breaking the existing API surface.`);
225
+ } else {
226
+ parts.push(`This capability is experimental — free to refactor as needed.`);
227
+ }
228
+
229
+ // Test advice
230
+ if (scenarios.length) {
231
+ parts.push(`Before shipping changes, run the registered scenarios: ${scenarios.map(s => s.scenarioId || "unnamed").join(", ")}.`);
232
+ } else {
233
+ parts.push(`No test scenarios are registered — consider adding one before making changes.`);
234
+ }
235
+
236
+ return parts.join(" ");
237
+ }
238
+
239
+ // ── printer ───────────────────────────────────────────────────────────────────
240
+
241
+ function printExplain(capId, cap, narrative, provider, dryRun) {
242
+ const level = stability(cap);
243
+ const icon = LEVEL_ICON[level] || "🌊";
244
+ const color = LEVEL_COLOR[level] || green;
245
+
246
+ console.log();
247
+ console.log(bold(` ${icon} ${color(capId)}`));
248
+ if (cap.name || cap.title) console.log(gray(` ${cap.name || cap.title}`));
249
+ console.log();
250
+
251
+ if (dryRun) {
252
+ console.log(yellow(" [dry-run] Prompt only — no AI call made"));
253
+ console.log();
254
+ return;
255
+ }
256
+
257
+ // Word-wrap narrative at ~80 chars
258
+ const words = narrative.split(" ");
259
+ let line = " ";
260
+ const lines = [];
261
+ for (const word of words) {
262
+ if (line.length + word.length > 82) { lines.push(line); line = " " + word; }
263
+ else line += (line === " " ? "" : " ") + word;
264
+ }
265
+ if (line.trim()) lines.push(line);
266
+
267
+ for (const l of lines) console.log(l);
268
+ console.log();
269
+
270
+ if (provider) {
271
+ console.log(gray(` ── via ${provider}`));
272
+ } else {
273
+ console.log(gray(" ── (AI provider not configured — showing structural summary)"));
274
+ console.log(gray(" Run: infernoflow setup to connect an AI provider"));
275
+ }
276
+ console.log();
277
+ }
278
+
279
+ // ── entry point ───────────────────────────────────────────────────────────────
280
+
281
+ export async function explainCommand(rawArgs) {
282
+ const args = (rawArgs || []).slice(1);
283
+ const dryRun = args.includes("--dry-run");
284
+ const jsonMode = args.includes("--json");
285
+
286
+ const capId = args.find(a => !a.startsWith("--"));
287
+
288
+ if (!capId) {
289
+ console.error(red("✗ Usage: infernoflow explain <capability-id> [--dry-run] [--json]"));
290
+ console.error(gray(" Example: infernoflow explain user-auth"));
291
+ process.exit(1);
292
+ }
293
+
294
+ const cwd = process.cwd();
295
+ const infernoDir = path.join(cwd, "inferno");
296
+
297
+ // Load capabilities
298
+ let allCaps = [];
299
+ const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
300
+ if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
301
+
302
+ const cap = allCaps.find(c => c.id === capId);
303
+ if (!cap) {
304
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
305
+ console.error(gray(" Run: infernoflow stability — to list all capability IDs"));
306
+ process.exit(1);
307
+ }
308
+
309
+ // Load scan + graph
310
+ const scanData = loadJson(path.join(infernoDir, "scan.json"));
311
+ const graph = loadJson(path.join(infernoDir, "graph.json"));
312
+ const scanEntry = scanData?.capabilities?.find(c => c.id === capId);
313
+
314
+ // Git history
315
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
316
+ const firstCommit = getFirstCommit(files[0], cwd);
317
+ const recentHistory = getRecentHistory(files[0], cwd);
318
+
319
+ // Scenarios
320
+ const scenarios = findScenarios(capId, infernoDir);
321
+
322
+ // Build prompt
323
+ const prompt = buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory);
324
+
325
+ if (dryRun && !jsonMode) {
326
+ console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
327
+ console.log(gray(" ──────────────────────────────────────────────────────────────"));
328
+ printExplain(capId, cap, "", null, true);
329
+ console.log(bold(" Prompt that would be sent to AI:"));
330
+ console.log();
331
+ console.log(prompt.split("\n").map(l => " " + l).join("\n"));
332
+ console.log();
333
+ return;
334
+ }
335
+
336
+ // Call AI
337
+ let narrative = null;
338
+ let provider = null;
339
+
340
+ if (!dryRun) {
341
+ try {
342
+ const result = await callAI(prompt, cwd);
343
+ if (result?.text) {
344
+ narrative = result.text.trim();
345
+ provider = result.provider;
346
+ }
347
+ } catch {}
348
+ }
349
+
350
+ // Fallback if no AI
351
+ if (!narrative) {
352
+ narrative = buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios);
353
+ provider = null;
354
+ }
355
+
356
+ if (jsonMode) {
357
+ console.log(JSON.stringify({
358
+ capId,
359
+ name: cap.name || cap.title,
360
+ stability: stability(cap),
361
+ narrative,
362
+ provider: provider || "fallback",
363
+ sourceFiles: files,
364
+ scenarios: scenarios.map(s => s.scenarioId || s.description),
365
+ firstCommit,
366
+ }, null, 2));
367
+ return;
368
+ }
369
+
370
+ console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
371
+ console.log(gray(" ──────────────────────────────────────────────────────────────"));
372
+ printExplain(capId, cap, narrative, provider, false);
373
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * infernoflow test
3
+ *
4
+ * Run registered scenarios for a capability and report pass/fail.
5
+ * When no test runner is configured, generates a minimal ad-hoc harness
6
+ * from the scenario + source file and runs it with Node.
7
+ *
8
+ * Usage:
9
+ * infernoflow test Run all caps with scenarios
10
+ * infernoflow test user-auth Run scenarios for one cap
11
+ * infernoflow test user-auth payment-process Run multiple caps
12
+ * infernoflow test --all Run every cap (including no-scenario)
13
+ * infernoflow test --generate user-auth Print generated test file, don't run
14
+ * infernoflow test --json Machine-readable output
15
+ * infernoflow test --bail Stop on first failure
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import * as os from "node:os";
21
+ import { execSync, spawnSync } from "node:child_process";
22
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
23
+
24
+ // ── helpers ───────────────────────────────────────────────────────────────────
25
+
26
+ function loadJson(p) {
27
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); }
28
+ catch { return null; }
29
+ }
30
+
31
+ function stability(cap) { return cap?.stability || "experimental"; }
32
+
33
+ const PASS_ICON = green("✓");
34
+ const FAIL_ICON = red("✗");
35
+ const SKIP_ICON = gray("○");
36
+
37
+ // ── scenario loader ───────────────────────────────────────────────────────────
38
+
39
+ function loadScenarios(capId, infernoDir) {
40
+ const dir = path.join(infernoDir, "scenarios");
41
+ if (!fs.existsSync(dir)) return [];
42
+ const found = [];
43
+ for (const f of fs.readdirSync(dir)) {
44
+ if (!f.endsWith(".json")) continue;
45
+ try {
46
+ const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
47
+ const covered = s.capabilitiesCovered || s.capabilities || [];
48
+ if (covered.some(c => c.toLowerCase() === capId.toLowerCase())) {
49
+ found.push({ ...s, _file: f });
50
+ }
51
+ } catch {}
52
+ }
53
+ return found;
54
+ }
55
+
56
+ // ── test runner detection ─────────────────────────────────────────────────────
57
+
58
+ function detectTestRunner(cwd) {
59
+ const pkg = loadJson(path.join(cwd, "package.json"));
60
+ if (!pkg) return null;
61
+
62
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
63
+ if (deps?.vitest) return "vitest";
64
+ if (deps?.jest) return "jest";
65
+ if (deps?.mocha) return "mocha";
66
+ if (pkg.scripts?.test && !pkg.scripts.test.includes("no test")) {
67
+ return { custom: pkg.scripts.test };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // ── ad-hoc test generator ─────────────────────────────────────────────────────
73
+
74
+ function generateAdHocTest(capId, cap, scenario, scanEntry) {
75
+ const name = cap.name || cap.title || capId;
76
+ const desc = cap.description || "(no description)";
77
+ const files = scanEntry?.codeAnalysis?.sourceFiles || [];
78
+ const fns = scanEntry?.codeAnalysis?.functions || [];
79
+ const steps = scenario?.steps || scenario?.actions || [];
80
+ const expects = scenario?.expects || scenario?.assertions || [];
81
+
82
+ const lines = [
83
+ `// Auto-generated smoke test for: ${capId}`,
84
+ `// Generated by infernoflow test — edit as needed`,
85
+ ``,
86
+ `import { strict as assert } from "node:assert";`,
87
+ ``,
88
+ `// Capability: ${name}`,
89
+ `// ${desc}`,
90
+ ``,
91
+ ];
92
+
93
+ // Import source if we know the file
94
+ if (files.length) {
95
+ lines.push(`// Source: ${files[0]}`);
96
+ lines.push(`// import { ${fns[0] || capId} } from "./${path.basename(files[0])}";`);
97
+ lines.push(``);
98
+ }
99
+
100
+ lines.push(`async function run() {`);
101
+ lines.push(` const results = [];`);
102
+ lines.push(``);
103
+
104
+ // Generate test cases from scenario steps
105
+ if (steps.length) {
106
+ steps.forEach((step, i) => {
107
+ const desc = typeof step === "string" ? step : (step.action || step.description || `step ${i + 1}`);
108
+ lines.push(` // Step ${i + 1}: ${desc}`);
109
+ lines.push(` results.push({ step: ${JSON.stringify(desc)}, status: "manual" });`);
110
+ lines.push(``);
111
+ });
112
+ } else {
113
+ lines.push(` // No explicit steps — running basic smoke test`);
114
+ lines.push(` results.push({ step: "capability exists", status: "pass" });`);
115
+ lines.push(``);
116
+ }
117
+
118
+ // Add assertion checks
119
+ if (expects.length) {
120
+ expects.forEach((exp, i) => {
121
+ const desc = typeof exp === "string" ? exp : (exp.condition || exp.description || `assertion ${i + 1}`);
122
+ lines.push(` // Assert: ${desc}`);
123
+ lines.push(` // assert(condition, ${JSON.stringify(desc)});`);
124
+ });
125
+ lines.push(``);
126
+ }
127
+
128
+ lines.push(` return results;`);
129
+ lines.push(`}`);
130
+ lines.push(``);
131
+ lines.push(`run().then(results => {`);
132
+ lines.push(` const failed = results.filter(r => r.status === "fail");`);
133
+ lines.push(` results.forEach(r => {`);
134
+ lines.push(` const icon = r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : "○";`);
135
+ lines.push(` console.log(\` \${icon} \${r.step}\`);`);
136
+ lines.push(` });`);
137
+ lines.push(` if (failed.length) { console.error(\`\\n \${failed.length} failed\`); process.exit(1); }`);
138
+ lines.push(` else console.log(\`\\n All steps passed\`);`);
139
+ lines.push(`}).catch(err => { console.error(err); process.exit(1); });`);
140
+
141
+ return lines.join("\n");
142
+ }
143
+
144
+ // ── run a single scenario ─────────────────────────────────────────────────────
145
+
146
+ function runScenario(capId, cap, scenario, scanEntry, cwd) {
147
+ const scenarioId = scenario?.scenarioId || scenario?.id || scenario?._file?.replace(".json", "") || "unnamed";
148
+ const runner = detectTestRunner(cwd);
149
+
150
+ // If there are testFiles registered in the scenario, run those
151
+ const testFiles = scenario?.testFiles || scenario?.testFile ? [scenario.testFile].flat().filter(Boolean) : [];
152
+ if (testFiles.length) {
153
+ for (const tf of testFiles) {
154
+ const absPath = path.resolve(cwd, tf);
155
+ if (!fs.existsSync(absPath)) {
156
+ return { scenarioId, status: "skip", reason: `test file not found: ${tf}` };
157
+ }
158
+ }
159
+
160
+ // Run with detected test runner
161
+ if (runner && typeof runner === "string") {
162
+ const cmd = runner === "vitest"
163
+ ? `npx vitest run ${testFiles.join(" ")} --reporter verbose`
164
+ : runner === "jest"
165
+ ? `npx jest ${testFiles.join(" ")} --no-coverage`
166
+ : runner === "mocha"
167
+ ? `npx mocha ${testFiles.join(" ")}`
168
+ : null;
169
+
170
+ if (cmd) {
171
+ const result = spawnSync(cmd, { shell: true, cwd, encoding: "utf8", timeout: 60_000 });
172
+ const passed = result.status === 0;
173
+ return {
174
+ scenarioId,
175
+ status: passed ? "pass" : "fail",
176
+ output: (result.stdout || "") + (result.stderr || ""),
177
+ runner,
178
+ };
179
+ }
180
+ }
181
+
182
+ // Custom script runner
183
+ if (runner?.custom) {
184
+ const result = spawnSync(runner.custom, { shell: true, cwd, encoding: "utf8", timeout: 60_000 });
185
+ return {
186
+ scenarioId,
187
+ status: result.status === 0 ? "pass" : "fail",
188
+ output: (result.stdout || "") + (result.stderr || ""),
189
+ runner: "npm test",
190
+ };
191
+ }
192
+ }
193
+
194
+ // No test files — generate and run ad-hoc test
195
+ const testSrc = generateAdHocTest(capId, cap, scenario, scanEntry);
196
+ const tmpFile = path.join(os.tmpdir(), `infernoflow-test-${capId}-${Date.now()}.mjs`);
197
+
198
+ try {
199
+ fs.writeFileSync(tmpFile, testSrc);
200
+ const result = spawnSync(process.execPath, ["--input-type=module", tmpFile], {
201
+ cwd,
202
+ encoding: "utf8",
203
+ timeout: 30_000,
204
+ });
205
+ const passed = result.status === 0;
206
+ return {
207
+ scenarioId,
208
+ status: passed ? "pass" : "fail",
209
+ output: (result.stdout || "") + (result.stderr || ""),
210
+ runner: "ad-hoc",
211
+ generated: true,
212
+ };
213
+ } finally {
214
+ try { fs.unlinkSync(tmpFile); } catch {}
215
+ }
216
+ }
217
+
218
+ // ── print result ──────────────────────────────────────────────────────────────
219
+
220
+ function printCapResult(capId, cap, results, verbose) {
221
+ const level = stability(cap);
222
+ const badge = level === "frozen" ? red("frozen") : level === "stable" ? yellow("stable") : gray("experimental");
223
+ const total = results.length;
224
+ const passed = results.filter(r => r.status === "pass").length;
225
+ const failed = results.filter(r => r.status === "fail").length;
226
+ const skipped = results.filter(r => r.status === "skip").length;
227
+
228
+ const statusIcon = failed > 0 ? red("✗") : passed > 0 ? green("✓") : gray("○");
229
+ console.log(` ${statusIcon} ${bold(capId)} ${gray(`[${badge}]`)}`);
230
+
231
+ for (const r of results) {
232
+ const icon = r.status === "pass" ? PASS_ICON : r.status === "fail" ? FAIL_ICON : SKIP_ICON;
233
+ const note = r.generated ? gray(" (generated)") : r.runner ? gray(` (${r.runner})`) : "";
234
+ console.log(` ${icon} ${gray(r.scenarioId)}${note}`);
235
+ if (r.reason) console.log(` ${gray(r.reason)}`);
236
+ if (verbose && r.output) {
237
+ const trimmed = r.output.trim().split("\n").slice(0, 10).join("\n");
238
+ console.log(trimmed.split("\n").map(l => ` ${gray(l)}`).join("\n"));
239
+ }
240
+ }
241
+
242
+ return { total, passed, failed, skipped };
243
+ }
244
+
245
+ // ── entry point ───────────────────────────────────────────────────────────────
246
+
247
+ export async function testCommand(rawArgs) {
248
+ const args = (rawArgs || []).slice(1);
249
+ const jsonMode = args.includes("--json");
250
+ const bail = args.includes("--bail");
251
+ const generateMode = args.includes("--generate");
252
+ const runAll = args.includes("--all");
253
+ const verbose = args.includes("--verbose") || args.includes("-v");
254
+
255
+ const capArgs = args.filter(a => !a.startsWith("--") && a !== "-v");
256
+
257
+ const cwd = process.cwd();
258
+ const infernoDir = path.join(cwd, "inferno");
259
+
260
+ if (!fs.existsSync(infernoDir)) {
261
+ if (!jsonMode) console.error(red("✗ inferno/ not found. Run: infernoflow init"));
262
+ process.exit(1);
263
+ }
264
+
265
+ // Load capabilities
266
+ let allCaps = [];
267
+ const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
268
+ if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
269
+
270
+ const scanData = loadJson(path.join(infernoDir, "scan.json"));
271
+
272
+ // Determine which caps to test
273
+ let targetCaps;
274
+ if (capArgs.length) {
275
+ targetCaps = capArgs.map(id => {
276
+ const cap = allCaps.find(c => c.id === id);
277
+ if (!cap) {
278
+ if (!jsonMode) console.error(red(`✗ Capability "${id}" not found in capabilities.json`));
279
+ process.exit(1);
280
+ }
281
+ return cap;
282
+ });
283
+ } else if (runAll) {
284
+ targetCaps = allCaps;
285
+ } else {
286
+ // Default: caps that have scenarios registered
287
+ targetCaps = allCaps.filter(cap => loadScenarios(cap.id, infernoDir).length > 0);
288
+ if (!targetCaps.length) {
289
+ if (!jsonMode) {
290
+ console.log();
291
+ console.log(` ${gray("No scenarios registered. Use --all to test all capabilities, or")} `);
292
+ console.log(` ${gray("add scenarios to inferno/scenarios/ first.")}`);
293
+ console.log();
294
+ }
295
+ process.exit(0);
296
+ }
297
+ }
298
+
299
+ if (!jsonMode) {
300
+ console.log();
301
+ console.log(` ${bold("🧪 infernoflow test")}`);
302
+ console.log();
303
+ }
304
+
305
+ // --generate mode: print generated test for first cap
306
+ if (generateMode) {
307
+ const cap = targetCaps[0];
308
+ const scenarios = loadScenarios(cap.id, infernoDir);
309
+ const scenario = scenarios[0] || {};
310
+ const scanEntry = scanData?.capabilities?.find(c => c.id === cap.id);
311
+ const src = generateAdHocTest(cap.id, cap, scenario, scanEntry);
312
+ console.log(src);
313
+ return;
314
+ }
315
+
316
+ // Run tests
317
+ const summary = { total: 0, passed: 0, failed: 0, skipped: 0, caps: [] };
318
+ let bailed = false;
319
+
320
+ for (const cap of targetCaps) {
321
+ const scenarios = loadScenarios(cap.id, infernoDir);
322
+ const scanEntry = scanData?.capabilities?.find(c => c.id === cap.id);
323
+
324
+ let capResults = [];
325
+
326
+ if (!scenarios.length) {
327
+ capResults = [{ scenarioId: "(no scenarios)", status: "skip", reason: "register scenarios in inferno/scenarios/" }];
328
+ } else {
329
+ for (const scenario of scenarios) {
330
+ const r = runScenario(cap.id, cap, scenario, scanEntry, cwd);
331
+ capResults.push(r);
332
+ if (bail && r.status === "fail") { bailed = true; break; }
333
+ }
334
+ }
335
+
336
+ const counts = printCapResult(cap.id, cap, capResults, verbose);
337
+ summary.total += counts.total;
338
+ summary.passed += counts.passed;
339
+ summary.failed += counts.failed;
340
+ summary.skipped += counts.skipped;
341
+ summary.caps.push({ id: cap.id, stability: stability(cap), results: capResults });
342
+
343
+ if (bailed) break;
344
+ }
345
+
346
+ if (!jsonMode) {
347
+ console.log();
348
+ const statusColor = summary.failed > 0 ? red : summary.passed > 0 ? green : gray;
349
+ console.log(` ${statusColor(bold(String(summary.passed)))} passed ${summary.failed > 0 ? red(bold(String(summary.failed))) : gray("0")} failed ${gray(String(summary.skipped))} skipped`);
350
+ if (bailed) console.log(` ${yellow("(bailed on first failure)")}`);
351
+ console.log();
352
+ if (!summary.failed) {
353
+ console.log(gray(" ── infernoflow test complete"));
354
+ }
355
+ console.log();
356
+ }
357
+
358
+ if (jsonMode) {
359
+ console.log(JSON.stringify(summary, null, 2));
360
+ }
361
+
362
+ process.exit(summary.failed > 0 ? 1 : 0);
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {