infernoflow 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/infernoflow.mjs +46 -0
- package/dist/lib/ai/providerRouter.mjs +227 -64
- package/dist/lib/commands/adoptWizard.mjs +320 -0
- package/dist/lib/commands/coverage.mjs +282 -0
- package/dist/lib/commands/doctor.mjs +284 -0
- package/dist/lib/commands/init.mjs +70 -0
- package/dist/lib/commands/review.mjs +238 -0
- package/dist/lib/commands/vibe.mjs +365 -0
- package/dist/lib/templates/index.mjs +131 -0
- package/package.json +3 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow adopt
|
|
3
|
+
*
|
|
4
|
+
* Interactive adoption wizard for existing projects.
|
|
5
|
+
* Takes a project from zero to fully wired in one guided flow.
|
|
6
|
+
*
|
|
7
|
+
* Steps:
|
|
8
|
+
* 1. Detect project type, framework, language
|
|
9
|
+
* 2. Scout capabilities in source files
|
|
10
|
+
* 3. Interactive approve / rename / skip each candidate
|
|
11
|
+
* 4. Write capabilities.json + contract.json
|
|
12
|
+
* 5. Generate starter scenarios
|
|
13
|
+
* 6. Install git hooks
|
|
14
|
+
* 7. Regenerate CONTEXT.md
|
|
15
|
+
* 8. Show health score and next steps
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* infernoflow adopt Full interactive wizard
|
|
19
|
+
* infernoflow adopt --yes Auto-approve all (non-interactive)
|
|
20
|
+
* infernoflow adopt --dir src Custom source directory
|
|
21
|
+
* infernoflow adopt --json Machine-readable, implies --yes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as readline from "node:readline";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
29
|
+
|
|
30
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function detectProject(cwd) {
|
|
33
|
+
const has = (...files) => files.some(f => fs.existsSync(path.join(cwd, f)));
|
|
34
|
+
const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), "utf8")); } catch { return {}; } };
|
|
35
|
+
|
|
36
|
+
const pkg = has("package.json") ? read("package.json") : {};
|
|
37
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
38
|
+
|
|
39
|
+
let framework = "unknown", lang = "javascript", type = "backend";
|
|
40
|
+
|
|
41
|
+
if (has("tsconfig.json")) lang = "typescript";
|
|
42
|
+
if (has("Pipfile","pyproject.toml","requirements.txt")) lang = "python";
|
|
43
|
+
if (has("Gemfile")) lang = "ruby";
|
|
44
|
+
if (has("go.mod")) lang = "go";
|
|
45
|
+
if (has("Cargo.toml")) lang = "rust";
|
|
46
|
+
|
|
47
|
+
if (deps["next"]) { framework = "nextjs"; type = "fullstack"; }
|
|
48
|
+
else if (deps["nuxt"]) { framework = "nuxt"; type = "fullstack"; }
|
|
49
|
+
else if (deps["react"]) { framework = "react"; type = "frontend"; }
|
|
50
|
+
else if (deps["vue"]) { framework = "vue"; type = "frontend"; }
|
|
51
|
+
else if (deps["@angular/core"]) { framework = "angular"; type = "frontend"; }
|
|
52
|
+
else if (deps["express"]) { framework = "express"; type = "backend"; }
|
|
53
|
+
else if (deps["fastify"]) { framework = "fastify"; type = "backend"; }
|
|
54
|
+
else if (deps["hono"]) { framework = "hono"; type = "backend"; }
|
|
55
|
+
else if (deps["@nestjs/core"]) { framework = "nestjs"; type = "backend"; }
|
|
56
|
+
else if (deps["graphql"]) { framework = "graphql"; type = "backend"; }
|
|
57
|
+
else if (has("config/routes.rb")) { framework = "rails"; type = "fullstack"; }
|
|
58
|
+
else if (has("manage.py")) { framework = "django"; type = "backend"; }
|
|
59
|
+
|
|
60
|
+
if (pkg.bin || has("bin/")) type = "cli";
|
|
61
|
+
if (has("nx.json","turbo.json","pnpm-workspace.yaml")) type = "monorepo";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: pkg.name || path.basename(cwd),
|
|
65
|
+
version: pkg.version || "0.1.0",
|
|
66
|
+
description: pkg.description || "",
|
|
67
|
+
lang, framework, type,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Scout ─────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const SRC_EXTS = new Set([".js",".mjs",".cjs",".ts",".tsx",".jsx",".py",".rb"]);
|
|
74
|
+
const SKIP_DIRS = new Set(["node_modules",".git","dist","build",".next","__pycache__","vendor","coverage","inferno"]);
|
|
75
|
+
|
|
76
|
+
const PATTERNS = [
|
|
77
|
+
{
|
|
78
|
+
regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
79
|
+
extract: m => ({ id: routeId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
|
|
83
|
+
extract: fp => {
|
|
84
|
+
const m = fp.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
const r = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
|
|
87
|
+
return { id: routeId("/" + r, "api"), hint: `Next.js API: /${r}`, confidence: 0.9 };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
regex: /export\s+(?:async\s+)?(?:function|class|const)\s+([A-Z][A-Za-z0-9]+)/g,
|
|
92
|
+
extract: m => ({ id: camelId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function routeId(r, method) {
|
|
97
|
+
const id = r.replace(/^\//, "").replace(/\/:?[^/]+/g, "").replace(/\//g, "-")
|
|
98
|
+
.replace(/[^a-zA-Z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
99
|
+
return id || method.toLowerCase() + "-root";
|
|
100
|
+
}
|
|
101
|
+
function camelId(name) {
|
|
102
|
+
return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "").replace(/-+/g, "-");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function* walk(dir) {
|
|
106
|
+
if (!fs.existsSync(dir)) return;
|
|
107
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
108
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
109
|
+
const full = path.join(dir, e.name);
|
|
110
|
+
if (e.isDirectory()) yield* walk(full);
|
|
111
|
+
else if (e.isFile() && SRC_EXTS.has(path.extname(e.name))) yield full;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scout(dirs, cwd) {
|
|
116
|
+
const seen = new Map();
|
|
117
|
+
const add = (id, r, file) => {
|
|
118
|
+
if (!seen.has(id) || r.confidence > seen.get(id).confidence)
|
|
119
|
+
seen.set(id, { ...r, source: path.relative(cwd, file) });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const dir of dirs) {
|
|
123
|
+
for (const file of walk(dir)) {
|
|
124
|
+
for (const pat of PATTERNS) {
|
|
125
|
+
if (pat.filePattern && pat.filePattern.test(file)) {
|
|
126
|
+
const r = pat.extract(file);
|
|
127
|
+
if (r && r.id.length >= 3) add(r.id, r, file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let content;
|
|
131
|
+
try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
132
|
+
for (const pat of PATTERNS) {
|
|
133
|
+
if (!pat.regex) continue;
|
|
134
|
+
pat.regex.lastIndex = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
137
|
+
const r = pat.extract(m);
|
|
138
|
+
if (r && r.id.length >= 3 && !["index","app","main","root","default","handler"].includes(r.id))
|
|
139
|
+
add(r.id, r, file);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...seen.values()].filter(c => c.confidence >= 0.5).sort((a, b) => b.confidence - a.confidence);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Interactive review ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function ask(rl, q) { return new Promise(r => rl.question(q, r)); }
|
|
150
|
+
|
|
151
|
+
async function review(candidates, autoYes) {
|
|
152
|
+
if (autoYes) return candidates.filter(c => c.confidence >= 0.6);
|
|
153
|
+
|
|
154
|
+
const approved = [];
|
|
155
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
156
|
+
|
|
157
|
+
console.log();
|
|
158
|
+
console.log(` ${bold("Review candidates")} ${cyan("y")} approve · ${yellow("e")} edit · ${red("n")} skip · ${gray("a")} approve all · ${gray("q")} stop`);
|
|
159
|
+
console.log();
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
162
|
+
const c = candidates[i];
|
|
163
|
+
const conf = `${Math.round(c.confidence * 100)}%`;
|
|
164
|
+
const col = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
` [${i + 1}/${candidates.length}] ${bold(c.id.padEnd(30))} ${col(conf)} ${gray(c.hint)}\n` +
|
|
167
|
+
` ${gray(c.source)}\n > `
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ans = (await ask(rl, "")).trim().toLowerCase();
|
|
171
|
+
if (ans === "q") break;
|
|
172
|
+
if (ans === "a") {
|
|
173
|
+
for (let j = i; j < candidates.length; j++) approved.push(candidates[j]);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (ans === "n") { console.log(` ${gray("skipped")}\n`); continue; }
|
|
177
|
+
|
|
178
|
+
let id = c.id;
|
|
179
|
+
if (ans === "e") {
|
|
180
|
+
const newId = (await ask(rl, ` New id (${c.id}): `)).trim();
|
|
181
|
+
if (newId && /^[a-z0-9-_]+$/.test(newId)) id = newId;
|
|
182
|
+
}
|
|
183
|
+
approved.push({ ...c, id });
|
|
184
|
+
console.log(` ${green("✔")} ${bold(id)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rl.close();
|
|
188
|
+
return approved;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Writers ───────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function writeFiles(infernoDir, approved, project) {
|
|
194
|
+
// contract.json
|
|
195
|
+
const cp = path.join(infernoDir, "contract.json");
|
|
196
|
+
const existing = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : {};
|
|
197
|
+
fs.writeFileSync(cp, JSON.stringify({
|
|
198
|
+
...existing,
|
|
199
|
+
policyId: existing.policyId || project.name.replace(/[^a-zA-Z0-9-]/g, "-"),
|
|
200
|
+
policyVersion: existing.policyVersion || 1,
|
|
201
|
+
capabilities: approved.map(c => c.id),
|
|
202
|
+
}, null, 2) + "\n");
|
|
203
|
+
|
|
204
|
+
// capabilities.json
|
|
205
|
+
fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
|
|
206
|
+
capabilities: approved.map(c => ({
|
|
207
|
+
id: c.id, description: c.hint,
|
|
208
|
+
since: new Date().toISOString().slice(0, 10), source: "adopt",
|
|
209
|
+
})),
|
|
210
|
+
}, null, 2) + "\n");
|
|
211
|
+
|
|
212
|
+
// Starter scenarios
|
|
213
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
214
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
215
|
+
for (const c of approved) {
|
|
216
|
+
const sp = path.join(scenDir, `${c.id}.json`);
|
|
217
|
+
if (fs.existsSync(sp)) continue;
|
|
218
|
+
fs.writeFileSync(sp, JSON.stringify({
|
|
219
|
+
id: `${c.id}-happy-path`, capability: c.id,
|
|
220
|
+
description: `Happy path for ${c.description || c.id}`,
|
|
221
|
+
steps: [{ action: "invoke", target: c.id, input: {} }, { action: "assert", field: "status", value: "success" }],
|
|
222
|
+
capabilitiesCovered: [c.id],
|
|
223
|
+
}, null, 2) + "\n");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function runCli(args, cwd) {
|
|
228
|
+
return spawnSync("infernoflow", args, {
|
|
229
|
+
cwd, encoding: "utf8", timeout: 30_000,
|
|
230
|
+
stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export async function adoptWizardCommand(rawArgs) {
|
|
237
|
+
const args = rawArgs.slice(1);
|
|
238
|
+
const autoYes = args.includes("--yes") || args.includes("-y") || args.includes("--json");
|
|
239
|
+
const jsonMode = args.includes("--json");
|
|
240
|
+
const cwd = process.cwd();
|
|
241
|
+
|
|
242
|
+
const dirIdx = args.indexOf("--dir");
|
|
243
|
+
const dirs = dirIdx !== -1
|
|
244
|
+
? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
|
|
245
|
+
: ["src","lib","app","api","pages","routes","controllers","services","handlers"]
|
|
246
|
+
.map(d => path.join(cwd, d)).filter(d => fs.existsSync(d));
|
|
247
|
+
if (!dirs.length) dirs.push(cwd);
|
|
248
|
+
|
|
249
|
+
const project = detectProject(cwd);
|
|
250
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
251
|
+
if (!fs.existsSync(infernoDir)) {
|
|
252
|
+
fs.mkdirSync(infernoDir, { recursive: true });
|
|
253
|
+
fs.mkdirSync(path.join(infernoDir, "scenarios"), { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!jsonMode) {
|
|
257
|
+
console.log();
|
|
258
|
+
console.log(` ${bold("🔥 infernoflow adopt")} ${gray("— existing project wizard")}`);
|
|
259
|
+
console.log();
|
|
260
|
+
console.log(` ${bold("Project:")} ${cyan(project.name)} v${project.version}`);
|
|
261
|
+
console.log(` ${bold("Language:")} ${project.lang} ${bold("Framework:")} ${project.framework} ${bold("Type:")} ${project.type}`);
|
|
262
|
+
console.log();
|
|
263
|
+
info(`Scanning ${dirs.map(d => path.relative(cwd, d) || ".").join(", ")} …`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const candidates = scout(dirs, cwd);
|
|
267
|
+
|
|
268
|
+
if (!candidates.length) {
|
|
269
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities detected." })); return; }
|
|
270
|
+
warn("No capabilities detected. Use --dir src,lib,api");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!jsonMode) {
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(` ${bold(`${candidates.length} capability candidates detected`)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const approved = await review(candidates, autoYes);
|
|
280
|
+
|
|
281
|
+
if (!approved.length) {
|
|
282
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities approved." })); return; }
|
|
283
|
+
warn("Nothing approved — exiting.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
writeFiles(infernoDir, approved, project);
|
|
288
|
+
runCli(["context"], cwd);
|
|
289
|
+
|
|
290
|
+
// Offer hooks in interactive mode
|
|
291
|
+
let hooksInstalled = false;
|
|
292
|
+
if (!autoYes) {
|
|
293
|
+
const rl3 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
294
|
+
const ans = (await ask(rl3, `\n Install git hooks for auto-sync? ${gray("[Y/n]")} `)).trim().toLowerCase();
|
|
295
|
+
rl3.close();
|
|
296
|
+
if (ans !== "n") { runCli(["setup", "--yes"], cwd); hooksInstalled = true; }
|
|
297
|
+
} else {
|
|
298
|
+
runCli(["setup", "--yes"], cwd); hooksInstalled = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (jsonMode) {
|
|
302
|
+
console.log(JSON.stringify({ ok: true, project, detected: candidates.length, approved: approved.length, capabilities: approved.map(c => c.id), hooksInstalled }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log();
|
|
307
|
+
done(`${bold(String(approved.length))} capabilities adopted`);
|
|
308
|
+
console.log();
|
|
309
|
+
console.log(` ${green("✔")} ${bold("inferno/contract.json")} written`);
|
|
310
|
+
console.log(` ${green("✔")} ${bold("inferno/capabilities.json")} written`);
|
|
311
|
+
console.log(` ${green("✔")} ${bold("inferno/scenarios/")} scaffolded`);
|
|
312
|
+
console.log(` ${green("✔")} ${bold("inferno/CONTEXT.md")} generated`);
|
|
313
|
+
if (hooksInstalled) console.log(` ${green("✔")} Git hooks installed`);
|
|
314
|
+
console.log();
|
|
315
|
+
console.log(` ${bold("Next steps:")}`);
|
|
316
|
+
console.log(` ${cyan("1.")} Paste ${bold("inferno/CONTEXT.md")} into Claude or Cursor`);
|
|
317
|
+
console.log(` ${cyan("2.")} Run ${bold("infernoflow vibe")} to keep everything in sync automatically`);
|
|
318
|
+
console.log(` ${cyan("3.")} Run ${bold("infernoflow health")} to see your starting score`);
|
|
319
|
+
console.log();
|
|
320
|
+
}
|
|
@@ -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
|
+
}
|