infernoflow 0.19.0 → 0.21.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * infernoflow health
3
+ *
4
+ * Computes a weighted 0–100 health score for the capability contract.
5
+ * Breaks it down across five dimensions so you know exactly where to improve.
6
+ *
7
+ * Dimensions:
8
+ * Coverage % of capabilities that have descriptions (weight 25)
9
+ * Documentation % with at least one scenario/test (weight 20)
10
+ * Freshness How recently the contract was updated (weight 20)
11
+ * Completeness Version field, owner, tags present (weight 15)
12
+ * Drift risk Open issues from `infernoflow check` (weight 20)
13
+ *
14
+ * Usage:
15
+ * infernoflow health Print score + breakdown
16
+ * infernoflow health --json Machine-readable
17
+ * infernoflow health --fail-below 70 Exit 1 if score < 70 (CI gate)
18
+ * infernoflow health --watch Re-run every 30s (for terminals)
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { execSync } from "node:child_process";
24
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
25
+
26
+ const WEIGHTS = {
27
+ coverage: 25,
28
+ documentation: 20,
29
+ freshness: 20,
30
+ completeness: 15,
31
+ drift: 20,
32
+ };
33
+
34
+ // ── Readers ───────────────────────────────────────────────────────────────────
35
+
36
+ function readContract(infernoDir) {
37
+ for (const f of ["contract.json", "capabilities.json"]) {
38
+ const p = path.join(infernoDir, f);
39
+ if (!fs.existsSync(p)) continue;
40
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function readScenarios(infernoDir) {
46
+ const scenDir = path.join(infernoDir, "scenarios");
47
+ if (!fs.existsSync(scenDir)) return [];
48
+ return fs.readdirSync(scenDir)
49
+ .filter(f => f.endsWith(".json"))
50
+ .flatMap(f => {
51
+ try {
52
+ const data = JSON.parse(fs.readFileSync(path.join(scenDir, f), "utf8"));
53
+ return data.capability ? [data.capability] : (data.capabilities || []);
54
+ } catch { return []; }
55
+ });
56
+ }
57
+
58
+ function runCheck(cwd) {
59
+ try {
60
+ const out = execSync("npx infernoflow check --json", {
61
+ cwd, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"],
62
+ });
63
+ return JSON.parse(out);
64
+ } catch (err) {
65
+ try { return JSON.parse(err.stdout || "{}"); } catch { return {}; }
66
+ }
67
+ }
68
+
69
+ function lastModifiedDaysAgo(infernoDir) {
70
+ const files = ["contract.json", "capabilities.json"]
71
+ .map(f => path.join(infernoDir, f))
72
+ .filter(fs.existsSync);
73
+ if (!files.length) return 999;
74
+ const mtime = Math.max(...files.map(f => fs.statSync(f).mtimeMs));
75
+ return (Date.now() - mtime) / (1000 * 60 * 60 * 24);
76
+ }
77
+
78
+ // ── Scorers ───────────────────────────────────────────────────────────────────
79
+
80
+ function scoreCoverage(caps) {
81
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
82
+ const withDesc = caps.filter(c => c.description && c.description.length > 5).length;
83
+ const pct = Math.round((withDesc / caps.length) * 100);
84
+ return {
85
+ score: pct,
86
+ detail: `${withDesc}/${caps.length} capabilities have descriptions`,
87
+ pct,
88
+ };
89
+ }
90
+
91
+ function scoreDocumentation(caps, scenarioCaps) {
92
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
93
+ const scenSet = new Set(scenarioCaps.map(s => String(s).toLowerCase()));
94
+ const withScen = caps.filter(c => scenSet.has(c.id.toLowerCase())).length;
95
+ const pct = Math.round((withScen / caps.length) * 100);
96
+ return {
97
+ score: Math.min(100, pct + (scenSet.size === 0 ? 0 : 10)), // bonus for having any scenarios
98
+ detail: `${withScen}/${caps.length} capabilities have test scenarios`,
99
+ pct,
100
+ };
101
+ }
102
+
103
+ function scoreFreshness(daysAgo) {
104
+ let score;
105
+ let label;
106
+ if (daysAgo <= 1) { score = 100; label = "updated today"; }
107
+ else if (daysAgo <= 3) { score = 95; label = `updated ${Math.round(daysAgo)}d ago`; }
108
+ else if (daysAgo <= 7) { score = 85; label = `updated ${Math.round(daysAgo)}d ago`; }
109
+ else if (daysAgo <= 14) { score = 70; label = `updated ${Math.round(daysAgo)}d ago`; }
110
+ else if (daysAgo <= 30) { score = 50; label = `updated ${Math.round(daysAgo)}d ago`; }
111
+ else if (daysAgo <= 60) { score = 30; label = `updated ${Math.round(daysAgo)}d ago — stale`; }
112
+ else { score = 10; label = `not updated in ${Math.round(daysAgo)}d — very stale`; }
113
+ return { score, detail: label };
114
+ }
115
+
116
+ function scoreCompleteness(caps, contract) {
117
+ if (!caps.length) return { score: 0, detail: "No capabilities" };
118
+ const hasVersion = !!(contract?.version || contract?.contractVersion);
119
+ const withTags = caps.filter(c => c.tags?.length).length;
120
+ const withOwner = caps.filter(c => c.owner).length;
121
+ const withSince = caps.filter(c => c.since).length;
122
+
123
+ const tagPct = Math.round((withTags / caps.length) * 100);
124
+ const ownerPct = Math.round((withOwner / caps.length) * 100);
125
+ const sincePct = Math.round((withSince / caps.length) * 100);
126
+
127
+ const score = Math.round(
128
+ (hasVersion ? 20 : 0) +
129
+ tagPct * 0.3 +
130
+ ownerPct * 0.3 +
131
+ sincePct * 0.2
132
+ );
133
+
134
+ return {
135
+ score: Math.min(100, score),
136
+ detail: `version: ${hasVersion ? "✓" : "✗"}, tags: ${tagPct}%, owner: ${ownerPct}%, since: ${sincePct}%`,
137
+ };
138
+ }
139
+
140
+ function scoreDrift(checkResult) {
141
+ const issues = checkResult?.issues || [];
142
+ const warnings = issues.filter(i => (i.severity || i.level || "error") === "warning").length;
143
+ const errors = issues.filter(i => (i.severity || i.level || "error") === "error").length;
144
+ const status = checkResult?.status;
145
+
146
+ if (status === "ok" || (!errors && !warnings)) return { score: 100, detail: "No issues found" };
147
+
148
+ const score = Math.max(0, 100 - (errors * 20) - (warnings * 8));
149
+ return {
150
+ score,
151
+ detail: `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}`,
152
+ };
153
+ }
154
+
155
+ // ── Aggregate ─────────────────────────────────────────────────────────────────
156
+
157
+ function computeHealth(infernoDir, cwd) {
158
+ const contract = readContract(infernoDir);
159
+ const caps = (contract?.capabilities || []).map(c =>
160
+ typeof c === "string" ? { id: c, description: "", tags: [], owner: "", since: "" } : c
161
+ );
162
+ const scenarioCaps = readScenarios(infernoDir);
163
+ const daysAgo = lastModifiedDaysAgo(infernoDir);
164
+ const checkResult = runCheck(cwd);
165
+
166
+ const dimensions = {
167
+ coverage: scoreCoverage(caps),
168
+ documentation: scoreDocumentation(caps, scenarioCaps),
169
+ freshness: scoreFreshness(daysAgo),
170
+ completeness: scoreCompleteness(caps, contract),
171
+ drift: scoreDrift(checkResult),
172
+ };
173
+
174
+ const totalScore = Math.round(
175
+ Object.entries(dimensions).reduce((sum, [key, dim]) => {
176
+ return sum + (dim.score * WEIGHTS[key]) / 100;
177
+ }, 0)
178
+ );
179
+
180
+ return { totalScore, dimensions, caps, daysAgo, checkResult };
181
+ }
182
+
183
+ // ── Renderer ──────────────────────────────────────────────────────────────────
184
+
185
+ function scoreColor(score) {
186
+ if (score >= 80) return green;
187
+ if (score >= 60) return yellow;
188
+ return red;
189
+ }
190
+
191
+ function scoreGrade(score) {
192
+ if (score >= 90) return "A";
193
+ if (score >= 80) return "B";
194
+ if (score >= 70) return "C";
195
+ if (score >= 60) return "D";
196
+ return "F";
197
+ }
198
+
199
+ function barOf(score, width = 30) {
200
+ const filled = Math.round((score / 100) * width);
201
+ const empty = width - filled;
202
+ return "█".repeat(filled) + "░".repeat(empty);
203
+ }
204
+
205
+ function printReport(totalScore, dimensions) {
206
+ const col = scoreColor(totalScore);
207
+ const grade = scoreGrade(totalScore);
208
+
209
+ console.log();
210
+ console.log(` ${bold("🔥 infernoflow health score")}`);
211
+ console.log();
212
+ console.log(` ${col(bold(String(totalScore)))} / 100 ${col(bold(grade))} ${col(barOf(totalScore))}`);
213
+ console.log();
214
+
215
+ const dimNames = {
216
+ coverage: "Coverage ",
217
+ documentation: "Docs/Tests ",
218
+ freshness: "Freshness ",
219
+ completeness: "Completeness ",
220
+ drift: "Drift risk ",
221
+ };
222
+
223
+ for (const [key, dim] of Object.entries(dimensions)) {
224
+ const w = WEIGHTS[key];
225
+ const sc = dim.score;
226
+ const c = scoreColor(sc);
227
+ const bar = barOf(sc, 20);
228
+ const weighted = Math.round((sc * w) / 100);
229
+ console.log(
230
+ ` ${bold(dimNames[key])} ${c(String(sc).padStart(3))} ${c(bar)} ${gray(`×${w}% = ${weighted}pts ${dim.detail}`)}`
231
+ );
232
+ }
233
+ console.log();
234
+ }
235
+
236
+ function printTips(totalScore, dimensions) {
237
+ const tips = [];
238
+
239
+ if (dimensions.coverage.score < 70)
240
+ tips.push("Add descriptions to your capabilities in contract.json");
241
+ if (dimensions.documentation.score < 60)
242
+ tips.push("Create scenario files in inferno/scenarios/ for each capability");
243
+ if (dimensions.freshness.score < 70)
244
+ tips.push("Run `infernoflow suggest` to sync recent changes to the contract");
245
+ if (dimensions.completeness.score < 60)
246
+ tips.push("Add version, tags, owner, and since fields to your capabilities");
247
+ if (dimensions.drift.score < 80)
248
+ tips.push("Run `infernoflow check` and fix the reported issues");
249
+
250
+ if (tips.length) {
251
+ console.log(` ${bold("Tips to improve:")}`);
252
+ tips.forEach(t => console.log(` ${yellow("·")} ${t}`));
253
+ console.log();
254
+ }
255
+ }
256
+
257
+ // ── Entry ─────────────────────────────────────────────────────────────────────
258
+
259
+ export async function healthCommand(rawArgs) {
260
+ const args = rawArgs.slice(1);
261
+ const jsonMode = args.includes("--json");
262
+ const watchMode = args.includes("--watch");
263
+ const cwd = process.cwd();
264
+ const infernoDir = path.join(cwd, "inferno");
265
+
266
+ if (!fs.existsSync(infernoDir)) {
267
+ const msg = "inferno/ not found. Run: infernoflow init";
268
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
269
+ else warn(msg);
270
+ process.exit(1);
271
+ }
272
+
273
+ const failBelowIdx = args.indexOf("--fail-below");
274
+ const failBelow = failBelowIdx !== -1 ? parseInt(args[failBelowIdx + 1], 10) : null;
275
+
276
+ const intervalIdx = args.indexOf("--interval");
277
+ const intervalSecs = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1], 10) : 30;
278
+
279
+ const runOnce = () => {
280
+ const { totalScore, dimensions } = computeHealth(infernoDir, cwd);
281
+
282
+ if (jsonMode) {
283
+ const dimFlat = Object.fromEntries(
284
+ Object.entries(dimensions).map(([k, v]) => [k, { score: v.score, detail: v.detail, weight: WEIGHTS[k] }])
285
+ );
286
+ console.log(JSON.stringify({ ok: true, score: totalScore, grade: scoreGrade(totalScore), dimensions: dimFlat }));
287
+ } else {
288
+ if (watchMode) process.stdout.write("\x1Bc"); // clear screen
289
+ printReport(totalScore, dimensions);
290
+ if (!watchMode) printTips(totalScore, dimensions);
291
+ }
292
+
293
+ if (failBelow !== null && totalScore < failBelow) {
294
+ if (!jsonMode) console.error(red(` ✗ Score ${totalScore} is below threshold ${failBelow} — failing.\n`));
295
+ process.exit(1);
296
+ }
297
+
298
+ return totalScore;
299
+ };
300
+
301
+ if (watchMode) {
302
+ if (!jsonMode) info(`Watching health score every ${intervalSecs}s — press Ctrl+C to stop`);
303
+ runOnce();
304
+ setInterval(runOnce, intervalSecs * 1000);
305
+ await new Promise(() => {});
306
+ } else {
307
+ runOnce();
308
+ }
309
+ }
@@ -155,6 +155,76 @@ export async function initCommand(args) {
155
155
  const force = args.includes("--force") || args.includes("-f");
156
156
  const yes = args.includes("--yes") || args.includes("-y");
157
157
  const adopt = args.includes("--adopt");
158
+
159
+ // ── Template shortcut ──────────────────────────────────────────────────────
160
+ const templateIdx = args.indexOf("--template");
161
+ const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null;
162
+
163
+ if (templateName) {
164
+ let tmplMod;
165
+ try { tmplMod = await import("../templates/index.mjs"); } catch {}
166
+ const tmpl = tmplMod?.getTemplate(templateName);
167
+ if (!tmpl) {
168
+ const available = tmplMod ? tmplMod.listTemplates().map(t => t.name).join(", ") : "rest-api, nextjs, cli, graphql, monorepo";
169
+ warn(`Unknown template: ${templateName}. Available: ${available}`);
170
+ process.exit(1);
171
+ }
172
+
173
+ const infernoDir = path.join(cwd, "inferno");
174
+ const scenDir = path.join(infernoDir, "scenarios");
175
+ if (!fs.existsSync(infernoDir)) fs.mkdirSync(infernoDir, { recursive: true });
176
+ if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
177
+
178
+ const policyId = detectProjectName(cwd);
179
+ const caps = tmpl.capabilities;
180
+
181
+ // Write contract.json
182
+ fs.writeFileSync(path.join(infernoDir, "contract.json"), JSON.stringify({
183
+ policyId, policyVersion: 1,
184
+ capabilities: caps.map(c => c.id),
185
+ }, null, 2) + "\n");
186
+
187
+ // Write capabilities.json
188
+ fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
189
+ capabilities: caps.map(c => ({
190
+ id: c.id, description: c.description,
191
+ since: new Date().toISOString().slice(0, 10), source: `template:${templateName}`,
192
+ })),
193
+ }, null, 2) + "\n");
194
+
195
+ // Write one scenario per capability
196
+ for (const cap of caps) {
197
+ fs.writeFileSync(path.join(scenDir, `${cap.id}.json`), JSON.stringify({
198
+ id: `${cap.id}-happy-path`, capability: cap.id,
199
+ description: `Happy path for ${cap.description || cap.id}`,
200
+ steps: [
201
+ { action: "invoke", target: cap.id, input: {} },
202
+ { action: "assert", field: "status", value: "success" },
203
+ ],
204
+ capabilitiesCovered: [cap.id],
205
+ }, null, 2) + "\n");
206
+ }
207
+
208
+ // Write CHANGELOG.md
209
+ writeChangelog(path.join(infernoDir, "CHANGELOG.md"), policyId);
210
+
211
+ // Write CONTEXT.md hint
212
+ fs.writeFileSync(path.join(infernoDir, "CONTEXT.md"),
213
+ `# ${policyId} — infernoflow context\n\n> Template: ${templateName} — ${tmpl.description}\n\n## Hint\n${tmpl.contextHint}\n\n## Capabilities (${caps.length})\n${caps.map(c => `- \`${c.id}\`: ${c.description}`).join("\n")}\n`
214
+ );
215
+
216
+ if (tmpl.scripts) {
217
+ info(`Suggested package.json scripts for this template:`);
218
+ Object.entries(tmpl.scripts).forEach(([k, v]) => console.log(` ${bold(k)}: ${gray(v)}`));
219
+ console.log();
220
+ }
221
+
222
+ done(`Initialised from template ${bold(cyan(templateName))} — ${bold(String(caps.length))} capabilities`);
223
+ console.log();
224
+ info(`Run ${cyan("infernoflow vibe")} to start vibe coding mode`);
225
+ console.log();
226
+ return;
227
+ }
158
228
  const cursorHooks = args.includes("--cursor-hooks");
159
229
  const vscodeCopilotHooks = args.includes("--vscode-copilot-hooks");
160
230
  const reportJson = args.includes("--report-json");
@@ -0,0 +1,291 @@
1
+ /**
2
+ * infernoflow scout
3
+ *
4
+ * Scans your source files for undocumented capabilities — functions, routes,
5
+ * exports, controllers — that exist in code but are missing from the contract.
6
+ *
7
+ * Smarter than --adopt: it reads your existing contract, diffs against what
8
+ * it finds in source, and only surfaces genuine gaps.
9
+ *
10
+ * Usage:
11
+ * infernoflow scout Scan src/, lib/, app/ (auto-detected)
12
+ * infernoflow scout --dir src,api Custom scan directories
13
+ * infernoflow scout --apply Write discovered caps to capabilities.json
14
+ * infernoflow scout --json Machine-readable output
15
+ * infernoflow scout --min-confidence 0.7 Only show high-confidence hits
16
+ *
17
+ * Output:
18
+ * Lists undocumented capabilities with confidence score + source location.
19
+ * With --apply, merges them into the contract file.
20
+ */
21
+
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
25
+
26
+ // ── Pattern library ───────────────────────────────────────────────────────────
27
+
28
+ const PATTERNS = [
29
+ // Express / Fastify / Hono routes
30
+ {
31
+ name: "http-route",
32
+ regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
33
+ extract: (m) => ({ id: routeToId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
34
+ },
35
+ // Next.js / Nuxt API route files (path from filename)
36
+ {
37
+ name: "api-file",
38
+ filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
39
+ extract: (filePath) => {
40
+ const m = filePath.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
41
+ if (!m) return null;
42
+ const route = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
43
+ return { id: routeToId("/" + route, "api"), hint: `Next.js API: /${route}`, confidence: 0.9 };
44
+ },
45
+ },
46
+ // Named exports (functions, classes, consts)
47
+ {
48
+ name: "export",
49
+ regex: /export\s+(?:async\s+)?(?:function|class|const|let)\s+([A-Z][A-Za-z0-9]+)/g,
50
+ extract: (m) => ({ id: camelToId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
51
+ },
52
+ // Default export object keys (controller-style)
53
+ {
54
+ name: "controller-method",
55
+ regex: /^\s{2,}(?:async\s+)?([a-z][A-Za-z0-9]+)\s*[:(]/gm,
56
+ extract: (m) => ({ id: camelToId(m[1]), hint: `method ${m[1]}`, confidence: 0.5 }),
57
+ minLen: 5,
58
+ },
59
+ // GraphQL resolver fields
60
+ {
61
+ name: "graphql-resolver",
62
+ regex: /['"`]?([A-Za-z][A-Za-z0-9]+)['"`]?\s*:\s*(?:async\s+)?\([^)]*\)\s*(?:=>|{)/g,
63
+ extract: (m) => ({ id: camelToId(m[1]), hint: `resolver ${m[1]}`, confidence: 0.6 }),
64
+ },
65
+ // Django / Flask URL patterns
66
+ {
67
+ name: "python-route",
68
+ regex: /(?:path|url|re_path)\s*\(\s*['"`]([^'"`]+)['"`]\s*,\s*([A-Za-z0-9_.]+)/g,
69
+ extract: (m) => ({ id: routeToId(m[1], "view"), hint: `view ${m[2]} @ ${m[1]}`, confidence: 0.8 }),
70
+ },
71
+ // Rails routes
72
+ {
73
+ name: "rails-route",
74
+ regex: /(?:get|post|put|patch|delete)\s+['"`]([^'"`]+)['"`]\s*,\s*to:\s*['"`]([^'"`]+)['"`]/g,
75
+ extract: (m) => ({ id: routeToId(m[1], "rails"), hint: `Rails: ${m[2]}`, confidence: 0.8 }),
76
+ },
77
+ ];
78
+
79
+ // ── Helpers ───────────────────────────────────────────────────────────────────
80
+
81
+ function routeToId(route, method) {
82
+ return route
83
+ .replace(/^\//, "")
84
+ .replace(/\/:?[^/]+/g, "") // strip params
85
+ .replace(/\//g, "-")
86
+ .replace(/[^a-zA-Z0-9-]/g, "")
87
+ .replace(/-+/g, "-")
88
+ .replace(/^-|-$/g, "")
89
+ .toLowerCase() || method.toLowerCase() + "-root";
90
+ }
91
+
92
+ function camelToId(name) {
93
+ return name
94
+ .replace(/([A-Z])/g, "-$1")
95
+ .toLowerCase()
96
+ .replace(/^-/, "")
97
+ .replace(/-+/g, "-");
98
+ }
99
+
100
+ // ── File scanner ──────────────────────────────────────────────────────────────
101
+
102
+ const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb"]);
103
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", "vendor", "coverage"]);
104
+
105
+ function* walkFiles(dir) {
106
+ if (!fs.existsSync(dir)) return;
107
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
108
+ for (const e of entries) {
109
+ if (IGNORE_DIRS.has(e.name)) continue;
110
+ const full = path.join(dir, e.name);
111
+ if (e.isDirectory()) {
112
+ yield* walkFiles(full);
113
+ } else if (e.isFile() && SOURCE_EXTENSIONS.has(path.extname(e.name))) {
114
+ yield full;
115
+ }
116
+ }
117
+ }
118
+
119
+ function scanFile(filePath) {
120
+ const hits = [];
121
+
122
+ // File-pattern rules (e.g. Next.js API files)
123
+ for (const pat of PATTERNS) {
124
+ if (pat.filePattern && pat.filePattern.test(filePath)) {
125
+ const result = pat.extract(filePath);
126
+ if (result) hits.push({ ...result, source: filePath, pattern: pat.name });
127
+ }
128
+ }
129
+
130
+ let content;
131
+ try { content = fs.readFileSync(filePath, "utf8"); } catch { return hits; }
132
+
133
+ for (const pat of PATTERNS) {
134
+ if (!pat.regex) continue;
135
+ pat.regex.lastIndex = 0;
136
+ let m;
137
+ while ((m = pat.regex.exec(content)) !== null) {
138
+ const result = pat.extract(m);
139
+ if (!result) continue;
140
+ if (pat.minLen && result.id.length < pat.minLen) continue;
141
+ hits.push({ ...result, source: filePath, pattern: pat.name });
142
+ }
143
+ }
144
+
145
+ return hits;
146
+ }
147
+
148
+ // ── Gap detection ─────────────────────────────────────────────────────────────
149
+
150
+ function readContract(infernoDir) {
151
+ for (const f of ["capabilities.json", "contract.json"]) {
152
+ const p = path.join(infernoDir, f);
153
+ if (!fs.existsSync(p)) continue;
154
+ try { return { file: f, data: JSON.parse(fs.readFileSync(p, "utf8")) }; } catch {}
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function getKnownIds(contract) {
160
+ const caps = contract?.data?.capabilities || [];
161
+ return new Set(caps.map(c => (typeof c === "string" ? c : c.id || c.name || "").toLowerCase()));
162
+ }
163
+
164
+ function dedupeHits(hits) {
165
+ const seen = new Map();
166
+ for (const h of hits) {
167
+ const key = h.id;
168
+ const existing = seen.get(key);
169
+ if (!existing || h.confidence > existing.confidence) {
170
+ seen.set(key, h);
171
+ }
172
+ }
173
+ return [...seen.values()].sort((a, b) => b.confidence - a.confidence);
174
+ }
175
+
176
+ // ── Apply ─────────────────────────────────────────────────────────────────────
177
+
178
+ function applyToContract(infernoDir, contract, newCaps) {
179
+ const caps = contract.data.capabilities || [];
180
+ const added = [];
181
+
182
+ for (const cap of newCaps) {
183
+ const entry = {
184
+ id: cap.id,
185
+ description: cap.hint,
186
+ since: new Date().toISOString().slice(0, 10),
187
+ source: "scout",
188
+ };
189
+ caps.push(entry);
190
+ added.push(cap.id);
191
+ }
192
+
193
+ contract.data.capabilities = caps;
194
+ fs.writeFileSync(
195
+ path.join(infernoDir, contract.file),
196
+ JSON.stringify(contract.data, null, 2) + "\n"
197
+ );
198
+ return added;
199
+ }
200
+
201
+ // ── Entry ─────────────────────────────────────────────────────────────────────
202
+
203
+ export async function scoutCommand(rawArgs) {
204
+ const args = rawArgs.slice(1);
205
+ const jsonMode = args.includes("--json");
206
+ const apply = args.includes("--apply");
207
+ const cwd = process.cwd();
208
+ const infernoDir = path.join(cwd, "inferno");
209
+
210
+ if (!fs.existsSync(infernoDir)) {
211
+ const msg = "inferno/ not found. Run: infernoflow init";
212
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
213
+ else warn(msg);
214
+ process.exit(1);
215
+ }
216
+
217
+ // Parse --dir flag
218
+ const dirIdx = args.indexOf("--dir");
219
+ const scanDirs = dirIdx !== -1
220
+ ? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
221
+ : ["src", "lib", "app", "api", "pages", "routes", "controllers", "handlers"]
222
+ .map(d => path.join(cwd, d))
223
+ .filter(fs.existsSync);
224
+
225
+ // Parse --min-confidence
226
+ const confIdx = args.indexOf("--min-confidence");
227
+ const minConfidence = confIdx !== -1 ? parseFloat(args[confIdx + 1]) : 0.6;
228
+
229
+ if (!scanDirs.length) {
230
+ const msg = "No source directories found. Use --dir src,lib,api";
231
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
232
+ else warn(msg);
233
+ process.exit(1);
234
+ }
235
+
236
+ const contract = readContract(infernoDir);
237
+ const knownIds = getKnownIds(contract);
238
+
239
+ if (!jsonMode) {
240
+ info(`Scanning ${scanDirs.map(d => path.relative(cwd, d)).join(", ")} …`);
241
+ console.log();
242
+ }
243
+
244
+ // Collect hits from all source dirs
245
+ const allHits = [];
246
+ for (const dir of scanDirs) {
247
+ for (const file of walkFiles(dir)) {
248
+ allHits.push(...scanFile(file));
249
+ }
250
+ }
251
+
252
+ // Filter: unknown, above confidence threshold, non-trivial id
253
+ const candidates = dedupeHits(allHits).filter(h =>
254
+ !knownIds.has(h.id.toLowerCase()) &&
255
+ h.confidence >= minConfidence &&
256
+ h.id.length >= 3 &&
257
+ !["index", "app", "main", "root", "default", "handler"].includes(h.id)
258
+ );
259
+
260
+ if (jsonMode) {
261
+ console.log(JSON.stringify({ ok: true, found: candidates.length, candidates }));
262
+ return;
263
+ }
264
+
265
+ if (!candidates.length) {
266
+ done("No undocumented capabilities found — contract looks complete.");
267
+ return;
268
+ }
269
+
270
+ console.log(` ${bold(`${candidates.length} undocumented capability candidate${candidates.length !== 1 ? "s" : ""} found`)}`);
271
+ console.log();
272
+
273
+ const w = Math.max(...candidates.map(c => c.id.length), 10) + 2;
274
+ for (const c of candidates) {
275
+ const confStr = `${Math.round(c.confidence * 100)}%`;
276
+ const confColor = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
277
+ const rel = path.relative(cwd, c.source);
278
+ console.log(` ${bold(c.id.padEnd(w))} ${confColor(confStr.padEnd(5))} ${gray(c.hint)}`);
279
+ console.log(` ${" ".repeat(w + 7)}${gray(rel)}`);
280
+ }
281
+ console.log();
282
+
283
+ if (apply) {
284
+ const added = applyToContract(infernoDir, contract, candidates);
285
+ done(`Added ${bold(String(added.length))} capabilities to ${contract.file}`);
286
+ console.log();
287
+ } else {
288
+ info(`Run with ${cyan("--apply")} to add these to your contract.`);
289
+ console.log();
290
+ }
291
+ }