infernoflow 0.22.1 → 0.23.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.
@@ -54,6 +54,7 @@ const COMMAND_DESCRIPTIONS = {
54
54
  doctor: "Diagnose your infernoflow setup — checks Node, git, contract, AI providers, MCP, hooks",
55
55
  coverage: "Map test files to capabilities — show which caps have test coverage and which don't",
56
56
  review: "AI-powered capability impact review for staged or recent git changes",
57
+ scan: "Deep AST scan — reads actual function bodies, extracts calls, DB ops, external services",
57
58
  };
58
59
 
59
60
  const COMMAND_HANDLERS = {
@@ -101,6 +102,7 @@ const COMMAND_HANDLERS = {
101
102
  doctor: async (args) => (await import("../lib/commands/doctor.mjs")).doctorCommand(args),
102
103
  coverage: async (args) => (await import("../lib/commands/coverage.mjs")).coverageCommand(args),
103
104
  review: async (args) => (await import("../lib/commands/review.mjs")).reviewCommand(args),
105
+ scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
104
106
  };
105
107
 
106
108
  function formatCommandsHelp() {
@@ -355,6 +357,12 @@ ${formatCommandsHelp()}
355
357
  --fail-below <pct> Exit 1 if coverage percentage is below this value (CI gate)
356
358
  --json Machine-readable coverage breakdown
357
359
 
360
+ ${bold("scan options:")}
361
+ --dir <path> Extra directory to scan (repeatable)
362
+ --capability <id> Scan and enrich a single capability only
363
+ --dry-run Print results without writing files
364
+ --json Machine-readable scan output
365
+
358
366
  ${bold("review options:")}
359
367
  --unstaged Review all working-tree changes (not just staged)
360
368
  --last Review last commit (git diff HEAD~1)
@@ -0,0 +1,566 @@
1
+ /**
2
+ * infernoflow scan
3
+ *
4
+ * Deep AST-based code analysis. Reads actual function bodies — not just names.
5
+ * Extracts: external calls, DB operations, HTTP calls, auth patterns, error types,
6
+ * external service usage (Stripe, S3, SendGrid, etc.).
7
+ *
8
+ * Enriches capabilities.json with a `codeAnalysis` block on each capability,
9
+ * and saves the full scan report to inferno/scan.json.
10
+ *
11
+ * Usage:
12
+ * infernoflow scan Scan project, enrich capabilities
13
+ * infernoflow scan --dir src/ Scan specific directory
14
+ * infernoflow scan --json Print scan.json to stdout
15
+ * infernoflow scan --dry-run Print without writing files
16
+ * infernoflow scan --capability auth-login Scan one capability only
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { createRequire } from "node:module";
22
+ import { execSync } from "node:child_process";
23
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ // ── TypeScript compiler API (global install) ──────────────────────────────────
28
+
29
+ const TS_PATHS = [
30
+ "/usr/local/lib/node_modules_global/lib/node_modules/typescript",
31
+ "/usr/lib/node_modules/typescript",
32
+ path.join(process.env.HOME || "", ".npm-global/lib/node_modules/typescript"),
33
+ ];
34
+
35
+ function loadTypeScript() {
36
+ for (const p of TS_PATHS) {
37
+ try { return require(path.join(p, "lib/typescript.js")); } catch {}
38
+ }
39
+ try { return require("typescript"); } catch {}
40
+ return null;
41
+ }
42
+
43
+ const ts = loadTypeScript();
44
+
45
+ // ── external service fingerprints ────────────────────────────────────────────
46
+
47
+ const SERVICE_PATTERNS = [
48
+ { service: "stripe", patterns: ["stripe", "Stripe", "createPaymentIntent", "charges.create"] },
49
+ { service: "sendgrid", patterns: ["sendgrid", "@sendgrid", "sgMail", "sendgrid.send"] },
50
+ { service: "ses", patterns: ["SES", "ses.sendEmail", "aws-sdk/ses", "nodemailer"] },
51
+ { service: "s3", patterns: ["S3", "s3.upload", "s3.getObject", "PutObjectCommand", "@aws-sdk/s3"] },
52
+ { service: "redis", patterns: ["redis", "Redis", "ioredis", "createClient"] },
53
+ { service: "jwt", patterns: ["jwt", "jsonwebtoken", "sign(", "verify(", "decode("] },
54
+ { service: "bcrypt", patterns: ["bcrypt", "argon2", "scrypt", "hashSync", "compare("] },
55
+ { service: "prisma", patterns: ["prisma.", "PrismaClient", "@prisma/client"] },
56
+ { service: "mongoose", patterns: ["mongoose", ".save()", ".findOne(", ".aggregate("] },
57
+ { service: "postgres", patterns: ["pg", "Pool(", "Client(", "query(", "postgres("] },
58
+ { service: "mysql", patterns: ["mysql", "mysql2", "createConnection"] },
59
+ { service: "graphql", patterns: ["graphql", "gql`", "ApolloServer", "GraphQLSchema"] },
60
+ { service: "firebase", patterns: ["firebase", "firestore", "initializeApp"] },
61
+ { service: "twilio", patterns: ["twilio", "Twilio(", "messages.create"] },
62
+ { service: "openai", patterns: ["openai", "OpenAI(", "createCompletion", "chat.completions"] },
63
+ ];
64
+
65
+ function detectServices(text) {
66
+ const found = new Set();
67
+ for (const { service, patterns } of SERVICE_PATTERNS) {
68
+ if (patterns.some(p => text.includes(p))) found.add(service);
69
+ }
70
+ return [...found];
71
+ }
72
+
73
+ // ── DB call patterns ──────────────────────────────────────────────────────────
74
+
75
+ const DB_PATTERNS = [
76
+ /\.(find|findOne|findMany|findById|findAll)\s*\(/g,
77
+ /\.(create|insert|insertOne|insertMany|save)\s*\(/g,
78
+ /\.(update|updateOne|updateMany|updateById|upsert)\s*\(/g,
79
+ /\.(delete|deleteOne|deleteMany|remove|destroy)\s*\(/g,
80
+ /\.(query|execute|raw)\s*\(/g,
81
+ /\.(aggregate|groupBy|count|sum)\s*\(/g,
82
+ /db\.\w+\s*\(/g,
83
+ /prisma\.\w+\.\w+\s*\(/g,
84
+ ];
85
+
86
+ function detectDbCalls(text) {
87
+ const calls = new Set();
88
+ for (const re of DB_PATTERNS) {
89
+ const r = new RegExp(re.source, "g");
90
+ let m;
91
+ while ((m = r.exec(text)) !== null) calls.add(m[0].replace(/\s*\($/, "()"));
92
+ }
93
+ return [...calls].slice(0, 10);
94
+ }
95
+
96
+ // ── HTTP call patterns ────────────────────────────────────────────────────────
97
+
98
+ const HTTP_PATTERNS = [
99
+ /fetch\s*\(/g,
100
+ /axios\.(get|post|put|patch|delete)\s*\(/g,
101
+ /http\.(get|post|request)\s*\(/g,
102
+ /got\.(get|post|put|delete)\s*\(/g,
103
+ /request\.(get|post|put|delete)\s*\(/g,
104
+ /\$http\.(get|post|put|delete)\s*\(/g,
105
+ ];
106
+
107
+ function detectHttpCalls(text) {
108
+ const calls = new Set();
109
+ for (const re of HTTP_PATTERNS) {
110
+ const r = new RegExp(re.source, "g");
111
+ let m;
112
+ while ((m = r.exec(text)) !== null) calls.add(m[0].replace(/\s*\($/, "()"));
113
+ }
114
+ return [...calls].slice(0, 8);
115
+ }
116
+
117
+ // ── TypeScript / JavaScript AST analysis ─────────────────────────────────────
118
+
119
+ function getNodeName(node) {
120
+ if (!ts) return null;
121
+ if (node.name && ts.isIdentifier(node.name)) return node.name.text;
122
+ return null;
123
+ }
124
+
125
+ function collectCallsInNode(node, calls = new Set()) {
126
+ if (!ts) return calls;
127
+ if (ts.isCallExpression(node)) {
128
+ const expr = node.expression;
129
+ if (ts.isIdentifier(expr)) {
130
+ calls.add(expr.text + "()");
131
+ } else if (ts.isPropertyAccessExpression(expr)) {
132
+ calls.add(expr.name.text + "()");
133
+ }
134
+ }
135
+ ts.forEachChild(node, child => collectCallsInNode(child, calls));
136
+ return calls;
137
+ }
138
+
139
+ function collectThrowsInNode(node, throws = new Set()) {
140
+ if (!ts) return throws;
141
+ if (ts.isThrowStatement(node) && node.expression) {
142
+ if (ts.isNewExpression(node.expression) && ts.isIdentifier(node.expression.expression)) {
143
+ throws.add(node.expression.expression.text);
144
+ }
145
+ }
146
+ ts.forEachChild(node, child => collectThrowsInNode(child, throws));
147
+ return throws;
148
+ }
149
+
150
+ function isFunctionNode(node) {
151
+ if (!ts) return false;
152
+ return (
153
+ ts.isFunctionDeclaration(node) ||
154
+ ts.isFunctionExpression(node) ||
155
+ ts.isArrowFunction(node) ||
156
+ ts.isMethodDeclaration(node)
157
+ );
158
+ }
159
+
160
+ function getParentVariableName(node) {
161
+ // For arrow functions assigned to const: const foo = () => {}
162
+ if (!ts) return null;
163
+ if (node.parent && ts.isVariableDeclaration(node.parent)) {
164
+ return getNodeName(node.parent);
165
+ }
166
+ if (node.parent && ts.isPropertyAssignment(node.parent)) {
167
+ return getNodeName(node.parent);
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function analyzeJsTs(filePath, code) {
173
+ if (!ts) return null;
174
+
175
+ let srcFile;
176
+ try {
177
+ srcFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, /*setParentNodes*/ true);
178
+ } catch {
179
+ return null;
180
+ }
181
+
182
+ const functions = [];
183
+
184
+ function visit(node) {
185
+ if (isFunctionNode(node)) {
186
+ const name = getNodeName(node) || getParentVariableName(node) || "<anonymous>";
187
+ const calls = [...collectCallsInNode(node)].slice(0, 20);
188
+ const throws = [...collectThrowsInNode(node)];
189
+ const text = code.slice(node.pos, node.end);
190
+ functions.push({
191
+ name,
192
+ calls,
193
+ throws,
194
+ services: detectServices(text),
195
+ dbCalls: detectDbCalls(text),
196
+ httpCalls: detectHttpCalls(text),
197
+ loc: srcFile.getLineAndCharacterOfPosition(node.pos).line + 1,
198
+ });
199
+ }
200
+ ts.forEachChild(node, visit);
201
+ }
202
+
203
+ visit(srcFile);
204
+ return functions;
205
+ }
206
+
207
+ // ── Python AST analysis via child_process ─────────────────────────────────────
208
+
209
+ const PYTHON_SCRIPT = `
210
+ import ast, json, sys
211
+
212
+ def get_calls(node):
213
+ calls = []
214
+ for n in ast.walk(node):
215
+ if isinstance(n, ast.Call):
216
+ if isinstance(n.func, ast.Name):
217
+ calls.append(n.func.id + "()")
218
+ elif isinstance(n.func, ast.Attribute):
219
+ calls.append(n.func.attr + "()")
220
+ return list(set(calls))[:20]
221
+
222
+ def get_raises(node):
223
+ raises = []
224
+ for n in ast.walk(node):
225
+ if isinstance(n, ast.Raise) and n.exc:
226
+ if isinstance(n.exc, ast.Call) and isinstance(n.exc.func, ast.Name):
227
+ raises.append(n.exc.func.id)
228
+ elif isinstance(n.exc, ast.Name):
229
+ raises.append(n.exc.id)
230
+ return list(set(raises))
231
+
232
+ try:
233
+ code = open(sys.argv[1], encoding="utf-8", errors="ignore").read()
234
+ tree = ast.parse(code)
235
+ functions = []
236
+ for node in ast.walk(tree):
237
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
238
+ functions.append({
239
+ "name": node.name,
240
+ "calls": get_calls(node),
241
+ "throws": get_raises(node),
242
+ "loc": node.lineno,
243
+ })
244
+ print(json.dumps(functions))
245
+ except Exception as e:
246
+ print(json.dumps([]))
247
+ `;
248
+
249
+ function analyzePython(filePath) {
250
+ try {
251
+ const result = execSync(
252
+ `python3 -c ${JSON.stringify(PYTHON_SCRIPT)} ${JSON.stringify(filePath)}`,
253
+ { timeout: 8000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
254
+ );
255
+ const fns = JSON.parse(result.trim() || "[]");
256
+ // add service/db/http detection from raw file text
257
+ const code = fs.readFileSync(filePath, "utf8");
258
+ return fns.map(f => ({
259
+ ...f,
260
+ services: detectServices(code),
261
+ dbCalls: detectDbCalls(code),
262
+ httpCalls: detectHttpCalls(code),
263
+ }));
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ // ── regex fallback (Go, Ruby, Java, other) ────────────────────────────────────
270
+
271
+ const FUNC_PATTERNS = [
272
+ { re: /^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(/gm, lang: "go" },
273
+ { re: /^\s*(?:def|async def)\s+(\w+)\s*\(/gm, lang: "py" },
274
+ { re: /^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)?(\w+)\s*\(/gm, lang: "java" },
275
+ { re: /^\s*def\s+(\w+)\s*[\(\|]/gm, lang: "rb" },
276
+ ];
277
+
278
+ function analyzeWithRegex(filePath, code) {
279
+ const ext = path.extname(filePath).slice(1);
280
+ const pattern = FUNC_PATTERNS.find(p => p.lang === ext);
281
+ if (!pattern) return null;
282
+
283
+ const functions = [];
284
+ const r = new RegExp(pattern.re.source, "gm");
285
+ let m;
286
+ while ((m = r.exec(code)) !== null) {
287
+ // grab up to 60 lines after the match for context
288
+ const start = m.index;
289
+ const end = Math.min(start + 2000, code.length);
290
+ const chunk = code.slice(start, end);
291
+ functions.push({
292
+ name: m[1],
293
+ calls: [],
294
+ throws: [],
295
+ services: detectServices(chunk),
296
+ dbCalls: detectDbCalls(chunk),
297
+ httpCalls: detectHttpCalls(chunk),
298
+ loc: code.slice(0, start).split("\n").length,
299
+ });
300
+ }
301
+ return functions.length > 0 ? functions : null;
302
+ }
303
+
304
+ // ── file walker ───────────────────────────────────────────────────────────────
305
+
306
+ const SKIP_DIRS = new Set([
307
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt",
308
+ "coverage", "__pycache__", ".pytest_cache", "vendor", "tmp", ".turbo",
309
+ "target", ".gradle", "public", "static", "assets",
310
+ ]);
311
+
312
+ const SUPPORTED_EXTS = new Set([
313
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
314
+ ".py", ".go", ".rb", ".java",
315
+ ]);
316
+
317
+ const TEST_FILE = /\.(test|spec)\.[jt]sx?$|_test\.(go|py|rb)|spec\.(rb|js|ts)$/;
318
+
319
+ function* walkFiles(dir) {
320
+ let entries;
321
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
322
+ catch { return; }
323
+ for (const e of entries) {
324
+ if (e.isDirectory()) {
325
+ if (!SKIP_DIRS.has(e.name)) yield* walkFiles(path.join(dir, e.name));
326
+ } else if (e.isFile()) {
327
+ const ext = path.extname(e.name);
328
+ if (SUPPORTED_EXTS.has(ext) && !TEST_FILE.test(e.name)) {
329
+ yield path.join(dir, e.name);
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ // ── per-file analyzer ─────────────────────────────────────────────────────────
336
+
337
+ function analyzeFile(filePath) {
338
+ let code;
339
+ try { code = fs.readFileSync(filePath, "utf8"); }
340
+ catch { return []; }
341
+
342
+ const ext = path.extname(filePath);
343
+
344
+ if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) {
345
+ return analyzeJsTs(filePath, code) || analyzeWithRegex(filePath, code) || [];
346
+ }
347
+ if (ext === ".py") {
348
+ return analyzePython(filePath) || analyzeWithRegex(filePath, code) || [];
349
+ }
350
+ return analyzeWithRegex(filePath, code) || [];
351
+ }
352
+
353
+ // ── capability matcher ────────────────────────────────────────────────────────
354
+
355
+ function tokenise(str) {
356
+ return str.replace(/([a-z])([A-Z])/g, "$1 $2")
357
+ .toLowerCase().split(/[\s_\-/.]+/).filter(t => t.length > 1);
358
+ }
359
+
360
+ function overlap(a, b) {
361
+ const sa = new Set(a), sb = new Set(b);
362
+ let n = 0;
363
+ for (const t of sa) if (sb.has(t)) n++;
364
+ const u = sa.size + sb.size - n;
365
+ return u === 0 ? 0 : n / u;
366
+ }
367
+
368
+ function matchFunctionToCapability(fn, capabilities) {
369
+ const fnTokens = tokenise(fn.name);
370
+ let best = null, bestScore = 0;
371
+ for (const cap of capabilities) {
372
+ const score = Math.max(
373
+ overlap(fnTokens, tokenise(cap.id || "")),
374
+ overlap(fnTokens, tokenise(cap.name || cap.title || "")),
375
+ );
376
+ if (score > bestScore) { bestScore = score; best = cap; }
377
+ }
378
+ return bestScore >= 0.2 ? { cap: best, score: bestScore } : null;
379
+ }
380
+
381
+ // ── merge analysis into capability ────────────────────────────────────────────
382
+
383
+ function mergeAnalysis(existing = {}, fn, filePath, cwd) {
384
+ const rel = path.relative(cwd, filePath);
385
+
386
+ // merge arrays without duplicates
387
+ const merge = (a = [], b = []) => [...new Set([...a, ...b])];
388
+
389
+ return {
390
+ functions: merge(existing.functions, [fn.name]),
391
+ sourceFiles: merge(existing.sourceFiles, [rel]),
392
+ calls: merge(existing.calls, fn.calls),
393
+ throws: merge(existing.throws, fn.throws),
394
+ services: merge(existing.services, fn.services),
395
+ dbCalls: merge(existing.dbCalls, fn.dbCalls),
396
+ httpCalls: merge(existing.httpCalls, fn.httpCalls),
397
+ scannedAt: new Date().toISOString(),
398
+ };
399
+ }
400
+
401
+ // ── reporters ─────────────────────────────────────────────────────────────────
402
+
403
+ function printReport(enriched) {
404
+ console.log();
405
+ console.log(bold(" Scan Results"));
406
+ console.log(gray(" ─────────────────────────────────────────────────────────────────"));
407
+
408
+ for (const [capId, analysis] of Object.entries(enriched)) {
409
+ const { codeAnalysis: a } = analysis;
410
+ if (!a) continue;
411
+
412
+ console.log();
413
+ console.log(` ${green("●")} ${bold(capId)}`);
414
+ if (a.sourceFiles?.length) console.log(gray(` files: `) + a.sourceFiles.join(", "));
415
+ if (a.functions?.length) console.log(gray(` funcs: `) + a.functions.join(", "));
416
+ if (a.services?.length) console.log(gray(` services: `) + cyan(a.services.join(", ")));
417
+ if (a.dbCalls?.length) console.log(gray(` db: `) + a.dbCalls.slice(0, 4).join(", "));
418
+ if (a.httpCalls?.length) console.log(gray(` http: `) + a.httpCalls.slice(0, 4).join(", "));
419
+ if (a.throws?.length) console.log(gray(` throws: `) + yellow(a.throws.join(", ")));
420
+ }
421
+
422
+ console.log();
423
+ console.log(gray(" ─────────────────────────────────────────────────────────────────"));
424
+ }
425
+
426
+ // ── entry point ───────────────────────────────────────────────────────────────
427
+
428
+ export async function scanCommand(rawArgs) {
429
+ const args = rawArgs || [];
430
+ const dryRun = args.includes("--dry-run");
431
+ const jsonMode = args.includes("--json");
432
+ const dirIdx = args.indexOf("--dir");
433
+ const extraDirs = dirIdx !== -1 ? [args[dirIdx + 1]] : [];
434
+ const capFilter = (() => { const i = args.indexOf("--capability"); return i !== -1 ? args[i + 1] : null; })();
435
+
436
+ const cwd = process.cwd();
437
+ const infernoDir = path.join(cwd, "inferno");
438
+
439
+ // Load capabilities
440
+ const capsPath = path.join(infernoDir, "capabilities.json");
441
+ if (!fs.existsSync(capsPath)) {
442
+ console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
443
+ process.exit(1);
444
+ }
445
+ let capabilities;
446
+ try { capabilities = JSON.parse(fs.readFileSync(capsPath, "utf8")); }
447
+ catch (e) { console.error(red("✗ Failed to parse capabilities.json: " + e.message)); process.exit(1); }
448
+
449
+ if (!Array.isArray(capabilities)) {
450
+ // handle object format { capabilities: [...] }
451
+ if (capabilities.capabilities) capabilities = capabilities.capabilities;
452
+ else { console.error(red("✗ Unexpected capabilities.json format.")); process.exit(1); }
453
+ }
454
+
455
+ // Filter by --capability flag
456
+ const targetCaps = capFilter
457
+ ? capabilities.filter(c => c.id === capFilter || (c.name || "").toLowerCase() === capFilter.toLowerCase())
458
+ : capabilities;
459
+
460
+ if (targetCaps.length === 0) {
461
+ console.log(yellow(capFilter ? `No capability matched: ${capFilter}` : "No capabilities found."));
462
+ process.exit(0);
463
+ }
464
+
465
+ // Walk source files
466
+ const scanDirs = [cwd, ...extraDirs];
467
+ if (!jsonMode) process.stdout.write(gray(" Walking source files…"));
468
+ const files = [];
469
+ for (const dir of scanDirs) {
470
+ for (const f of walkFiles(dir)) files.push(f);
471
+ }
472
+ if (!jsonMode) process.stdout.write(`\r Found ${files.length} source files. \n`);
473
+
474
+ // Analyze files
475
+ if (!jsonMode) process.stdout.write(gray(" Analyzing…"));
476
+ const allFunctions = []; // { fn, filePath }
477
+ let analyzed = 0;
478
+ for (const filePath of files) {
479
+ const fns = analyzeFile(filePath);
480
+ for (const fn of fns) allFunctions.push({ fn, filePath });
481
+ analyzed++;
482
+ if (!jsonMode && analyzed % 20 === 0) {
483
+ process.stdout.write(`\r Analyzed ${analyzed}/${files.length} files…`);
484
+ }
485
+ }
486
+ if (!jsonMode) process.stdout.write(`\r Analyzed ${files.length} files, found ${allFunctions.length} functions. \n`);
487
+
488
+ // Map functions to capabilities
489
+ const enriched = {}; // capId → { ...cap, codeAnalysis: {...} }
490
+
491
+ for (const cap of targetCaps) {
492
+ enriched[cap.id] = { ...cap, codeAnalysis: null };
493
+ }
494
+
495
+ for (const { fn, filePath } of allFunctions) {
496
+ const match = matchFunctionToCapability(fn, targetCaps);
497
+ if (!match) continue;
498
+ const { cap } = match;
499
+ const existing = enriched[cap.id]?.codeAnalysis || {};
500
+ enriched[cap.id].codeAnalysis = mergeAnalysis(existing, fn, filePath, cwd);
501
+ }
502
+
503
+ // Compute stats
504
+ const total = Object.keys(enriched).length;
505
+ const matched = Object.values(enriched).filter(e => e.codeAnalysis).length;
506
+
507
+ if (jsonMode) {
508
+ const out = {
509
+ scannedAt: new Date().toISOString(),
510
+ files: files.length,
511
+ functions: allFunctions.length,
512
+ capabilities: Object.entries(enriched).map(([id, data]) => ({
513
+ id,
514
+ name: data.name || data.title,
515
+ codeAnalysis: data.codeAnalysis,
516
+ })),
517
+ };
518
+ console.log(JSON.stringify(out, null, 2));
519
+ return;
520
+ }
521
+
522
+ printReport(enriched);
523
+ console.log(` ${green("✔")} Matched ${matched}/${total} capabilities to source functions`);
524
+ console.log();
525
+
526
+ if (dryRun) {
527
+ console.log(yellow(" --dry-run: no files written."));
528
+ return;
529
+ }
530
+
531
+ // Write scan.json
532
+ const scanData = {
533
+ scannedAt: new Date().toISOString(),
534
+ files: files.length,
535
+ functions: allFunctions.length,
536
+ capabilities: Object.entries(enriched).map(([id, data]) => ({
537
+ id,
538
+ name: data.name || data.title,
539
+ codeAnalysis: data.codeAnalysis,
540
+ })),
541
+ };
542
+ const scanPath = path.join(infernoDir, "scan.json");
543
+ fs.writeFileSync(scanPath, JSON.stringify(scanData, null, 2));
544
+ console.log(gray(` Saved → inferno/scan.json`));
545
+
546
+ // Enrich capabilities.json
547
+ let changed = 0;
548
+ const updatedCaps = capabilities.map(cap => {
549
+ const analysis = enriched[cap.id]?.codeAnalysis;
550
+ if (!analysis) return cap;
551
+ changed++;
552
+ return { ...cap, codeAnalysis: analysis };
553
+ });
554
+
555
+ if (changed > 0) {
556
+ fs.writeFileSync(capsPath, JSON.stringify(updatedCaps, null, 2));
557
+ console.log(gray(` Updated ${changed} capability entries in capabilities.json`));
558
+ }
559
+
560
+ console.log();
561
+ if (!ts) {
562
+ console.log(yellow(" ⚠ TypeScript compiler not found — JS/TS analyzed with regex fallback."));
563
+ console.log(gray(` For deeper analysis: npm install -g typescript`));
564
+ console.log();
565
+ }
566
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {