infernoflow 0.37.0 → 0.37.3
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/CHANGELOG.md +125 -0
- package/dist/bin/infernoflow.mjs +29 -277
- package/dist/lib/adopters/angular.mjs +1 -128
- package/dist/lib/adopters/css.mjs +1 -111
- package/dist/lib/adopters/react.mjs +1 -104
- package/dist/lib/ai/ideDetection.mjs +1 -31
- package/dist/lib/ai/localProvider.mjs +1 -88
- package/dist/lib/ai/providerRouter.mjs +2 -295
- package/dist/lib/commands/adopt.mjs +20 -869
- package/dist/lib/commands/adoptWizard.mjs +9 -320
- package/dist/lib/commands/agent.mjs +5 -191
- package/dist/lib/commands/ai.mjs +2 -407
- package/dist/lib/commands/ask.mjs +4 -299
- package/dist/lib/commands/audit.mjs +13 -300
- package/dist/lib/commands/changelog.mjs +26 -594
- package/dist/lib/commands/check.mjs +3 -184
- package/dist/lib/commands/ci.mjs +3 -208
- package/dist/lib/commands/claudeMd.mjs +30 -135
- package/dist/lib/commands/cloud.mjs +10 -773
- package/dist/lib/commands/context.mjs +34 -346
- package/dist/lib/commands/coverage.mjs +2 -282
- package/dist/lib/commands/dashboard.mjs +123 -635
- package/dist/lib/commands/demo.mjs +8 -465
- package/dist/lib/commands/diff.mjs +5 -274
- package/dist/lib/commands/docGate.mjs +2 -81
- package/dist/lib/commands/doctor.mjs +3 -321
- package/dist/lib/commands/explain.mjs +8 -438
- package/dist/lib/commands/export.mjs +10 -239
- package/dist/lib/commands/feedback.mjs +12 -216
- package/dist/lib/commands/generateSkills.mjs +38 -163
- package/dist/lib/commands/graph.mjs +11 -378
- package/dist/lib/commands/health.mjs +2 -309
- package/dist/lib/commands/impact.mjs +2 -325
- package/dist/lib/commands/implement.mjs +7 -103
- package/dist/lib/commands/init.mjs +45 -631
- package/dist/lib/commands/installCursorHooks.mjs +1 -36
- package/dist/lib/commands/installVsCodeCopilotHooks.mjs +1 -37
- package/dist/lib/commands/link.mjs +2 -342
- package/dist/lib/commands/log.mjs +18 -248
- package/dist/lib/commands/monorepo.mjs +4 -428
- package/dist/lib/commands/notify.mjs +4 -258
- package/dist/lib/commands/onboard.mjs +4 -296
- package/dist/lib/commands/prComment.mjs +2 -361
- package/dist/lib/commands/prImpact.mjs +2 -157
- package/dist/lib/commands/publish.mjs +15 -316
- package/dist/lib/commands/recap.mjs +6 -380
- package/dist/lib/commands/report.mjs +28 -272
- package/dist/lib/commands/review.mjs +9 -223
- package/dist/lib/commands/run.mjs +8 -336
- package/dist/lib/commands/scaffold.mjs +54 -419
- package/dist/lib/commands/scan.mjs +11 -1118
- package/dist/lib/commands/scout.mjs +2 -291
- package/dist/lib/commands/setup.mjs +5 -310
- package/dist/lib/commands/share.mjs +13 -196
- package/dist/lib/commands/snapshot.mjs +3 -383
- package/dist/lib/commands/stability.mjs +2 -293
- package/dist/lib/commands/stats.mjs +5 -402
- package/dist/lib/commands/status.mjs +4 -172
- package/dist/lib/commands/suggest.mjs +21 -563
- package/dist/lib/commands/switch.mjs +13 -517
- package/dist/lib/commands/syncAuto.mjs +1 -96
- package/dist/lib/commands/synthesize.mjs +10 -228
- package/dist/lib/commands/teamSync.mjs +2 -388
- package/dist/lib/commands/test.mjs +6 -363
- package/dist/lib/commands/theme.mjs +18 -195
- package/dist/lib/commands/uninstall.mjs +13 -406
- package/dist/lib/commands/upgrade.mjs +20 -153
- package/dist/lib/commands/version.mjs +2 -282
- package/dist/lib/commands/vibe.mjs +7 -357
- package/dist/lib/commands/watch.mjs +4 -203
- package/dist/lib/commands/why.mjs +4 -358
- package/dist/lib/cursorHooksInstall.mjs +1 -60
- package/dist/lib/draftToolingInstall.mjs +7 -68
- package/dist/lib/git/detect-drift.mjs +4 -208
- package/dist/lib/learning/adapt.mjs +6 -101
- package/dist/lib/learning/observe.mjs +1 -119
- package/dist/lib/learning/patternDetector.mjs +1 -298
- package/dist/lib/learning/profile.mjs +2 -279
- package/dist/lib/learning/skillSynthesizer.mjs +24 -145
- package/dist/lib/telemetry.mjs +19 -269
- package/dist/lib/templates/index.mjs +1 -131
- package/dist/lib/theme/scanner.mjs +4 -343
- package/dist/lib/ui/errors.mjs +1 -142
- package/dist/lib/ui/output.mjs +6 -95
- package/dist/lib/ui/prompts.mjs +6 -147
- package/dist/lib/vsCodeCopilotHooksInstall.mjs +1 -42
- package/package.json +2 -4
- package/scripts/postinstall.js +2 -2
|
@@ -1,460 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* external service usage (Stripe, S3, SendGrid, etc.).
|
|
7
|
-
*
|
|
8
|
-
* Sprint 4 additions:
|
|
9
|
-
* - Route discovery (Express / Fastify / Next.js App Router / Next.js Pages API)
|
|
10
|
-
* - HTTP URL extraction — captures actual URL strings, not just call patterns
|
|
11
|
-
* - Capability name inference from route paths (POST /api/users → CreateUser)
|
|
12
|
-
* - Entry point classification (route handlers + exported functions vs helpers)
|
|
13
|
-
* - --suggest flag: shows untracked entry points as capability candidates
|
|
14
|
-
*
|
|
15
|
-
* Enriches capabilities.json with a `codeAnalysis` block on each capability,
|
|
16
|
-
* and saves the full scan report to inferno/scan.json.
|
|
17
|
-
*
|
|
18
|
-
* Usage:
|
|
19
|
-
* infernoflow scan Scan project, enrich capabilities
|
|
20
|
-
* infernoflow scan --dir src/ Scan specific directory
|
|
21
|
-
* infernoflow scan --json Print scan.json to stdout
|
|
22
|
-
* infernoflow scan --dry-run Print without writing files
|
|
23
|
-
* infernoflow scan --capability auth-login Scan one capability only
|
|
24
|
-
* infernoflow scan --suggest Show untracked entry points as new capability candidates
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import * as fs from "node:fs";
|
|
28
|
-
import * as path from "node:path";
|
|
29
|
-
import { createRequire } from "node:module";
|
|
30
|
-
import { execSync } from "node:child_process";
|
|
31
|
-
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
32
|
-
|
|
33
|
-
const require = createRequire(import.meta.url);
|
|
34
|
-
|
|
35
|
-
// ── TypeScript compiler API (global install) ──────────────────────────────────
|
|
36
|
-
|
|
37
|
-
const TS_PATHS = [
|
|
38
|
-
"/usr/local/lib/node_modules_global/lib/node_modules/typescript",
|
|
39
|
-
"/usr/lib/node_modules/typescript",
|
|
40
|
-
path.join(process.env.HOME || "", ".npm-global/lib/node_modules/typescript"),
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
function loadTypeScript() {
|
|
44
|
-
for (const p of TS_PATHS) {
|
|
45
|
-
try { return require(path.join(p, "lib/typescript.js")); } catch {}
|
|
46
|
-
}
|
|
47
|
-
try { return require("typescript"); } catch {}
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const ts = loadTypeScript();
|
|
52
|
-
|
|
53
|
-
// ── external service fingerprints ────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
const SERVICE_PATTERNS = [
|
|
56
|
-
{ service: "stripe", patterns: ["stripe", "Stripe", "createPaymentIntent", "charges.create"] },
|
|
57
|
-
{ service: "sendgrid", patterns: ["sendgrid", "@sendgrid", "sgMail", "sendgrid.send"] },
|
|
58
|
-
{ service: "ses", patterns: ["SES", "ses.sendEmail", "aws-sdk/ses", "nodemailer"] },
|
|
59
|
-
{ service: "s3", patterns: ["S3", "s3.upload", "s3.getObject", "PutObjectCommand", "@aws-sdk/s3"] },
|
|
60
|
-
{ service: "redis", patterns: ["redis", "Redis", "ioredis", "createClient"] },
|
|
61
|
-
{ service: "jwt", patterns: ["jwt", "jsonwebtoken", "sign(", "verify(", "decode("] },
|
|
62
|
-
{ service: "bcrypt", patterns: ["bcrypt", "argon2", "scrypt", "hashSync", "compare("] },
|
|
63
|
-
{ service: "prisma", patterns: ["prisma.", "PrismaClient", "@prisma/client"] },
|
|
64
|
-
{ service: "mongoose", patterns: ["mongoose", ".save()", ".findOne(", ".aggregate("] },
|
|
65
|
-
{ service: "postgres", patterns: ["pg", "Pool(", "Client(", "query(", "postgres("] },
|
|
66
|
-
{ service: "mysql", patterns: ["mysql", "mysql2", "createConnection"] },
|
|
67
|
-
{ service: "graphql", patterns: ["graphql", "gql`", "ApolloServer", "GraphQLSchema"] },
|
|
68
|
-
{ service: "firebase", patterns: ["firebase", "firestore", "initializeApp"] },
|
|
69
|
-
{ service: "twilio", patterns: ["twilio", "Twilio(", "messages.create"] },
|
|
70
|
-
{ service: "openai", patterns: ["openai", "OpenAI(", "createCompletion", "chat.completions"] },
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
function detectServices(text) {
|
|
74
|
-
const found = new Set();
|
|
75
|
-
for (const { service, patterns } of SERVICE_PATTERNS) {
|
|
76
|
-
if (patterns.some(p => text.includes(p))) found.add(service);
|
|
77
|
-
}
|
|
78
|
-
return [...found];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ── DB call patterns ──────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
const DB_PATTERNS = [
|
|
84
|
-
/\.(find|findOne|findMany|findById|findAll)\s*\(/g,
|
|
85
|
-
/\.(create|insert|insertOne|insertMany|save)\s*\(/g,
|
|
86
|
-
/\.(update|updateOne|updateMany|updateById|upsert)\s*\(/g,
|
|
87
|
-
/\.(delete|deleteOne|deleteMany|remove|destroy)\s*\(/g,
|
|
88
|
-
/\.(query|execute|raw)\s*\(/g,
|
|
89
|
-
/\.(aggregate|groupBy|count|sum)\s*\(/g,
|
|
90
|
-
/db\.\w+\s*\(/g,
|
|
91
|
-
/prisma\.\w+\.\w+\s*\(/g,
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
function detectDbCalls(text) {
|
|
95
|
-
const calls = new Set();
|
|
96
|
-
for (const re of DB_PATTERNS) {
|
|
97
|
-
const r = new RegExp(re.source, "g");
|
|
98
|
-
let m;
|
|
99
|
-
while ((m = r.exec(text)) !== null) calls.add(m[0].replace(/\s*\($/, "()"));
|
|
100
|
-
}
|
|
101
|
-
return [...calls].slice(0, 10);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── HTTP call patterns + URL extraction ──────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
const HTTP_PATTERNS = [
|
|
107
|
-
/fetch\s*\(/g,
|
|
108
|
-
/axios\.(get|post|put|patch|delete)\s*\(/g,
|
|
109
|
-
/http\.(get|post|request)\s*\(/g,
|
|
110
|
-
/got\.(get|post|put|delete)\s*\(/g,
|
|
111
|
-
/request\.(get|post|put|delete)\s*\(/g,
|
|
112
|
-
/\$http\.(get|post|put|delete)\s*\(/g,
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
function detectHttpCalls(text) {
|
|
116
|
-
const calls = new Set();
|
|
117
|
-
for (const re of HTTP_PATTERNS) {
|
|
118
|
-
const r = new RegExp(re.source, "g");
|
|
119
|
-
let m;
|
|
120
|
-
while ((m = r.exec(text)) !== null) calls.add(m[0].replace(/\s*\($/, "()"));
|
|
121
|
-
}
|
|
122
|
-
return [...calls].slice(0, 8);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Extract actual URL strings from HTTP calls.
|
|
127
|
-
* axios.post('/api/users', data) → { method: 'POST', url: '/api/users' }
|
|
128
|
-
* fetch('/api/tasks') → { method: 'GET', url: '/api/tasks' }
|
|
129
|
-
*/
|
|
130
|
-
const HTTP_URL_CALL_RE = /(?:(?:axios|got|request|\$http)\.(get|post|put|patch|delete)\s*\(\s*|fetch\s*\(\s*)['"`]([^'"`\s\)]+)['"`]/g;
|
|
131
|
-
|
|
132
|
-
function extractHttpCallUrls(text) {
|
|
133
|
-
const calls = [];
|
|
134
|
-
const r = new RegExp(HTTP_URL_CALL_RE.source, "g");
|
|
135
|
-
let m;
|
|
136
|
-
while ((m = r.exec(text)) !== null) {
|
|
137
|
-
const methodLiteral = m[1]; // undefined for fetch
|
|
138
|
-
const url = m[2];
|
|
139
|
-
// Only internal paths (start with / or contain /api/)
|
|
140
|
-
if (!url.startsWith("/") && !url.includes("/api/")) continue;
|
|
141
|
-
const method = methodLiteral ? methodLiteral.toUpperCase() : "GET";
|
|
142
|
-
calls.push({ method, url });
|
|
143
|
-
}
|
|
144
|
-
return calls;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ── Route discovery ───────────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options", "all"];
|
|
150
|
-
|
|
151
|
-
// Express/Koa/Hapi: app.get('/path', fn) or router.post('/path', fn)
|
|
152
|
-
const ROUTE_RE = new RegExp(
|
|
153
|
-
`(?:app|router|server|api|routes?)\\.(${HTTP_METHODS.join("|")})\\s*\\(\\s*['"\`]([^'"\`\\s)]+)['"\`]`,
|
|
154
|
-
"g"
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
// Fastify: fastify.route({ method: 'POST', url: '/path' ... })
|
|
158
|
-
const FASTIFY_ROUTE_RE = /fastify\.route\s*\(\s*\{[^}]*?method\s*:\s*['"](\w+)['"][^}]*?url\s*:\s*['"]([^'"]+)['"]/gs;
|
|
159
|
-
|
|
160
|
-
// Express router.route('/path').get(...) chaining
|
|
161
|
-
const ROUTE_CHAIN_RE = /(?:app|router)\.route\s*\(\s*['"`]([^'"`\s)]+)['"`]\s*\)\s*\.(get|post|put|patch|delete)/g;
|
|
162
|
-
|
|
163
|
-
// Next.js App Router: export async function GET(req) in route.ts/route.js
|
|
164
|
-
const NEXT_EXPORT_RE = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(/g;
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Extract route definitions from a source file.
|
|
168
|
-
* Returns: [{ method, path, source, filePath, loc }]
|
|
169
|
-
*/
|
|
170
|
-
function extractRoutes(filePath, code) {
|
|
171
|
-
const routes = [];
|
|
172
|
-
const isNextAppRouter = /app[/\\].*route\.[jt]sx?$/.test(filePath) ||
|
|
173
|
-
/app[/\\].*\broute\b.*\.[jt]sx?$/.test(filePath);
|
|
174
|
-
const isNextApiPages = /pages[/\\]api[/\\]/.test(filePath);
|
|
175
|
-
|
|
176
|
-
let m;
|
|
177
|
-
|
|
178
|
-
// Express/Koa style
|
|
179
|
-
const rr = new RegExp(ROUTE_RE.source, "g");
|
|
180
|
-
while ((m = rr.exec(code)) !== null) {
|
|
181
|
-
const method = m[1].toUpperCase();
|
|
182
|
-
if (method === "ALL") continue; // skip catch-alls for capability inference
|
|
183
|
-
routes.push({
|
|
184
|
-
method,
|
|
185
|
-
path: m[2],
|
|
186
|
-
source: "express",
|
|
187
|
-
filePath,
|
|
188
|
-
loc: code.slice(0, m.index).split("\n").length,
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Fastify route()
|
|
193
|
-
const fr = new RegExp(FASTIFY_ROUTE_RE.source, "gs");
|
|
194
|
-
while ((m = fr.exec(code)) !== null) {
|
|
195
|
-
routes.push({
|
|
196
|
-
method: m[1].toUpperCase(),
|
|
197
|
-
path: m[2],
|
|
198
|
-
source: "fastify",
|
|
199
|
-
filePath,
|
|
200
|
-
loc: code.slice(0, m.index).split("\n").length,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Express router.route('/path').get(...)
|
|
205
|
-
const cr = new RegExp(ROUTE_CHAIN_RE.source, "g");
|
|
206
|
-
while ((m = cr.exec(code)) !== null) {
|
|
207
|
-
routes.push({
|
|
208
|
-
method: m[2].toUpperCase(),
|
|
209
|
-
path: m[1],
|
|
210
|
-
source: "express-chain",
|
|
211
|
-
filePath,
|
|
212
|
-
loc: code.slice(0, m.index).split("\n").length,
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Next.js App Router
|
|
217
|
-
if (isNextAppRouter) {
|
|
218
|
-
const nr = new RegExp(NEXT_EXPORT_RE.source, "g");
|
|
219
|
-
while ((m = nr.exec(code)) !== null) {
|
|
220
|
-
// Infer URL from file path: app/users/[id]/route.ts → /users/:id
|
|
221
|
-
const routePath = filePath
|
|
222
|
-
.replace(/\\/g, "/")
|
|
223
|
-
.replace(/.*\/app\//, "/")
|
|
224
|
-
.replace(/\/route\.[jt]sx?$/, "")
|
|
225
|
-
.replace(/\[([^\]]+)\]/g, ":$1") || "/";
|
|
226
|
-
routes.push({
|
|
227
|
-
method: m[1].toUpperCase(),
|
|
228
|
-
path: routePath,
|
|
229
|
-
source: "next-app",
|
|
230
|
-
filePath,
|
|
231
|
-
loc: code.slice(0, m.index).split("\n").length,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Next.js Pages API (export default handler)
|
|
237
|
-
if (isNextApiPages) {
|
|
238
|
-
const routePath = filePath
|
|
239
|
-
.replace(/\\/g, "/")
|
|
240
|
-
.replace(/.*\/pages\/api\//, "/api/")
|
|
241
|
-
.replace(/\.[jt]sx?$/, "")
|
|
242
|
-
.replace(/\/index$/, "")
|
|
243
|
-
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
244
|
-
routes.push({
|
|
245
|
-
method: "*",
|
|
246
|
-
path: routePath || "/api",
|
|
247
|
-
source: "next-pages",
|
|
248
|
-
filePath,
|
|
249
|
-
loc: 1,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return routes;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ── Capability name inference from routes ─────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Derive a human-readable capability name from a route.
|
|
260
|
-
* POST /api/users → CreateUser
|
|
261
|
-
* GET /api/users/:id → GetUser
|
|
262
|
-
* DELETE /api/tasks/:id → DeleteTask
|
|
263
|
-
* GET /api/tasks/:id/comments → ListTaskComment
|
|
264
|
-
* PUT /api/upload → UpdateUpload
|
|
265
|
-
*/
|
|
266
|
-
function capNameFromRoute(method, routePath) {
|
|
267
|
-
// Normalise: strip leading /api or /v1 etc.
|
|
268
|
-
const clean = routePath
|
|
269
|
-
.replace(/^\/+/, "")
|
|
270
|
-
.replace(/^api\/v?\d+\//, "")
|
|
271
|
-
.replace(/^api\//, "");
|
|
272
|
-
|
|
273
|
-
const parts = clean.split("/").filter(Boolean);
|
|
274
|
-
const resources = parts.filter(p => !p.startsWith(":"));
|
|
275
|
-
const hasId = parts.some(p => p.startsWith(":"));
|
|
276
|
-
|
|
277
|
-
const noun = resources[resources.length - 1] || "Resource";
|
|
278
|
-
const parent = resources.length > 1 ? resources[resources.length - 2] : null;
|
|
279
|
-
|
|
280
|
-
const singularize = (s) => {
|
|
281
|
-
if (s.endsWith("ies")) return s.slice(0, -3) + "y";
|
|
282
|
-
if (s.endsWith("ses")) return s.slice(0, -2);
|
|
283
|
-
if (s.endsWith("s") && !s.endsWith("ss")) return s.slice(0, -1);
|
|
284
|
-
return s;
|
|
285
|
-
};
|
|
286
|
-
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
287
|
-
const toCamel = (s) => s.split(/[-_]/).map(capitalize).join("");
|
|
288
|
-
|
|
289
|
-
const nounCap = capitalize(toCamel(singularize(noun)));
|
|
290
|
-
const parentCap = parent ? capitalize(toCamel(singularize(parent))) : "";
|
|
291
|
-
|
|
292
|
-
const verbMap = {
|
|
293
|
-
GET: hasId ? "Get" : "List",
|
|
294
|
-
POST: hasId ? "Add" : "Create",
|
|
295
|
-
PUT: "Update",
|
|
296
|
-
PATCH: "Update",
|
|
297
|
-
DELETE: "Delete",
|
|
298
|
-
HEAD: "Check",
|
|
299
|
-
OPTIONS: "Options",
|
|
300
|
-
"*": "Handle",
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const verb = verbMap[method] || "Handle";
|
|
304
|
-
|
|
305
|
-
// Nested resource: /tasks/:id/comments → ListTaskComment
|
|
306
|
-
if (parentCap && resources.length > 1) return `${verb}${parentCap}${nounCap}`;
|
|
307
|
-
return `${verb}${nounCap}`;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Convert a capability name to a kebab-case id.
|
|
312
|
-
* CreateUser → create-user
|
|
313
|
-
*/
|
|
314
|
-
function nameToId(name) {
|
|
315
|
-
return name
|
|
316
|
-
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
317
|
-
.toLowerCase();
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ── TypeScript / JavaScript AST analysis ─────────────────────────────────────
|
|
321
|
-
|
|
322
|
-
function getNodeName(node) {
|
|
323
|
-
if (!ts) return null;
|
|
324
|
-
if (node.name && ts.isIdentifier(node.name)) return node.name.text;
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Walk all descendants of root using node.forEachChild (instance method).
|
|
330
|
-
* Collects all call expressions and throw statements globally,
|
|
331
|
-
* then assigns them to containing functions by source position range.
|
|
332
|
-
*/
|
|
333
|
-
function collectAllNodes(root) {
|
|
334
|
-
const calls = []; // { pos, end, name }
|
|
335
|
-
const throws = []; // { pos, end, name }
|
|
336
|
-
|
|
337
|
-
function walk(node) {
|
|
338
|
-
if (ts.isCallExpression(node)) {
|
|
339
|
-
const expr = node.expression;
|
|
340
|
-
if (ts.isIdentifier(expr)) {
|
|
341
|
-
calls.push({ pos: node.pos, end: node.end, name: expr.text + "()" });
|
|
342
|
-
} else if (ts.isPropertyAccessExpression(expr)) {
|
|
343
|
-
calls.push({ pos: node.pos, end: node.end, name: expr.name.text + "()" });
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (ts.isThrowStatement(node) && node.expression) {
|
|
347
|
-
if (ts.isNewExpression(node.expression) && ts.isIdentifier(node.expression.expression)) {
|
|
348
|
-
throws.push({ pos: node.pos, end: node.end, name: node.expression.expression.text });
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
node.forEachChild?.(walk);
|
|
352
|
-
}
|
|
353
|
-
walk(root);
|
|
354
|
-
return { calls, throws };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function callsInRange(allCalls, pos, end) {
|
|
358
|
-
return [...new Set(
|
|
359
|
-
allCalls.filter(c => c.pos >= pos && c.end <= end).map(c => c.name)
|
|
360
|
-
)].slice(0, 20);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function throwsInRange(allThrows, pos, end) {
|
|
364
|
-
return [...new Set(
|
|
365
|
-
allThrows.filter(t => t.pos >= pos && t.end <= end).map(t => t.name)
|
|
366
|
-
)];
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function isFunctionNode(node) {
|
|
370
|
-
if (!ts) return false;
|
|
371
|
-
return (
|
|
372
|
-
ts.isFunctionDeclaration(node) ||
|
|
373
|
-
ts.isFunctionExpression(node) ||
|
|
374
|
-
ts.isArrowFunction(node) ||
|
|
375
|
-
ts.isMethodDeclaration(node)
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function getParentVariableName(node) {
|
|
380
|
-
// For arrow functions assigned to const: const foo = () => {}
|
|
381
|
-
if (!ts) return null;
|
|
382
|
-
if (node.parent && ts.isVariableDeclaration(node.parent)) {
|
|
383
|
-
return getNodeName(node.parent);
|
|
384
|
-
}
|
|
385
|
-
if (node.parent && ts.isPropertyAssignment(node.parent)) {
|
|
386
|
-
return getNodeName(node.parent);
|
|
387
|
-
}
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Check whether a node has the `export` modifier.
|
|
393
|
-
*/
|
|
394
|
-
function isExportedNode(node) {
|
|
395
|
-
if (!ts) return false;
|
|
396
|
-
try {
|
|
397
|
-
const flags = ts.getCombinedModifierFlags(node);
|
|
398
|
-
return !!(flags & ts.ModifierFlags.Export);
|
|
399
|
-
} catch { return false; }
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function analyzeJsTs(filePath, code) {
|
|
403
|
-
if (!ts) return null;
|
|
404
|
-
|
|
405
|
-
let srcFile;
|
|
406
|
-
try {
|
|
407
|
-
srcFile = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, /*setParentNodes*/ true);
|
|
408
|
-
} catch {
|
|
409
|
-
return null;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Collect ALL call/throw nodes in one pass from root
|
|
413
|
-
const { calls: allCalls, throws: allThrows } = collectAllNodes(srcFile);
|
|
414
|
-
|
|
415
|
-
const functions = [];
|
|
416
|
-
|
|
417
|
-
function visit(node) {
|
|
418
|
-
if (isFunctionNode(node)) {
|
|
419
|
-
const name = getNodeName(node) || getParentVariableName(node) || "<anonymous>";
|
|
420
|
-
const text = code.slice(node.pos, node.end);
|
|
421
|
-
const calls = callsInRange(allCalls, node.pos, node.end);
|
|
422
|
-
const throws = throwsInRange(allThrows, node.pos, node.end);
|
|
423
|
-
|
|
424
|
-
// Check export status: either node itself or parent VariableStatement is exported
|
|
425
|
-
let exported = isExportedNode(node);
|
|
426
|
-
if (!exported && node.parent) {
|
|
427
|
-
// const foo = () => {} inside export const foo = ...
|
|
428
|
-
if (ts.isVariableDeclaration(node.parent) && node.parent.parent) {
|
|
429
|
-
const varList = node.parent.parent;
|
|
430
|
-
if (ts.isVariableDeclarationList(varList) && varList.parent) {
|
|
431
|
-
exported = isExportedNode(varList.parent);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
functions.push({
|
|
437
|
-
name,
|
|
438
|
-
calls,
|
|
439
|
-
throws,
|
|
440
|
-
services: detectServices(text),
|
|
441
|
-
dbCalls: detectDbCalls(text),
|
|
442
|
-
httpCalls: detectHttpCalls(text),
|
|
443
|
-
httpCallUrls: extractHttpCallUrls(text),
|
|
444
|
-
isExported: exported,
|
|
445
|
-
loc: srcFile.getLineAndCharacterOfPosition(node.pos).line + 1,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
node.forEachChild?.(visit);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
visit(srcFile);
|
|
452
|
-
return functions;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ── Python AST analysis via child_process ─────────────────────────────────────
|
|
456
|
-
|
|
457
|
-
const PYTHON_SCRIPT = `
|
|
1
|
+
import*as j from"node:fs";import*as x from"node:path";import{createRequire as re}from"node:module";import{execSync as ie}from"node:child_process";import{bold as _,cyan as T,gray as d,green as F,yellow as R,red as D}from"../ui/output.mjs";const G=re(import.meta.url),le=["/usr/local/lib/node_modules_global/lib/node_modules/typescript","/usr/lib/node_modules/typescript",x.join(process.env.HOME||"",".npm-global/lib/node_modules/typescript")];function ce(){for(const e of le)try{return G(x.join(e,"lib/typescript.js"))}catch{}try{return G("typescript")}catch{}return null}const g=ce(),ae=[{service:"stripe",patterns:["stripe","Stripe","createPaymentIntent","charges.create"]},{service:"sendgrid",patterns:["sendgrid","@sendgrid","sgMail","sendgrid.send"]},{service:"ses",patterns:["SES","ses.sendEmail","aws-sdk/ses","nodemailer"]},{service:"s3",patterns:["S3","s3.upload","s3.getObject","PutObjectCommand","@aws-sdk/s3"]},{service:"redis",patterns:["redis","Redis","ioredis","createClient"]},{service:"jwt",patterns:["jwt","jsonwebtoken","sign(","verify(","decode("]},{service:"bcrypt",patterns:["bcrypt","argon2","scrypt","hashSync","compare("]},{service:"prisma",patterns:["prisma.","PrismaClient","@prisma/client"]},{service:"mongoose",patterns:["mongoose",".save()",".findOne(",".aggregate("]},{service:"postgres",patterns:["pg","Pool(","Client(","query(","postgres("]},{service:"mysql",patterns:["mysql","mysql2","createConnection"]},{service:"graphql",patterns:["graphql","gql`","ApolloServer","GraphQLSchema"]},{service:"firebase",patterns:["firebase","firestore","initializeApp"]},{service:"twilio",patterns:["twilio","Twilio(","messages.create"]},{service:"openai",patterns:["openai","OpenAI(","createCompletion","chat.completions"]}];function I(e){const t=new Set;for(const{service:r,patterns:n}of ae)n.some(s=>e.includes(s))&&t.add(r);return[...t]}const pe=[/\.(find|findOne|findMany|findById|findAll)\s*\(/g,/\.(create|insert|insertOne|insertMany|save)\s*\(/g,/\.(update|updateOne|updateMany|updateById|upsert)\s*\(/g,/\.(delete|deleteOne|deleteMany|remove|destroy)\s*\(/g,/\.(query|execute|raw)\s*\(/g,/\.(aggregate|groupBy|count|sum)\s*\(/g,/db\.\w+\s*\(/g,/prisma\.\w+\.\w+\s*\(/g];function M(e){const t=new Set;for(const r of pe){const n=new RegExp(r.source,"g");let s;for(;(s=n.exec(e))!==null;)t.add(s[0].replace(/\s*\($/,"()"))}return[...t].slice(0,10)}const ue=[/fetch\s*\(/g,/axios\.(get|post|put|patch|delete)\s*\(/g,/http\.(get|post|request)\s*\(/g,/got\.(get|post|put|delete)\s*\(/g,/request\.(get|post|put|delete)\s*\(/g,/\$http\.(get|post|put|delete)\s*\(/g];function H(e){const t=new Set;for(const r of ue){const n=new RegExp(r.source,"g");let s;for(;(s=n.exec(e))!==null;)t.add(s[0].replace(/\s*\($/,"()"))}return[...t].slice(0,8)}const fe=/(?:(?:axios|got|request|\$http)\.(get|post|put|patch|delete)\s*\(\s*|fetch\s*\(\s*)['"`]([^'"`\s\)]+)['"`]/g;function L(e){const t=[],r=new RegExp(fe.source,"g");let n;for(;(n=r.exec(e))!==null;){const s=n[1],o=n[2];if(!o.startsWith("/")&&!o.includes("/api/"))continue;const l=s?s.toUpperCase():"GET";t.push({method:l,url:o})}return t}const de=["get","post","put","patch","delete","head","options","all"],ge=new RegExp(`(?:app|router|server|api|routes?)\\.(${de.join("|")})\\s*\\(\\s*['"\`]([^'"\`\\s)]+)['"\`]`,"g"),he=/fastify\.route\s*\(\s*\{[^}]*?method\s*:\s*['"](\w+)['"][^}]*?url\s*:\s*['"]([^'"]+)['"]/gs,me=/(?:app|router)\.route\s*\(\s*['"`]([^'"`\s)]+)['"`]\s*\)\s*\.(get|post|put|patch|delete)/g,ye=/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(/g;function we(e,t){const r=[],n=/app[/\\].*route\.[jt]sx?$/.test(e)||/app[/\\].*\broute\b.*\.[jt]sx?$/.test(e),s=/pages[/\\]api[/\\]/.test(e);let o;const l=new RegExp(ge.source,"g");for(;(o=l.exec(t))!==null;){const p=o[1].toUpperCase();p!=="ALL"&&r.push({method:p,path:o[2],source:"express",filePath:e,loc:t.slice(0,o.index).split(`
|
|
2
|
+
`).length})}const i=new RegExp(he.source,"gs");for(;(o=i.exec(t))!==null;)r.push({method:o[1].toUpperCase(),path:o[2],source:"fastify",filePath:e,loc:t.slice(0,o.index).split(`
|
|
3
|
+
`).length});const a=new RegExp(me.source,"g");for(;(o=a.exec(t))!==null;)r.push({method:o[2].toUpperCase(),path:o[1],source:"express-chain",filePath:e,loc:t.slice(0,o.index).split(`
|
|
4
|
+
`).length});if(n){const p=new RegExp(ye.source,"g");for(;(o=p.exec(t))!==null;){const c=e.replace(/\\/g,"/").replace(/.*\/app\//,"/").replace(/\/route\.[jt]sx?$/,"").replace(/\[([^\]]+)\]/g,":$1")||"/";r.push({method:o[1].toUpperCase(),path:c,source:"next-app",filePath:e,loc:t.slice(0,o.index).split(`
|
|
5
|
+
`).length})}}if(s){const p=e.replace(/\\/g,"/").replace(/.*\/pages\/api\//,"/api/").replace(/\.[jt]sx?$/,"").replace(/\/index$/,"").replace(/\[([^\]]+)\]/g,":$1");r.push({method:"*",path:p||"/api",source:"next-pages",filePath:e,loc:1})}return r}function k(e,t){const n=t.replace(/^\/+/,"").replace(/^api\/v?\d+\//,"").replace(/^api\//,"").split("/").filter(Boolean),s=n.filter(h=>!h.startsWith(":")),o=n.some(h=>h.startsWith(":")),l=s[s.length-1]||"Resource",i=s.length>1?s[s.length-2]:null,a=h=>h.endsWith("ies")?h.slice(0,-3)+"y":h.endsWith("ses")?h.slice(0,-2):h.endsWith("s")&&!h.endsWith("ss")?h.slice(0,-1):h,p=h=>h.charAt(0).toUpperCase()+h.slice(1),c=h=>h.split(/[-_]/).map(p).join(""),f=p(c(a(l))),m=i?p(c(a(i))):"",y={GET:o?"Get":"List",POST:o?"Add":"Create",PUT:"Update",PATCH:"Update",DELETE:"Delete",HEAD:"Check",OPTIONS:"Options","*":"Handle"}[e]||"Handle";return m&&s.length>1?`${y}${m}${f}`:`${y}${f}`}function K(e){return e.replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase()}function z(e){return g&&e.name&&g.isIdentifier(e.name)?e.name.text:null}function xe(e){const t=[],r=[];function n(s){if(g.isCallExpression(s)){const o=s.expression;g.isIdentifier(o)?t.push({pos:s.pos,end:s.end,name:o.text+"()"}):g.isPropertyAccessExpression(o)&&t.push({pos:s.pos,end:s.end,name:o.name.text+"()"})}g.isThrowStatement(s)&&s.expression&&g.isNewExpression(s.expression)&&g.isIdentifier(s.expression.expression)&&r.push({pos:s.pos,end:s.end,name:s.expression.expression.text}),s.forEachChild?.(n)}return n(e),{calls:t,throws:r}}function Ce(e,t,r){return[...new Set(e.filter(n=>n.pos>=t&&n.end<=r).map(n=>n.name))].slice(0,20)}function be(e,t,r){return[...new Set(e.filter(n=>n.pos>=t&&n.end<=r).map(n=>n.name))]}function $e(e){return g?g.isFunctionDeclaration(e)||g.isFunctionExpression(e)||g.isArrowFunction(e)||g.isMethodDeclaration(e):!1}function Se(e){return g&&(e.parent&&g.isVariableDeclaration(e.parent)||e.parent&&g.isPropertyAssignment(e.parent))?z(e.parent):null}function V(e){if(!g)return!1;try{return!!(g.getCombinedModifierFlags(e)&g.ModifierFlags.Export)}catch{return!1}}function Ee(e,t){if(!g)return null;let r;try{r=g.createSourceFile(e,t,g.ScriptTarget.Latest,!0)}catch{return null}const{calls:n,throws:s}=xe(r),o=[];function l(i){if($e(i)){const a=z(i)||Se(i)||"<anonymous>",p=t.slice(i.pos,i.end),c=Ce(n,i.pos,i.end),f=be(s,i.pos,i.end);let m=V(i);if(!m&&i.parent&&g.isVariableDeclaration(i.parent)&&i.parent.parent){const w=i.parent.parent;g.isVariableDeclarationList(w)&&w.parent&&(m=V(w.parent))}o.push({name:a,calls:c,throws:f,services:I(p),dbCalls:M(p),httpCalls:H(p),httpCallUrls:L(p),isExported:m,loc:r.getLineAndCharacterOfPosition(i.pos).line+1})}i.forEachChild?.(l)}return l(r),o}const ve=`
|
|
458
6
|
import ast, json, sys
|
|
459
7
|
|
|
460
8
|
def get_calls(node):
|
|
@@ -492,664 +40,9 @@ try:
|
|
|
492
40
|
print(json.dumps(functions))
|
|
493
41
|
except Exception as e:
|
|
494
42
|
print(json.dumps([]))
|
|
495
|
-
`;
|
|
496
|
-
|
|
497
|
-
function
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
{ timeout: 8000, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
502
|
-
);
|
|
503
|
-
const fns = JSON.parse(result.trim() || "[]");
|
|
504
|
-
// add service/db/http detection from raw file text
|
|
505
|
-
const code = fs.readFileSync(filePath, "utf8");
|
|
506
|
-
return fns.map(f => ({
|
|
507
|
-
...f,
|
|
508
|
-
services: detectServices(code),
|
|
509
|
-
dbCalls: detectDbCalls(code),
|
|
510
|
-
httpCalls: detectHttpCalls(code),
|
|
511
|
-
httpCallUrls: extractHttpCallUrls(code),
|
|
512
|
-
isExported: false,
|
|
513
|
-
}));
|
|
514
|
-
} catch {
|
|
515
|
-
return null;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// ── regex fallback (Go, Ruby, Java, other) ────────────────────────────────────
|
|
520
|
-
|
|
521
|
-
const FUNC_PATTERNS = [
|
|
522
|
-
{ re: /^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(/gm, lang: "go" },
|
|
523
|
-
{ re: /^\s*(?:def|async def)\s+(\w+)\s*\(/gm, lang: "py" },
|
|
524
|
-
{ re: /^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)?(\w+)\s*\(/gm, lang: "java" },
|
|
525
|
-
{ re: /^\s*def\s+(\w+)\s*[\(\|]/gm, lang: "rb" },
|
|
526
|
-
];
|
|
527
|
-
|
|
528
|
-
function analyzeWithRegex(filePath, code) {
|
|
529
|
-
const ext = path.extname(filePath).slice(1);
|
|
530
|
-
const pattern = FUNC_PATTERNS.find(p => p.lang === ext);
|
|
531
|
-
if (!pattern) return null;
|
|
532
|
-
|
|
533
|
-
const functions = [];
|
|
534
|
-
const r = new RegExp(pattern.re.source, "gm");
|
|
535
|
-
let m;
|
|
536
|
-
while ((m = r.exec(code)) !== null) {
|
|
537
|
-
// grab up to 60 lines after the match for context
|
|
538
|
-
const start = m.index;
|
|
539
|
-
const end = Math.min(start + 2000, code.length);
|
|
540
|
-
const chunk = code.slice(start, end);
|
|
541
|
-
functions.push({
|
|
542
|
-
name: m[1],
|
|
543
|
-
calls: [],
|
|
544
|
-
throws: [],
|
|
545
|
-
services: detectServices(chunk),
|
|
546
|
-
dbCalls: detectDbCalls(chunk),
|
|
547
|
-
httpCalls: detectHttpCalls(chunk),
|
|
548
|
-
httpCallUrls: extractHttpCallUrls(chunk),
|
|
549
|
-
isExported: false,
|
|
550
|
-
loc: code.slice(0, start).split("\n").length,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
return functions.length > 0 ? functions : null;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ── file walker ───────────────────────────────────────────────────────────────
|
|
557
|
-
|
|
558
|
-
const SKIP_DIRS = new Set([
|
|
559
|
-
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt",
|
|
560
|
-
"coverage", "__pycache__", ".pytest_cache", "vendor", "tmp", ".turbo",
|
|
561
|
-
"target", ".gradle", "public", "static", "assets",
|
|
562
|
-
]);
|
|
563
|
-
|
|
564
|
-
const SUPPORTED_EXTS = new Set([
|
|
565
|
-
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
566
|
-
".py", ".go", ".rb", ".java",
|
|
567
|
-
]);
|
|
568
|
-
|
|
569
|
-
const TEST_FILE = /\.(test|spec)\.[jt]sx?$|_test\.(go|py|rb)|spec\.(rb|js|ts)$/;
|
|
570
|
-
|
|
571
|
-
function* walkFiles(dir) {
|
|
572
|
-
let entries;
|
|
573
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
574
|
-
catch { return; }
|
|
575
|
-
for (const e of entries) {
|
|
576
|
-
if (e.isDirectory()) {
|
|
577
|
-
if (!SKIP_DIRS.has(e.name)) yield* walkFiles(path.join(dir, e.name));
|
|
578
|
-
} else if (e.isFile()) {
|
|
579
|
-
const ext = path.extname(e.name);
|
|
580
|
-
if (SUPPORTED_EXTS.has(ext) && !TEST_FILE.test(e.name)) {
|
|
581
|
-
yield path.join(dir, e.name);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// ── per-file analyzer ─────────────────────────────────────────────────────────
|
|
588
|
-
|
|
589
|
-
function analyzeFile(filePath) {
|
|
590
|
-
let code;
|
|
591
|
-
try { code = fs.readFileSync(filePath, "utf8"); }
|
|
592
|
-
catch { return { functions: [], routes: [] }; }
|
|
593
|
-
|
|
594
|
-
const ext = path.extname(filePath);
|
|
595
|
-
let functions = [];
|
|
596
|
-
|
|
597
|
-
if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) {
|
|
598
|
-
functions = analyzeJsTs(filePath, code) || analyzeWithRegex(filePath, code) || [];
|
|
599
|
-
} else if (ext === ".py") {
|
|
600
|
-
functions = analyzePython(filePath) || analyzeWithRegex(filePath, code) || [];
|
|
601
|
-
} else {
|
|
602
|
-
functions = analyzeWithRegex(filePath, code) || [];
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
const routes = ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext))
|
|
606
|
-
? extractRoutes(filePath, code)
|
|
607
|
-
: [];
|
|
608
|
-
|
|
609
|
-
return { functions, routes };
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// ── entry point classification ────────────────────────────────────────────────
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Mark each function as isEntryPoint / isHelper based on:
|
|
616
|
-
* 1. It's an exported function
|
|
617
|
-
* 2. It's registered as a route handler (function name appears near a route definition)
|
|
618
|
-
* 3. It directly has HTTP/DB calls (leaf service calls are likely entry-adjacent)
|
|
619
|
-
*/
|
|
620
|
-
function classifyEntryPoints(allFunctions, allRoutes) {
|
|
621
|
-
// Build set of function names used in route registration lines
|
|
622
|
-
// e.g. router.post('/api/x', createUser) — "createUser" is a handler
|
|
623
|
-
const routeHandlerNames = new Set();
|
|
624
|
-
for (const route of allRoutes) {
|
|
625
|
-
if (route.handler) routeHandlerNames.add(route.handler);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Build caller graph
|
|
629
|
-
const calledByCount = new Map(); // name → number of callers
|
|
630
|
-
for (const { fn } of allFunctions) {
|
|
631
|
-
for (const callee of fn.calls || []) {
|
|
632
|
-
const name = callee.replace("()", "");
|
|
633
|
-
calledByCount.set(name, (calledByCount.get(name) || 0) + 1);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return allFunctions.map(({ fn, filePath }) => {
|
|
638
|
-
const isRouteHandler = routeHandlerNames.has(fn.name);
|
|
639
|
-
const isExported = fn.isExported || false;
|
|
640
|
-
const callerCount = calledByCount.get(fn.name) || 0;
|
|
641
|
-
const hasServiceCalls = (fn.dbCalls?.length || 0) + (fn.services?.length || 0) +
|
|
642
|
-
(fn.httpCallUrls?.length || 0) > 0;
|
|
643
|
-
|
|
644
|
-
// Entry point: exported OR a known route handler
|
|
645
|
-
// Also treat functions with service calls that are NOT called by anyone as entry-point candidates
|
|
646
|
-
const isEntryPoint = isRouteHandler || isExported ||
|
|
647
|
-
(hasServiceCalls && callerCount === 0);
|
|
648
|
-
const isHelper = !isEntryPoint && callerCount > 0;
|
|
649
|
-
|
|
650
|
-
return {
|
|
651
|
-
fn: { ...fn, isEntryPoint, isHelper, callerCount },
|
|
652
|
-
filePath,
|
|
653
|
-
};
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ── HTTP chain resolver ───────────────────────────────────────────────────────
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Build a route index for fast URL lookup.
|
|
661
|
-
* Normalises dynamic segments: /users/:id and /users/123 both map to the same key.
|
|
662
|
-
* Returns: Map<"METHOD /normalised/path" → route>
|
|
663
|
-
*/
|
|
664
|
-
function buildRouteIndex(allRoutes) {
|
|
665
|
-
const index = new Map();
|
|
666
|
-
|
|
667
|
-
for (const route of allRoutes) {
|
|
668
|
-
// Normalise path: replace :param with :*
|
|
669
|
-
const norm = route.path.replace(/:[^/]+/g, ":*");
|
|
670
|
-
const key = `${route.method} ${norm}`;
|
|
671
|
-
if (!index.has(key)) index.set(key, route);
|
|
672
|
-
// Also index the raw path
|
|
673
|
-
const rawKey = `${route.method} ${route.path}`;
|
|
674
|
-
if (!index.has(rawKey)) index.set(rawKey, route);
|
|
675
|
-
}
|
|
676
|
-
return index;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Match an outbound HTTP call URL to a discovered route.
|
|
681
|
-
* Handles: exact match, normalised dynamic segments, prefix stripping (/api/v1/).
|
|
682
|
-
*/
|
|
683
|
-
function resolveUrl(method, url, routeIndex) {
|
|
684
|
-
// Strip query string
|
|
685
|
-
const cleanUrl = url.split("?")[0];
|
|
686
|
-
|
|
687
|
-
// Try exact match first
|
|
688
|
-
const exact = routeIndex.get(`${method} ${cleanUrl}`);
|
|
689
|
-
if (exact) return exact;
|
|
690
|
-
|
|
691
|
-
// Normalise dynamics and try
|
|
692
|
-
const norm = cleanUrl.replace(/\/[0-9a-f-]{8,}|\/\d+/g, "/:*");
|
|
693
|
-
const normKey = `${method} ${norm}`;
|
|
694
|
-
const normMatch = routeIndex.get(normKey);
|
|
695
|
-
if (normMatch) return normMatch;
|
|
696
|
-
|
|
697
|
-
// Strip common API prefixes and retry
|
|
698
|
-
const stripped = cleanUrl.replace(/^\/api\/v?\d+/, "").replace(/^\/api/, "");
|
|
699
|
-
if (stripped !== cleanUrl) {
|
|
700
|
-
const strippedKey = `${method} ${stripped}`;
|
|
701
|
-
const strippedMatch = routeIndex.get(strippedKey);
|
|
702
|
-
if (strippedMatch) return strippedMatch;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Wildcard method: some routes registered as ALL or *
|
|
706
|
-
const wildKey = `* ${cleanUrl}`;
|
|
707
|
-
return routeIndex.get(wildKey) || null;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Build end-to-end HTTP chains for all functions.
|
|
712
|
-
*
|
|
713
|
-
* For each function that makes outbound HTTP calls, resolve each URL to its
|
|
714
|
-
* route handler and produce a chain entry:
|
|
715
|
-
* { caller, method, url, handler, handlerFile, resolved }
|
|
716
|
-
*
|
|
717
|
-
* Also resolves transitively: if the handler itself calls another route,
|
|
718
|
-
* the chain continues (up to depth 3 to avoid cycles).
|
|
719
|
-
*
|
|
720
|
-
* Returns: Map<callerFnName → ChainStep[]>
|
|
721
|
-
*/
|
|
722
|
-
function buildHttpChains(classifiedFunctions, allRoutes, cwd) {
|
|
723
|
-
const routeIndex = buildRouteIndex(allRoutes);
|
|
724
|
-
|
|
725
|
-
// Build a name → { fn, filePath } index for handler lookup
|
|
726
|
-
const fnByName = new Map();
|
|
727
|
-
for (const { fn, filePath } of classifiedFunctions) {
|
|
728
|
-
if (!fnByName.has(fn.name)) fnByName.set(fn.name, { fn, filePath });
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const chains = new Map(); // callerName → ChainStep[]
|
|
732
|
-
|
|
733
|
-
function resolveChain(fnName, depth, visited) {
|
|
734
|
-
if (depth > 3 || visited.has(fnName)) return [];
|
|
735
|
-
visited.add(fnName);
|
|
736
|
-
|
|
737
|
-
const entry = fnByName.get(fnName);
|
|
738
|
-
if (!entry) return [];
|
|
739
|
-
|
|
740
|
-
const steps = [];
|
|
741
|
-
for (const { method, url } of entry.fn.httpCallUrls || []) {
|
|
742
|
-
const route = resolveUrl(method, url, routeIndex);
|
|
743
|
-
const step = {
|
|
744
|
-
caller: fnName,
|
|
745
|
-
method,
|
|
746
|
-
url,
|
|
747
|
-
resolved: !!route,
|
|
748
|
-
handler: route?.handler || null,
|
|
749
|
-
handlerFile: route ? path.relative(cwd, route.filePath) : null,
|
|
750
|
-
suggestedName: route ? capNameFromRoute(route.method, route.path) : null,
|
|
751
|
-
};
|
|
752
|
-
steps.push(step);
|
|
753
|
-
|
|
754
|
-
// Recurse into the handler if found
|
|
755
|
-
if (route?.handler && !visited.has(route.handler)) {
|
|
756
|
-
const nested = resolveChain(route.handler, depth + 1, new Set(visited));
|
|
757
|
-
steps.push(...nested.map(s => ({ ...s, via: fnName })));
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
return steps;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
for (const { fn } of classifiedFunctions) {
|
|
764
|
-
if ((fn.httpCallUrls || []).length === 0) continue;
|
|
765
|
-
const steps = resolveChain(fn.name, 0, new Set());
|
|
766
|
-
if (steps.length) chains.set(fn.name, steps);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
return chains;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// ── capability matcher ────────────────────────────────────────────────────────
|
|
773
|
-
|
|
774
|
-
function tokenise(str) {
|
|
775
|
-
return str.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
776
|
-
.toLowerCase().split(/[\s_\-/.]+/).filter(t => t.length > 1);
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function overlap(a, b) {
|
|
780
|
-
const sa = new Set(a), sb = new Set(b);
|
|
781
|
-
let n = 0;
|
|
782
|
-
for (const t of sa) if (sb.has(t)) n++;
|
|
783
|
-
const u = sa.size + sb.size - n;
|
|
784
|
-
return u === 0 ? 0 : n / u;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
function matchFunctionToCapability(fn, capabilities) {
|
|
788
|
-
const fnTokens = tokenise(fn.name);
|
|
789
|
-
let best = null, bestScore = 0;
|
|
790
|
-
for (const cap of capabilities) {
|
|
791
|
-
const score = Math.max(
|
|
792
|
-
overlap(fnTokens, tokenise(cap.id || "")),
|
|
793
|
-
overlap(fnTokens, tokenise(cap.name || cap.title || "")),
|
|
794
|
-
);
|
|
795
|
-
if (score > bestScore) { bestScore = score; best = cap; }
|
|
796
|
-
}
|
|
797
|
-
return bestScore >= 0.2 ? { cap: best, score: bestScore } : null;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// ── merge analysis into capability ────────────────────────────────────────────
|
|
801
|
-
|
|
802
|
-
function mergeAnalysis(existing = {}, fn, filePath, cwd, chainSteps) {
|
|
803
|
-
const rel = path.relative(cwd, filePath);
|
|
804
|
-
|
|
805
|
-
// merge arrays without duplicates
|
|
806
|
-
const merge = (a = [], b = []) => [...new Set([...a, ...b])];
|
|
807
|
-
|
|
808
|
-
// Format chain steps for storage: "callerFn → METHOD /url → handlerFn"
|
|
809
|
-
const newChains = (chainSteps || []).map(s => {
|
|
810
|
-
const handler = s.handler ? ` → ${s.handler}` : (s.resolved ? "" : " [unresolved]");
|
|
811
|
-
return `${s.caller} → ${s.method} ${s.url}${handler}`;
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
return {
|
|
815
|
-
functions: merge(existing.functions, [fn.name]),
|
|
816
|
-
sourceFiles: merge(existing.sourceFiles, [rel]),
|
|
817
|
-
calls: merge(existing.calls, fn.calls),
|
|
818
|
-
throws: merge(existing.throws, fn.throws),
|
|
819
|
-
services: merge(existing.services, fn.services),
|
|
820
|
-
dbCalls: merge(existing.dbCalls, fn.dbCalls),
|
|
821
|
-
httpCalls: merge(existing.httpCalls, fn.httpCalls),
|
|
822
|
-
httpCallUrls: merge(existing.httpCallUrls || [],
|
|
823
|
-
(fn.httpCallUrls || []).map(c => `${c.method} ${c.url}`)),
|
|
824
|
-
httpChains: merge(existing.httpChains || [], newChains),
|
|
825
|
-
isEntryPoint: fn.isEntryPoint || existing.isEntryPoint || false,
|
|
826
|
-
scannedAt: new Date().toISOString(),
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// ── --suggest: show untracked entry points ────────────────────────────────────
|
|
831
|
-
|
|
832
|
-
function printSuggestions(allFunctions, allRoutes, capabilities, cwd) {
|
|
833
|
-
const existingIds = new Set(capabilities.map(c => c.id));
|
|
834
|
-
const existingNames = new Set(capabilities.map(c => (c.name || c.title || "").toLowerCase()));
|
|
835
|
-
|
|
836
|
-
console.log();
|
|
837
|
-
console.log(bold(" Capability Candidates"));
|
|
838
|
-
console.log(gray(" Untracked entry points discovered in your codebase:"));
|
|
839
|
-
console.log(gray(" ─────────────────────────────────────────────────────────────────"));
|
|
840
|
-
|
|
841
|
-
const seen = new Set();
|
|
842
|
-
const candidates = [];
|
|
843
|
-
|
|
844
|
-
// Route-based candidates (highest confidence)
|
|
845
|
-
for (const route of allRoutes) {
|
|
846
|
-
const suggestedName = capNameFromRoute(route.method, route.path);
|
|
847
|
-
const suggestedId = nameToId(suggestedName);
|
|
848
|
-
if (existingIds.has(suggestedId) || existingNames.has(suggestedName.toLowerCase())) continue;
|
|
849
|
-
if (seen.has(suggestedId)) continue;
|
|
850
|
-
seen.add(suggestedId);
|
|
851
|
-
|
|
852
|
-
const rel = path.relative(cwd, route.filePath);
|
|
853
|
-
candidates.push({
|
|
854
|
-
id: suggestedId,
|
|
855
|
-
name: suggestedName,
|
|
856
|
-
source: `${route.method} ${route.path}`,
|
|
857
|
-
file: rel,
|
|
858
|
-
confidence: "high",
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Function-based candidates (entry points with service calls, no matching cap)
|
|
863
|
-
for (const { fn, filePath } of allFunctions) {
|
|
864
|
-
if (!fn.isEntryPoint) continue;
|
|
865
|
-
if (fn.name === "<anonymous>" || fn.name.length < 3) continue;
|
|
866
|
-
const match = matchFunctionToCapability(fn, capabilities);
|
|
867
|
-
if (match && match.score >= 0.35) continue; // already tracked
|
|
868
|
-
const id = nameToId(fn.name);
|
|
869
|
-
if (existingIds.has(id) || seen.has(id)) continue;
|
|
870
|
-
seen.add(id);
|
|
871
|
-
|
|
872
|
-
const rel = path.relative(cwd, filePath);
|
|
873
|
-
candidates.push({
|
|
874
|
-
id,
|
|
875
|
-
name: fn.name,
|
|
876
|
-
source: `function in ${rel}:${fn.loc}`,
|
|
877
|
-
file: rel,
|
|
878
|
-
confidence: "medium",
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
if (!candidates.length) {
|
|
883
|
-
console.log(gray(" All entry points are already tracked as capabilities. ✓"));
|
|
884
|
-
console.log();
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const high = candidates.filter(c => c.confidence === "high");
|
|
889
|
-
const medium = candidates.filter(c => c.confidence === "medium");
|
|
890
|
-
|
|
891
|
-
if (high.length) {
|
|
892
|
-
console.log();
|
|
893
|
-
console.log(cyan(" ● High confidence (from route definitions):"));
|
|
894
|
-
for (const c of high) {
|
|
895
|
-
console.log(` ${green(c.id.padEnd(35))} ${gray(c.source)}`);
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (medium.length) {
|
|
900
|
-
console.log();
|
|
901
|
-
console.log(cyan(" ● Medium confidence (exported / top-level functions):"));
|
|
902
|
-
for (const c of medium.slice(0, 10)) {
|
|
903
|
-
console.log(` ${yellow(c.id.padEnd(35))} ${gray(c.source)}`);
|
|
904
|
-
}
|
|
905
|
-
if (medium.length > 10) {
|
|
906
|
-
console.log(gray(` … and ${medium.length - 10} more`));
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
console.log();
|
|
911
|
-
console.log(gray(" To add these, run:"));
|
|
912
|
-
for (const c of [...high, ...medium.slice(0, 3)]) {
|
|
913
|
-
console.log(gray(` infernoflow add "${c.id}" "${c.name}"`));
|
|
914
|
-
}
|
|
915
|
-
console.log();
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// ── reporters ─────────────────────────────────────────────────────────────────
|
|
919
|
-
|
|
920
|
-
function printReport(enriched, allRoutes, cwd) {
|
|
921
|
-
console.log();
|
|
922
|
-
console.log(bold(" Scan Results"));
|
|
923
|
-
console.log(gray(" ─────────────────────────────────────────────────────────────────"));
|
|
924
|
-
|
|
925
|
-
for (const [capId, analysis] of Object.entries(enriched)) {
|
|
926
|
-
const { codeAnalysis: a } = analysis;
|
|
927
|
-
if (!a) continue;
|
|
928
|
-
|
|
929
|
-
console.log();
|
|
930
|
-
const epTag = a.isEntryPoint ? cyan(" [entry]") : "";
|
|
931
|
-
console.log(` ${green("●")} ${bold(capId)}${epTag}`);
|
|
932
|
-
if (a.sourceFiles?.length) console.log(gray(` files: `) + a.sourceFiles.join(", "));
|
|
933
|
-
if (a.functions?.length) console.log(gray(` funcs: `) + a.functions.join(", "));
|
|
934
|
-
if (a.services?.length) console.log(gray(` services: `) + cyan(a.services.join(", ")));
|
|
935
|
-
if (a.dbCalls?.length) console.log(gray(` db: `) + a.dbCalls.slice(0, 4).join(", "));
|
|
936
|
-
if (a.httpChains?.length) {
|
|
937
|
-
console.log(gray(` chains: `));
|
|
938
|
-
for (const chain of a.httpChains.slice(0, 5)) {
|
|
939
|
-
console.log(gray(` `) + cyan(chain));
|
|
940
|
-
}
|
|
941
|
-
} else if (a.httpCallUrls?.length) {
|
|
942
|
-
console.log(gray(` calls: `) + a.httpCallUrls.slice(0, 4).join(", "));
|
|
943
|
-
} else if (a.httpCalls?.length) {
|
|
944
|
-
console.log(gray(` http: `) + a.httpCalls.slice(0, 4).join(", "));
|
|
945
|
-
}
|
|
946
|
-
if (a.throws?.length) console.log(gray(` throws: `) + yellow(a.throws.join(", ")));
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// Show discovered routes summary
|
|
950
|
-
if (allRoutes.length) {
|
|
951
|
-
console.log();
|
|
952
|
-
console.log(bold(" Discovered Routes"));
|
|
953
|
-
console.log(gray(" ─────────────────────────────────────────────────────────────────"));
|
|
954
|
-
const byFile = new Map();
|
|
955
|
-
for (const r of allRoutes) {
|
|
956
|
-
const rel = path.relative(cwd, r.filePath);
|
|
957
|
-
if (!byFile.has(rel)) byFile.set(rel, []);
|
|
958
|
-
byFile.get(rel).push(r);
|
|
959
|
-
}
|
|
960
|
-
for (const [file, routes] of byFile) {
|
|
961
|
-
console.log(gray(`\n ${file}`));
|
|
962
|
-
for (const r of routes) {
|
|
963
|
-
const name = r.method !== "*" ? capNameFromRoute(r.method, r.path) : "";
|
|
964
|
-
const tag = name ? gray(` → ${name}`) : "";
|
|
965
|
-
console.log(` ${cyan(r.method.padEnd(7))} ${r.path}${tag}`);
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
console.log();
|
|
971
|
-
console.log(gray(" ─────────────────────────────────────────────────────────────────"));
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// ── entry point ───────────────────────────────────────────────────────────────
|
|
975
|
-
|
|
976
|
-
export async function scanCommand(rawArgs) {
|
|
977
|
-
const args = rawArgs || [];
|
|
978
|
-
const dryRun = args.includes("--dry-run");
|
|
979
|
-
const jsonMode = args.includes("--json");
|
|
980
|
-
const suggestMode = args.includes("--suggest") || args.includes("-s");
|
|
981
|
-
const dirIdx = args.indexOf("--dir");
|
|
982
|
-
const extraDirs = dirIdx !== -1 ? [args[dirIdx + 1]] : [];
|
|
983
|
-
const capFilter = (() => { const i = args.indexOf("--capability"); return i !== -1 ? args[i + 1] : null; })();
|
|
984
|
-
|
|
985
|
-
const cwd = process.cwd();
|
|
986
|
-
const infernoDir = path.join(cwd, "inferno");
|
|
987
|
-
|
|
988
|
-
// Load capabilities
|
|
989
|
-
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
990
|
-
if (!fs.existsSync(capsPath)) {
|
|
991
|
-
console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
|
|
992
|
-
process.exit(1);
|
|
993
|
-
}
|
|
994
|
-
let capabilities;
|
|
995
|
-
let capsFileIsObject = false;
|
|
996
|
-
let capsFileWrapper = null;
|
|
997
|
-
try { capabilities = JSON.parse(fs.readFileSync(capsPath, "utf8")); }
|
|
998
|
-
catch (e) { console.error(red("✗ Failed to parse capabilities.json: " + e.message)); process.exit(1); }
|
|
999
|
-
|
|
1000
|
-
if (!Array.isArray(capabilities)) {
|
|
1001
|
-
if (capabilities.capabilities) {
|
|
1002
|
-
capsFileIsObject = true;
|
|
1003
|
-
capsFileWrapper = capabilities;
|
|
1004
|
-
capabilities = capabilities.capabilities;
|
|
1005
|
-
}
|
|
1006
|
-
else { console.error(red("✗ Unexpected capabilities.json format.")); process.exit(1); }
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
// Filter by --capability flag
|
|
1010
|
-
const targetCaps = capFilter
|
|
1011
|
-
? capabilities.filter(c => c.id === capFilter || (c.name || "").toLowerCase() === capFilter.toLowerCase())
|
|
1012
|
-
: capabilities;
|
|
1013
|
-
|
|
1014
|
-
if (targetCaps.length === 0 && !suggestMode) {
|
|
1015
|
-
console.log(yellow(capFilter ? `No capability matched: ${capFilter}` : "No capabilities found."));
|
|
1016
|
-
process.exit(0);
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// Walk source files
|
|
1020
|
-
const scanDirs = [cwd, ...extraDirs];
|
|
1021
|
-
if (!jsonMode) process.stdout.write(gray(" Walking source files…"));
|
|
1022
|
-
const files = [];
|
|
1023
|
-
for (const dir of scanDirs) {
|
|
1024
|
-
for (const f of walkFiles(dir)) files.push(f);
|
|
1025
|
-
}
|
|
1026
|
-
if (!jsonMode) process.stdout.write(`\r Found ${files.length} source files. \n`);
|
|
1027
|
-
|
|
1028
|
-
// Analyze files
|
|
1029
|
-
if (!jsonMode) process.stdout.write(gray(" Analyzing…"));
|
|
1030
|
-
const allFunctions = []; // { fn, filePath }
|
|
1031
|
-
const allRoutes = []; // route definitions discovered
|
|
1032
|
-
let analyzed = 0;
|
|
1033
|
-
for (const filePath of files) {
|
|
1034
|
-
const { functions, routes } = analyzeFile(filePath);
|
|
1035
|
-
for (const fn of functions) allFunctions.push({ fn, filePath });
|
|
1036
|
-
for (const r of routes) allRoutes.push(r);
|
|
1037
|
-
analyzed++;
|
|
1038
|
-
if (!jsonMode && analyzed % 20 === 0) {
|
|
1039
|
-
process.stdout.write(`\r Analyzed ${analyzed}/${files.length} files…`);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
if (!jsonMode) {
|
|
1043
|
-
process.stdout.write(
|
|
1044
|
-
`\r Analyzed ${files.length} files · ${allFunctions.length} functions · ${allRoutes.length} routes \n`
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// Classify entry points
|
|
1049
|
-
const classifiedFunctions = classifyEntryPoints(allFunctions, allRoutes);
|
|
1050
|
-
|
|
1051
|
-
// Build end-to-end HTTP chains
|
|
1052
|
-
const httpChains = buildHttpChains(classifiedFunctions, allRoutes, cwd);
|
|
1053
|
-
const resolvedChainCount = [...httpChains.values()].flat().filter(s => s.resolved).length;
|
|
1054
|
-
if (!jsonMode && httpChains.size > 0) {
|
|
1055
|
-
process.stdout.write(
|
|
1056
|
-
gray(` Resolved ${resolvedChainCount} HTTP chain${resolvedChainCount !== 1 ? "s" : ""} end-to-end\n`)
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// --suggest mode: skip capability matching, just show candidates
|
|
1061
|
-
if (suggestMode) {
|
|
1062
|
-
printSuggestions(classifiedFunctions, allRoutes, capabilities, cwd);
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
// Map functions to capabilities
|
|
1067
|
-
const enriched = {}; // capId → { ...cap, codeAnalysis: {...} }
|
|
1068
|
-
|
|
1069
|
-
for (const cap of targetCaps) {
|
|
1070
|
-
enriched[cap.id] = { ...cap, codeAnalysis: null };
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
for (const { fn, filePath } of classifiedFunctions) {
|
|
1074
|
-
const match = matchFunctionToCapability(fn, targetCaps);
|
|
1075
|
-
if (!match) continue;
|
|
1076
|
-
const { cap } = match;
|
|
1077
|
-
const existing = enriched[cap.id]?.codeAnalysis || {};
|
|
1078
|
-
const chainSteps = httpChains.get(fn.name) || [];
|
|
1079
|
-
enriched[cap.id].codeAnalysis = mergeAnalysis(existing, fn, filePath, cwd, chainSteps);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Compute stats
|
|
1083
|
-
const total = Object.keys(enriched).length;
|
|
1084
|
-
const matched = Object.values(enriched).filter(e => e.codeAnalysis).length;
|
|
1085
|
-
|
|
1086
|
-
if (jsonMode) {
|
|
1087
|
-
const out = {
|
|
1088
|
-
scannedAt: new Date().toISOString(),
|
|
1089
|
-
files: files.length,
|
|
1090
|
-
functions: allFunctions.length,
|
|
1091
|
-
routes: allRoutes,
|
|
1092
|
-
httpChains: Object.fromEntries(httpChains),
|
|
1093
|
-
capabilities: Object.entries(enriched).map(([id, data]) => ({
|
|
1094
|
-
id,
|
|
1095
|
-
name: data.name || data.title,
|
|
1096
|
-
codeAnalysis: data.codeAnalysis,
|
|
1097
|
-
})),
|
|
1098
|
-
};
|
|
1099
|
-
console.log(JSON.stringify(out, null, 2));
|
|
1100
|
-
return;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
printReport(enriched, allRoutes, cwd);
|
|
1104
|
-
console.log(` ${green("✔")} Matched ${matched}/${total} capabilities to source functions`);
|
|
1105
|
-
if (allRoutes.length) {
|
|
1106
|
-
console.log(` ${green("✔")} Discovered ${allRoutes.length} route${allRoutes.length !== 1 ? "s" : ""} — run ${cyan("infernoflow scan --suggest")} to see untracked ones`);
|
|
1107
|
-
}
|
|
1108
|
-
console.log();
|
|
1109
|
-
|
|
1110
|
-
if (dryRun) {
|
|
1111
|
-
console.log(yellow(" --dry-run: no files written."));
|
|
1112
|
-
return;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Write scan.json
|
|
1116
|
-
const scanData = {
|
|
1117
|
-
scannedAt: new Date().toISOString(),
|
|
1118
|
-
files: files.length,
|
|
1119
|
-
functions: allFunctions.length,
|
|
1120
|
-
routes: allRoutes,
|
|
1121
|
-
httpChains: Object.fromEntries(httpChains),
|
|
1122
|
-
capabilities: Object.entries(enriched).map(([id, data]) => ({
|
|
1123
|
-
id,
|
|
1124
|
-
name: data.name || data.title,
|
|
1125
|
-
codeAnalysis: data.codeAnalysis,
|
|
1126
|
-
})),
|
|
1127
|
-
};
|
|
1128
|
-
const scanPath = path.join(infernoDir, "scan.json");
|
|
1129
|
-
fs.writeFileSync(scanPath, JSON.stringify(scanData, null, 2));
|
|
1130
|
-
console.log(gray(` Saved → inferno/scan.json`));
|
|
1131
|
-
|
|
1132
|
-
// Enrich capabilities.json
|
|
1133
|
-
let changed = 0;
|
|
1134
|
-
const updatedCaps = capabilities.map(cap => {
|
|
1135
|
-
const analysis = enriched[cap.id]?.codeAnalysis;
|
|
1136
|
-
if (!analysis) return cap;
|
|
1137
|
-
changed++;
|
|
1138
|
-
return { ...cap, codeAnalysis: analysis };
|
|
1139
|
-
});
|
|
1140
|
-
|
|
1141
|
-
if (changed > 0) {
|
|
1142
|
-
const toWrite = capsFileIsObject
|
|
1143
|
-
? { ...capsFileWrapper, capabilities: updatedCaps }
|
|
1144
|
-
: updatedCaps;
|
|
1145
|
-
fs.writeFileSync(capsPath, JSON.stringify(toWrite, null, 2));
|
|
1146
|
-
console.log(gray(` Updated ${changed} capability entries in capabilities.json`));
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
console.log();
|
|
1150
|
-
if (!ts) {
|
|
1151
|
-
console.log(yellow(" ⚠ TypeScript compiler not found — JS/TS analyzed with regex fallback."));
|
|
1152
|
-
console.log(gray(` For deeper analysis: npm install -g typescript`));
|
|
1153
|
-
console.log();
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
43
|
+
`;function je(e){try{const t=ie(`python3 -c ${JSON.stringify(ve)} ${JSON.stringify(e)}`,{timeout:8e3,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),r=JSON.parse(t.trim()||"[]"),n=j.readFileSync(e,"utf8");return r.map(s=>({...s,services:I(n),dbCalls:M(n),httpCalls:H(n),httpCallUrls:L(n),isExported:!1}))}catch{return null}}const Te=[{re:/^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(/gm,lang:"go"},{re:/^\s*(?:def|async def)\s+(\w+)\s*\(/gm,lang:"py"},{re:/^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)?(\w+)\s*\(/gm,lang:"java"},{re:/^\s*def\s+(\w+)\s*[\(\|]/gm,lang:"rb"}];function q(e,t){const r=x.extname(e).slice(1),n=Te.find(i=>i.lang===r);if(!n)return null;const s=[],o=new RegExp(n.re.source,"gm");let l;for(;(l=o.exec(t))!==null;){const i=l.index,a=Math.min(i+2e3,t.length),p=t.slice(i,a);s.push({name:l[1],calls:[],throws:[],services:I(p),dbCalls:M(p),httpCalls:H(p),httpCallUrls:L(p),isExported:!1,loc:t.slice(0,i).split(`
|
|
44
|
+
`).length})}return s.length>0?s:null}const Ae=new Set(["node_modules",".git","dist","build","out",".next",".nuxt","coverage","__pycache__",".pytest_cache","vendor","tmp",".turbo","target",".gradle","public","static","assets"]),Oe=new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".py",".go",".rb",".java"]),Re=/\.(test|spec)\.[jt]sx?$|_test\.(go|py|rb)|spec\.(rb|js|ts)$/;function*X(e){let t;try{t=j.readdirSync(e,{withFileTypes:!0})}catch{return}for(const r of t)if(r.isDirectory())Ae.has(r.name)||(yield*X(x.join(e,r.name)));else if(r.isFile()){const n=x.extname(r.name);Oe.has(n)&&!Re.test(r.name)&&(yield x.join(e,r.name))}}function _e(e){let t;try{t=j.readFileSync(e,"utf8")}catch{return{functions:[],routes:[]}}const r=x.extname(e);let n=[];[".ts",".tsx",".js",".jsx",".mjs",".cjs"].includes(r)?n=Ee(e,t)||q(e,t)||[]:r===".py"?n=je(e)||q(e,t)||[]:n=q(e,t)||[];const s=[".ts",".tsx",".js",".jsx",".mjs",".cjs"].includes(r)?we(e,t):[];return{functions:n,routes:s}}function Fe(e,t){const r=new Set;for(const s of t)s.handler&&r.add(s.handler);const n=new Map;for(const{fn:s}of e)for(const o of s.calls||[]){const l=o.replace("()","");n.set(l,(n.get(l)||0)+1)}return e.map(({fn:s,filePath:o})=>{const l=r.has(s.name),i=s.isExported||!1,a=n.get(s.name)||0,p=(s.dbCalls?.length||0)+(s.services?.length||0)+(s.httpCallUrls?.length||0)>0,c=l||i||p&&a===0,f=!c&&a>0;return{fn:{...s,isEntryPoint:c,isHelper:f,callerCount:a},filePath:o}})}function Pe(e){const t=new Map;for(const r of e){const n=r.path.replace(/:[^/]+/g,":*"),s=`${r.method} ${n}`;t.has(s)||t.set(s,r);const o=`${r.method} ${r.path}`;t.has(o)||t.set(o,r)}return t}function Ne(e,t,r){const n=t.split("?")[0],s=r.get(`${e} ${n}`);if(s)return s;const o=n.replace(/\/[0-9a-f-]{8,}|\/\d+/g,"/:*"),l=`${e} ${o}`,i=r.get(l);if(i)return i;const a=n.replace(/^\/api\/v?\d+/,"").replace(/^\/api/,"");if(a!==n){const c=`${e} ${a}`,f=r.get(c);if(f)return f}const p=`* ${n}`;return r.get(p)||null}function Ue(e,t,r){const n=Pe(t),s=new Map;for(const{fn:i,filePath:a}of e)s.has(i.name)||s.set(i.name,{fn:i,filePath:a});const o=new Map;function l(i,a,p){if(a>3||p.has(i))return[];p.add(i);const c=s.get(i);if(!c)return[];const f=[];for(const{method:m,url:w}of c.fn.httpCallUrls||[]){const y=Ne(m,w,n),h={caller:i,method:m,url:w,resolved:!!y,handler:y?.handler||null,handlerFile:y?x.relative(r,y.filePath):null,suggestedName:y?k(y.method,y.path):null};if(f.push(h),y?.handler&&!p.has(y.handler)){const $=l(y.handler,a+1,new Set(p));f.push(...$.map(v=>({...v,via:i})))}}return f}for(const{fn:i}of e){if((i.httpCallUrls||[]).length===0)continue;const a=l(i.name,0,new Set);a.length&&o.set(i.name,a)}return o}function W(e){return e.replace(/([a-z])([A-Z])/g,"$1 $2").toLowerCase().split(/[\s_\-/.]+/).filter(t=>t.length>1)}function Y(e,t){const r=new Set(e),n=new Set(t);let s=0;for(const l of r)n.has(l)&&s++;const o=r.size+n.size-s;return o===0?0:s/o}function Z(e,t){const r=W(e.name);let n=null,s=0;for(const o of t){const l=Math.max(Y(r,W(o.id||"")),Y(r,W(o.name||o.title||"")));l>s&&(s=l,n=o)}return s>=.2?{cap:n,score:s}:null}function De(e={},t,r,n,s){const o=x.relative(n,r),l=(a=[],p=[])=>[...new Set([...a,...p])],i=(s||[]).map(a=>{const p=a.handler?` \u2192 ${a.handler}`:a.resolved?"":" [unresolved]";return`${a.caller} \u2192 ${a.method} ${a.url}${p}`});return{functions:l(e.functions,[t.name]),sourceFiles:l(e.sourceFiles,[o]),calls:l(e.calls,t.calls),throws:l(e.throws,t.throws),services:l(e.services,t.services),dbCalls:l(e.dbCalls,t.dbCalls),httpCalls:l(e.httpCalls,t.httpCalls),httpCallUrls:l(e.httpCallUrls||[],(t.httpCallUrls||[]).map(a=>`${a.method} ${a.url}`)),httpChains:l(e.httpChains||[],i),isEntryPoint:t.isEntryPoint||e.isEntryPoint||!1,scannedAt:new Date().toISOString()}}function Ie(e,t,r,n){const s=new Set(r.map(c=>c.id)),o=new Set(r.map(c=>(c.name||c.title||"").toLowerCase()));console.log(),console.log(_(" Capability Candidates")),console.log(d(" Untracked entry points discovered in your codebase:")),console.log(d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));const l=new Set,i=[];for(const c of t){const f=k(c.method,c.path),m=K(f);if(s.has(m)||o.has(f.toLowerCase())||l.has(m))continue;l.add(m);const w=x.relative(n,c.filePath);i.push({id:m,name:f,source:`${c.method} ${c.path}`,file:w,confidence:"high"})}for(const{fn:c,filePath:f}of e){if(!c.isEntryPoint||c.name==="<anonymous>"||c.name.length<3)continue;const m=Z(c,r);if(m&&m.score>=.35)continue;const w=K(c.name);if(s.has(w)||l.has(w))continue;l.add(w);const y=x.relative(n,f);i.push({id:w,name:c.name,source:`function in ${y}:${c.loc}`,file:y,confidence:"medium"})}if(!i.length){console.log(d(" All entry points are already tracked as capabilities. \u2713")),console.log();return}const a=i.filter(c=>c.confidence==="high"),p=i.filter(c=>c.confidence==="medium");if(a.length){console.log(),console.log(T(" \u25CF High confidence (from route definitions):"));for(const c of a)console.log(` ${F(c.id.padEnd(35))} ${d(c.source)}`)}if(p.length){console.log(),console.log(T(" \u25CF Medium confidence (exported / top-level functions):"));for(const c of p.slice(0,10))console.log(` ${R(c.id.padEnd(35))} ${d(c.source)}`);p.length>10&&console.log(d(` \u2026 and ${p.length-10} more`))}console.log(),console.log(d(" To add these, run:"));for(const c of[...a,...p.slice(0,3)])console.log(d(` infernoflow add "${c.id}" "${c.name}"`));console.log()}function Me(e,t,r){console.log(),console.log(_(" Scan Results")),console.log(d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));for(const[n,s]of Object.entries(e)){const{codeAnalysis:o}=s;if(!o)continue;console.log();const l=o.isEntryPoint?T(" [entry]"):"";if(console.log(` ${F("\u25CF")} ${_(n)}${l}`),o.sourceFiles?.length&&console.log(d(" files: ")+o.sourceFiles.join(", ")),o.functions?.length&&console.log(d(" funcs: ")+o.functions.join(", ")),o.services?.length&&console.log(d(" services: ")+T(o.services.join(", "))),o.dbCalls?.length&&console.log(d(" db: ")+o.dbCalls.slice(0,4).join(", ")),o.httpChains?.length){console.log(d(" chains: "));for(const i of o.httpChains.slice(0,5))console.log(d(" ")+T(i))}else o.httpCallUrls?.length?console.log(d(" calls: ")+o.httpCallUrls.slice(0,4).join(", ")):o.httpCalls?.length&&console.log(d(" http: ")+o.httpCalls.slice(0,4).join(", "));o.throws?.length&&console.log(d(" throws: ")+R(o.throws.join(", ")))}if(t.length){console.log(),console.log(_(" Discovered Routes")),console.log(d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));const n=new Map;for(const s of t){const o=x.relative(r,s.filePath);n.has(o)||n.set(o,[]),n.get(o).push(s)}for(const[s,o]of n){console.log(d(`
|
|
45
|
+
${s}`));for(const l of o){const i=l.method!=="*"?k(l.method,l.path):"",a=i?d(` \u2192 ${i}`):"";console.log(` ${T(l.method.padEnd(7))} ${l.path}${a}`)}}}console.log(),console.log(d(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"))}async function ze(e){const t=e||[],r=t.includes("--dry-run"),n=t.includes("--json"),s=t.includes("--suggest")||t.includes("-s"),o=t.indexOf("--dir"),l=o!==-1?[t[o+1]]:[],i=(()=>{const u=t.indexOf("--capability");return u!==-1?t[u+1]:null})(),a=process.cwd(),p=x.join(a,"inferno"),c=x.join(p,"capabilities.json");j.existsSync(c)||(console.error(D("\u2717 inferno/capabilities.json not found \u2014 run `infernoflow init` first.")),process.exit(1));let f,m=!1,w=null;try{f=JSON.parse(j.readFileSync(c,"utf8"))}catch(u){console.error(D("\u2717 Failed to parse capabilities.json: "+u.message)),process.exit(1)}Array.isArray(f)||(f.capabilities?(m=!0,w=f,f=f.capabilities):(console.error(D("\u2717 Unexpected capabilities.json format.")),process.exit(1)));const y=i?f.filter(u=>u.id===i||(u.name||"").toLowerCase()===i.toLowerCase()):f;y.length===0&&!s&&(console.log(R(i?`No capability matched: ${i}`:"No capabilities found.")),process.exit(0));const h=[a,...l];n||process.stdout.write(d(" Walking source files\u2026"));const $=[];for(const u of h)for(const C of X(u))$.push(C);n||process.stdout.write(`\r Found ${$.length} source files.
|
|
46
|
+
`),n||process.stdout.write(d(" Analyzing\u2026"));const v=[],b=[];let P=0;for(const u of $){const{functions:C,routes:E}=_e(u);for(const A of C)v.push({fn:A,filePath:u});for(const A of E)b.push(A);P++,!n&&P%20===0&&process.stdout.write(`\r Analyzed ${P}/${$.length} files\u2026`)}n||process.stdout.write(`\r Analyzed ${$.length} files \xB7 ${v.length} functions \xB7 ${b.length} routes
|
|
47
|
+
`);const N=Fe(v,b),O=Ue(N,b,a),J=[...O.values()].flat().filter(u=>u.resolved).length;if(!n&&O.size>0&&process.stdout.write(d(` Resolved ${J} HTTP chain${J!==1?"s":""} end-to-end
|
|
48
|
+
`)),s){Ie(N,b,f,a);return}const S={};for(const u of y)S[u.id]={...u,codeAnalysis:null};for(const{fn:u,filePath:C}of N){const E=Z(u,y);if(!E)continue;const{cap:A}=E,ne=S[A.id]?.codeAnalysis||{},oe=O.get(u.name)||[];S[A.id].codeAnalysis=De(ne,u,C,a,oe)}const Q=Object.keys(S).length,ee=Object.values(S).filter(u=>u.codeAnalysis).length;if(n){const u={scannedAt:new Date().toISOString(),files:$.length,functions:v.length,routes:b,httpChains:Object.fromEntries(O),capabilities:Object.entries(S).map(([C,E])=>({id:C,name:E.name||E.title,codeAnalysis:E.codeAnalysis}))};console.log(JSON.stringify(u,null,2));return}if(Me(S,b,a),console.log(` ${F("\u2714")} Matched ${ee}/${Q} capabilities to source functions`),b.length&&console.log(` ${F("\u2714")} Discovered ${b.length} route${b.length!==1?"s":""} \u2014 run ${T("infernoflow scan --suggest")} to see untracked ones`),console.log(),r){console.log(R(" --dry-run: no files written."));return}const te={scannedAt:new Date().toISOString(),files:$.length,functions:v.length,routes:b,httpChains:Object.fromEntries(O),capabilities:Object.entries(S).map(([u,C])=>({id:u,name:C.name||C.title,codeAnalysis:C.codeAnalysis}))},se=x.join(p,"scan.json");j.writeFileSync(se,JSON.stringify(te,null,2)),console.log(d(" Saved \u2192 inferno/scan.json"));let U=0;const B=f.map(u=>{const C=S[u.id]?.codeAnalysis;return C?(U++,{...u,codeAnalysis:C}):u});if(U>0){const u=m?{...w,capabilities:B}:B;j.writeFileSync(c,JSON.stringify(u,null,2)),console.log(d(` Updated ${U} capability entries in capabilities.json`))}console.log(),g||(console.log(R(" \u26A0 TypeScript compiler not found \u2014 JS/TS analyzed with regex fallback.")),console.log(d(" For deeper analysis: npm install -g typescript")),console.log())}export{ze as scanCommand};
|