botversion-sdk 1.0.4 → 1.0.6
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/bin/init.js +186 -140
- package/cli/detector.js +360 -173
- package/cli/generator.js +21 -479
- package/cli/prompts.js +40 -42
- package/cli/writer.js +119 -43
- package/client.js +33 -69
- package/index.js +54 -515
- package/interceptor.js +57 -132
- package/package.json +3 -1
- package/scanner.js +733 -28
package/scanner.js
CHANGED
|
@@ -4,31 +4,84 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Scans an Express app object and extracts all registered routes
|
|
6
6
|
*/
|
|
7
|
-
function scanExpressRoutes(app) {
|
|
7
|
+
function scanExpressRoutes(app, cwd) {
|
|
8
8
|
const endpoints = [];
|
|
9
9
|
const seen = new Set();
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const router = app._router || app.router || (app.stack ? app : null);
|
|
11
|
+
// Build body map from ALL files upfront
|
|
12
|
+
const bodyMap = cwd ? buildBodyMap(cwd) : {};
|
|
14
13
|
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
)
|
|
14
|
+
if (app) {
|
|
15
|
+
if (app.lazyrouter) app.lazyrouter();
|
|
16
|
+
const router = app._router || app.router || (app.stack ? app : null);
|
|
17
|
+
if (router) {
|
|
18
|
+
const stack = router.stack || [];
|
|
19
|
+
extractRoutes(stack, "", endpoints, seen, bodyMap);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// ALSO scan all JS/TS files statically for route definitions
|
|
24
|
+
if (cwd) {
|
|
25
|
+
const allFiles = scanAllExpressFiles(cwd);
|
|
26
|
+
for (const file of allFiles) {
|
|
27
|
+
const fileEndpoints = scanExpressFileStatically(file, seen);
|
|
28
|
+
endpoints.push(...fileEndpoints);
|
|
26
29
|
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
endpoints.forEach((ep) => {
|
|
33
|
+
const status = ep.requestBody ? "✅" : "❌ NULL";
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return endpoints;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function scanExpressFileStatically(filePath, seen) {
|
|
40
|
+
const fs = require("fs");
|
|
41
|
+
const endpoints = [];
|
|
42
|
+
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
46
|
+
} catch {
|
|
27
47
|
return endpoints;
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
// Match patterns like:
|
|
51
|
+
// app.get('/path', ...)
|
|
52
|
+
// router.post('/path', ...)
|
|
53
|
+
// app.all('/curd.php', ...)
|
|
54
|
+
const routePattern =
|
|
55
|
+
/\.(get|post|put|delete|patch|all)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
56
|
+
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = routePattern.exec(content)) !== null) {
|
|
59
|
+
const method = match[1].toUpperCase();
|
|
60
|
+
const routePath = match[2];
|
|
61
|
+
|
|
62
|
+
// Skip middleware patterns
|
|
63
|
+
if (routePath.includes("*")) continue;
|
|
64
|
+
|
|
65
|
+
const key = method + ":" + routePath;
|
|
66
|
+
if (seen.has(key)) continue;
|
|
67
|
+
seen.add(key);
|
|
68
|
+
|
|
69
|
+
const effectiveMethod = method === "ALL" ? "GET" : method;
|
|
70
|
+
const needsBody = ["POST", "PUT", "PATCH"].includes(effectiveMethod);
|
|
71
|
+
const bodyFields = needsBody ? extractBodyFieldsFromFile(content) : null;
|
|
72
|
+
|
|
73
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
74
|
+
|
|
75
|
+
endpoints.push({
|
|
76
|
+
method: effectiveMethod,
|
|
77
|
+
path: routePath,
|
|
78
|
+
description: "",
|
|
79
|
+
requestBody: bodyFields,
|
|
80
|
+
routeParamMap: routeParamMap,
|
|
81
|
+
detectedBy: "static-scan-file",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
return endpoints;
|
|
33
86
|
}
|
|
34
87
|
|
|
@@ -40,7 +93,7 @@ function scanExpressRoutes(app) {
|
|
|
40
93
|
* - a router layer → layer.name === 'router', has its own .handle.stack
|
|
41
94
|
* - middleware → everything else (body-parser, cors, etc.) — skip these
|
|
42
95
|
*/
|
|
43
|
-
function extractRoutes(stack, prefix, endpoints, seen) {
|
|
96
|
+
function extractRoutes(stack, prefix, endpoints, seen, bodyMap) {
|
|
44
97
|
prefix = prefix || "";
|
|
45
98
|
|
|
46
99
|
stack.forEach(function (layer) {
|
|
@@ -53,28 +106,47 @@ function extractRoutes(stack, prefix, endpoints, seen) {
|
|
|
53
106
|
|
|
54
107
|
methods.forEach(function (method) {
|
|
55
108
|
method = method.toUpperCase();
|
|
56
|
-
|
|
57
|
-
// Skip Express internals
|
|
58
109
|
if (method === "_ALL") return;
|
|
59
110
|
|
|
60
111
|
var key = method + ":" + routePath;
|
|
61
112
|
if (seen.has(key)) return;
|
|
62
113
|
seen.add(key);
|
|
63
114
|
|
|
64
|
-
|
|
115
|
+
const needsBody = ["POST", "PUT", "PATCH"].includes(method);
|
|
116
|
+
let requestBody = null;
|
|
117
|
+
|
|
118
|
+
if (needsBody) {
|
|
119
|
+
// Strategy 1: scan inline handler directly via fn.toString() — most accurate
|
|
120
|
+
for (const handler of layer.route.stack) {
|
|
121
|
+
const fn = handler.handle || handler;
|
|
122
|
+
const fnStr = fn.toString();
|
|
123
|
+
const fields = extractBodyFieldsFromFile(fnStr);
|
|
124
|
+
if (fields) {
|
|
125
|
+
requestBody = fields;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Strategy 2: fall back to bodyMap using handler name
|
|
131
|
+
if (!requestBody) {
|
|
132
|
+
const handlerName = extractHandlerName(layer);
|
|
133
|
+
if (handlerName && bodyMap[handlerName]) {
|
|
134
|
+
requestBody = bodyMap[handlerName];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
65
140
|
|
|
66
141
|
endpoints.push({
|
|
67
142
|
method: method,
|
|
68
143
|
path: routePath,
|
|
69
144
|
description: "",
|
|
70
|
-
requestBody:
|
|
71
|
-
|
|
72
|
-
? buildParamSchema(params)
|
|
73
|
-
: null,
|
|
145
|
+
requestBody: requestBody,
|
|
146
|
+
routeParamMap: routeParamMap,
|
|
74
147
|
detectedBy: "static-scan",
|
|
75
148
|
});
|
|
76
149
|
});
|
|
77
|
-
|
|
78
150
|
return;
|
|
79
151
|
}
|
|
80
152
|
|
|
@@ -82,7 +154,7 @@ function extractRoutes(stack, prefix, endpoints, seen) {
|
|
|
82
154
|
if (layer.name === "router" && layer.handle && layer.handle.stack) {
|
|
83
155
|
// Extract the mount path from the regexp
|
|
84
156
|
var mountPath = prefix + regexpToPath(layer.regexp, layer.keys);
|
|
85
|
-
extractRoutes(layer.handle.stack, mountPath, endpoints, seen);
|
|
157
|
+
extractRoutes(layer.handle.stack, mountPath, endpoints, seen, bodyMap);
|
|
86
158
|
return;
|
|
87
159
|
}
|
|
88
160
|
|
|
@@ -101,7 +173,6 @@ function scanNextJsRoutes(pagesDir) {
|
|
|
101
173
|
const apiDir = path.join(pagesDir, "api");
|
|
102
174
|
|
|
103
175
|
if (!fs.existsSync(apiDir)) {
|
|
104
|
-
console.warn("[BotVersion SDK] No pages/api directory found at:", apiDir);
|
|
105
176
|
return endpoints;
|
|
106
177
|
}
|
|
107
178
|
|
|
@@ -139,11 +210,14 @@ function scanNextJsRoutes(pagesDir) {
|
|
|
139
210
|
bodyFields ||
|
|
140
211
|
(method === "DELETE" && queryFields ? queryFields : null);
|
|
141
212
|
|
|
213
|
+
const routeParamMap = buildRouteParamMap(normalizedPath, []);
|
|
214
|
+
|
|
142
215
|
endpoints.push({
|
|
143
216
|
method: method,
|
|
144
217
|
path: normalizedPath,
|
|
145
218
|
description: "",
|
|
146
219
|
requestBody: effectiveRequestBody,
|
|
220
|
+
routeParamMap: routeParamMap,
|
|
147
221
|
detectedBy: "static-scan",
|
|
148
222
|
});
|
|
149
223
|
});
|
|
@@ -230,6 +304,37 @@ function buildParamSchema(params) {
|
|
|
230
304
|
return schema;
|
|
231
305
|
}
|
|
232
306
|
|
|
307
|
+
function inferFieldType(fieldName, content) {
|
|
308
|
+
const arrayPatterns = [
|
|
309
|
+
new RegExp(
|
|
310
|
+
`${fieldName}\\s*\\.\\s*(map|filter|forEach|push|reduce|find|some|every|includes|join|slice|splice|length)\\b`,
|
|
311
|
+
),
|
|
312
|
+
new RegExp(`Array\\.isArray\\s*\\(\\s*${fieldName}\\s*\\)`),
|
|
313
|
+
new RegExp(`for\\s*\\(.*of\\s+${fieldName}\\b`),
|
|
314
|
+
new RegExp(`\\[\\s*\\.\\.\\.${fieldName}\\s*\\]`),
|
|
315
|
+
];
|
|
316
|
+
if (arrayPatterns.some((p) => p.test(content))) return "array";
|
|
317
|
+
|
|
318
|
+
const numberPatterns = [
|
|
319
|
+
new RegExp(`${fieldName}\\s*[+\\-*/%]\\s*\\d`),
|
|
320
|
+
new RegExp(`parseInt\\s*\\(\\s*${fieldName}`),
|
|
321
|
+
new RegExp(`parseFloat\\s*\\(\\s*${fieldName}`),
|
|
322
|
+
new RegExp(`Number\\s*\\(\\s*${fieldName}`),
|
|
323
|
+
];
|
|
324
|
+
if (numberPatterns.some((p) => p.test(content))) return "number";
|
|
325
|
+
|
|
326
|
+
const boolPatterns = [
|
|
327
|
+
new RegExp(`${fieldName}\\s*===?\\s*(true|false)`),
|
|
328
|
+
new RegExp(`(true|false)\\s*===?\\s*${fieldName}`),
|
|
329
|
+
new RegExp(`Boolean\\s*\\(\\s*${fieldName}`),
|
|
330
|
+
new RegExp(`typeof\\s+${fieldName}\\s*!==?\\s*["']boolean["']`),
|
|
331
|
+
new RegExp(`typeof\\s+${fieldName}\\s*===?\\s*["']boolean["']`),
|
|
332
|
+
];
|
|
333
|
+
if (boolPatterns.some((p) => p.test(content))) return "boolean";
|
|
334
|
+
|
|
335
|
+
return "string";
|
|
336
|
+
}
|
|
337
|
+
|
|
233
338
|
function extractBodyFieldsFromFile(content) {
|
|
234
339
|
const fields = new Set();
|
|
235
340
|
|
|
@@ -284,12 +389,188 @@ function extractBodyFieldsFromFile(content) {
|
|
|
284
389
|
|
|
285
390
|
const properties = {};
|
|
286
391
|
fields.forEach(function (field) {
|
|
287
|
-
|
|
392
|
+
const type = inferFieldType(field, content);
|
|
393
|
+
properties[field] =
|
|
394
|
+
type === "array"
|
|
395
|
+
? { type: "array", items: { type: "object" } }
|
|
396
|
+
: { type };
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
return { type: "object", properties };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function scanNextJsAppRoutes(appDir) {
|
|
403
|
+
const fs = require("fs");
|
|
404
|
+
const path = require("path");
|
|
405
|
+
const endpoints = [];
|
|
406
|
+
|
|
407
|
+
const apiDir = path.join(appDir, "api");
|
|
408
|
+
if (!fs.existsSync(apiDir)) {
|
|
409
|
+
return endpoints;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function walkDir(dir, routePath) {
|
|
413
|
+
routePath = routePath || "/api";
|
|
414
|
+
const files = fs.readdirSync(dir);
|
|
415
|
+
|
|
416
|
+
files.forEach(function (file) {
|
|
417
|
+
const fullPath = path.join(dir, file);
|
|
418
|
+
const stat = fs.statSync(fullPath);
|
|
419
|
+
|
|
420
|
+
if (stat.isDirectory()) {
|
|
421
|
+
// Convert [param] → :param
|
|
422
|
+
const segment = file.replace(/\[([^\]]+)\]/g, ":$1");
|
|
423
|
+
walkDir(fullPath, routePath + "/" + segment);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Only process route.ts / route.js
|
|
428
|
+
if (!/^route\.(js|ts)$/.test(file)) return;
|
|
429
|
+
|
|
430
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
431
|
+
const methods = detectAppRouterMethods(content);
|
|
432
|
+
|
|
433
|
+
methods.forEach(function (method) {
|
|
434
|
+
const bodyFields =
|
|
435
|
+
method !== "GET" ? extractAppRouterBodyFields(content) : null;
|
|
436
|
+
const queryFields = extractQueryFieldsFromFile(content);
|
|
437
|
+
|
|
438
|
+
const routeParamMap = buildRouteParamMap(routePath, []);
|
|
439
|
+
|
|
440
|
+
endpoints.push({
|
|
441
|
+
method: method,
|
|
442
|
+
path: routePath,
|
|
443
|
+
description: "",
|
|
444
|
+
requestBody:
|
|
445
|
+
bodyFields ||
|
|
446
|
+
(method === "DELETE" && queryFields ? queryFields : null),
|
|
447
|
+
routeParamMap: routeParamMap,
|
|
448
|
+
detectedBy: "static-scan",
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
walkDir(apiDir);
|
|
455
|
+
return endpoints;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function detectAppRouterMethods(content) {
|
|
459
|
+
const methods = [];
|
|
460
|
+
const patterns = [
|
|
461
|
+
{ pattern: /export\s+async\s+function\s+GET\b/, method: "GET" },
|
|
462
|
+
{ pattern: /export\s+async\s+function\s+POST\b/, method: "POST" },
|
|
463
|
+
{ pattern: /export\s+async\s+function\s+PUT\b/, method: "PUT" },
|
|
464
|
+
{ pattern: /export\s+async\s+function\s+DELETE\b/, method: "DELETE" },
|
|
465
|
+
{ pattern: /export\s+async\s+function\s+PATCH\b/, method: "PATCH" },
|
|
466
|
+
// named exports too: export { POST }
|
|
467
|
+
{ pattern: /export\s+function\s+GET\b/, method: "GET" },
|
|
468
|
+
{ pattern: /export\s+function\s+POST\b/, method: "POST" },
|
|
469
|
+
{ pattern: /export\s+function\s+PUT\b/, method: "PUT" },
|
|
470
|
+
{ pattern: /export\s+function\s+DELETE\b/, method: "DELETE" },
|
|
471
|
+
{ pattern: /export\s+function\s+PATCH\b/, method: "PATCH" },
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
patterns.forEach(function (p) {
|
|
475
|
+
if (p.pattern.test(content)) methods.push(p.method);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return methods.length > 0 ? methods : ["GET"];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function extractAppRouterBodyFields(content) {
|
|
482
|
+
const fields = new Set();
|
|
483
|
+
|
|
484
|
+
// Pattern 1: const { userId, tokens } = await request.json()
|
|
485
|
+
const destructureMatches = content.matchAll(
|
|
486
|
+
/const\s*\{([^}]+)\}\s*=\s*await\s+\w+\.json\(\)/g,
|
|
487
|
+
);
|
|
488
|
+
for (const match of destructureMatches) {
|
|
489
|
+
match[1].split(",").forEach(function (f) {
|
|
490
|
+
const clean = f.trim().split(":")[0].trim();
|
|
491
|
+
if (clean) fields.add(clean);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Pattern 2: const body = await request.json() then body.userId
|
|
496
|
+
const bodyVarMatch = content.match(
|
|
497
|
+
/const\s+(\w+)\s*=\s*await\s+\w+\.json\(\)/,
|
|
498
|
+
);
|
|
499
|
+
if (bodyVarMatch) {
|
|
500
|
+
const varName = bodyVarMatch[1];
|
|
501
|
+
const varMatches = content.matchAll(
|
|
502
|
+
new RegExp(`${varName}\\.([a-zA-Z_][a-zA-Z0-9_]*)`, "g"),
|
|
503
|
+
);
|
|
504
|
+
for (const match of varMatches) {
|
|
505
|
+
fields.add(match[1]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Pattern 3: (await request.json()).userId
|
|
510
|
+
const inlineMatches = content.matchAll(
|
|
511
|
+
/\(await\s+\w+\.json\(\)\)\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
512
|
+
);
|
|
513
|
+
for (const match of inlineMatches) {
|
|
514
|
+
fields.add(match[1]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (fields.size === 0) return null;
|
|
518
|
+
|
|
519
|
+
const properties = {};
|
|
520
|
+
fields.forEach(function (field) {
|
|
521
|
+
const type = inferFieldType(field, content);
|
|
522
|
+
properties[field] =
|
|
523
|
+
type === "array"
|
|
524
|
+
? { type: "array", items: { type: "object" } }
|
|
525
|
+
: { type };
|
|
288
526
|
});
|
|
289
527
|
|
|
290
528
|
return { type: "object", properties };
|
|
291
529
|
}
|
|
292
530
|
|
|
531
|
+
function buildRouteParamMap(routePath, segments) {
|
|
532
|
+
const paramMap = {};
|
|
533
|
+
const parts = routePath.split("/").filter(Boolean);
|
|
534
|
+
|
|
535
|
+
for (let i = 0; i < parts.length; i++) {
|
|
536
|
+
const part = parts[i];
|
|
537
|
+
|
|
538
|
+
// Skip catch-all [...slug] and optional [[...slug]]
|
|
539
|
+
if (/^\[?\[\.\.\./.test(part)) continue;
|
|
540
|
+
|
|
541
|
+
// Check if this segment is a dynamic param
|
|
542
|
+
// Handles both Next.js style [id] and Express style :id
|
|
543
|
+
const nextParamMatch = part.match(/^\[([^\]]+)\]$/);
|
|
544
|
+
const expressParamMatch = part.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
545
|
+
const paramMatch = nextParamMatch || expressParamMatch;
|
|
546
|
+
if (!paramMatch) continue;
|
|
547
|
+
|
|
548
|
+
const paramName = paramMatch[1];
|
|
549
|
+
|
|
550
|
+
// If param already has a descriptive name like [projectId], use it as-is
|
|
551
|
+
if (paramName !== "id" && paramName !== "slug" && paramName !== "param") {
|
|
552
|
+
paramMap[paramName] = paramName;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// If param is just [id], look at the parent folder name to figure out type
|
|
557
|
+
// e.g. projects/[id] → projectId
|
|
558
|
+
const parentSegment = parts[i - 1];
|
|
559
|
+
if (parentSegment) {
|
|
560
|
+
// Remove any dynamic brackets from parent if it's also a param
|
|
561
|
+
const cleanParent = parentSegment.replace(/^\[([^\]]+)\]$/, "$1");
|
|
562
|
+
// Singularize simple plural names: projects → project, users → user
|
|
563
|
+
const singular = cleanParent.replace(/ies$/, "y").replace(/s$/, "");
|
|
564
|
+
paramMap[paramName] = singular + "Id";
|
|
565
|
+
} else {
|
|
566
|
+
// No parent segment, just call it "id"
|
|
567
|
+
paramMap[paramName] = "id";
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return paramMap;
|
|
572
|
+
}
|
|
573
|
+
|
|
293
574
|
function extractQueryFieldsFromFile(content) {
|
|
294
575
|
const fields = new Set();
|
|
295
576
|
|
|
@@ -355,8 +636,432 @@ function regexpToPath(regexp, keys) {
|
|
|
355
636
|
}
|
|
356
637
|
}
|
|
357
638
|
|
|
639
|
+
function scanAllExpressFiles(cwd) {
|
|
640
|
+
const fs = require("fs");
|
|
641
|
+
const path = require("path");
|
|
642
|
+
|
|
643
|
+
const SKIP_DIRS = [
|
|
644
|
+
"node_modules",
|
|
645
|
+
".git",
|
|
646
|
+
".next",
|
|
647
|
+
"dist",
|
|
648
|
+
"build",
|
|
649
|
+
".cache",
|
|
650
|
+
"coverage",
|
|
651
|
+
"out",
|
|
652
|
+
];
|
|
653
|
+
|
|
654
|
+
const routeFiles = [];
|
|
655
|
+
|
|
656
|
+
function walk(dir, depth) {
|
|
657
|
+
if (depth > 4) return;
|
|
658
|
+
|
|
659
|
+
let entries;
|
|
660
|
+
try {
|
|
661
|
+
entries = fs.readdirSync(dir);
|
|
662
|
+
} catch {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (SKIP_DIRS.includes(entry)) continue;
|
|
668
|
+
|
|
669
|
+
const fullPath = path.join(dir, entry);
|
|
670
|
+
let stat;
|
|
671
|
+
try {
|
|
672
|
+
stat = fs.statSync(fullPath);
|
|
673
|
+
} catch {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (stat.isDirectory()) {
|
|
678
|
+
walk(fullPath, depth + 1);
|
|
679
|
+
} else if (/\.(js|ts)$/.test(entry)) {
|
|
680
|
+
try {
|
|
681
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
682
|
+
// Check if file contains Express route definitions
|
|
683
|
+
const isExpressFile =
|
|
684
|
+
content.includes("express()") ||
|
|
685
|
+
content.includes("express.Router()") ||
|
|
686
|
+
content.includes("Router()") ||
|
|
687
|
+
// Match only route-like patterns: app.get('/...) or router.post('/...)
|
|
688
|
+
/(?:app|router|server)\.(get|post|put|delete|patch|all)\s*\(\s*['"`]\//.test(
|
|
689
|
+
content,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
if (isExpressFile) {
|
|
693
|
+
routeFiles.push(fullPath);
|
|
694
|
+
}
|
|
695
|
+
} catch {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
walk(cwd, 0);
|
|
703
|
+
return routeFiles;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function buildBodyMap(cwd) {
|
|
707
|
+
const fs = require("fs");
|
|
708
|
+
const path = require("path");
|
|
709
|
+
const bodyMap = {}; // { functionName: { type: "object", properties: {...} } }
|
|
710
|
+
|
|
711
|
+
const SKIP_DIRS = [
|
|
712
|
+
"node_modules",
|
|
713
|
+
".git",
|
|
714
|
+
".next",
|
|
715
|
+
"dist",
|
|
716
|
+
"build",
|
|
717
|
+
".cache",
|
|
718
|
+
"coverage",
|
|
719
|
+
"out",
|
|
720
|
+
];
|
|
721
|
+
|
|
722
|
+
function walk(dir, depth) {
|
|
723
|
+
if (depth > 4) return;
|
|
724
|
+
let entries;
|
|
725
|
+
try {
|
|
726
|
+
entries = fs.readdirSync(dir);
|
|
727
|
+
} catch {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
for (const entry of entries) {
|
|
732
|
+
if (SKIP_DIRS.includes(entry)) continue;
|
|
733
|
+
const fullPath = path.join(dir, entry);
|
|
734
|
+
let stat;
|
|
735
|
+
try {
|
|
736
|
+
stat = fs.statSync(fullPath);
|
|
737
|
+
} catch {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (stat.isDirectory()) {
|
|
742
|
+
walk(fullPath, depth + 1);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!/\.(js|ts)$/.test(entry)) continue;
|
|
747
|
+
|
|
748
|
+
let content;
|
|
749
|
+
try {
|
|
750
|
+
content = fs.readFileSync(fullPath, "utf8");
|
|
751
|
+
} catch {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Skip files with no req.body at all
|
|
756
|
+
// Split file into individual function chunks more reliably
|
|
757
|
+
// by finding each function and extracting a reasonable chunk after it
|
|
758
|
+
|
|
759
|
+
const fnPatterns = [
|
|
760
|
+
// function loginUser(req, res) {
|
|
761
|
+
/(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*\{/g,
|
|
762
|
+
// const loginUser = async (req, res) => {
|
|
763
|
+
/const\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
764
|
+
// exports.loginUser = async (req, res) => {
|
|
765
|
+
/exports\.([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
766
|
+
// export const loginUser = async (req, res) => {
|
|
767
|
+
/export\s+const\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/g,
|
|
768
|
+
];
|
|
769
|
+
|
|
770
|
+
// Map every function name in this file to the whole file's body fields.
|
|
771
|
+
// Brace-counting is unreliable across JS/TS variations, so we use
|
|
772
|
+
// file-level field extraction here. Per-function accuracy is handled
|
|
773
|
+
// at runtime by Strategy 2 (fn.toString()) in extractRoutes.
|
|
774
|
+
const fileFields = extractBodyFieldsFromFile(content);
|
|
775
|
+
if (fileFields) {
|
|
776
|
+
const fnNames = new Set();
|
|
777
|
+
for (const pattern of fnPatterns) {
|
|
778
|
+
pattern.lastIndex = 0; // reset regex state before each use
|
|
779
|
+
let m;
|
|
780
|
+
while ((m = pattern.exec(content)) !== null) fnNames.add(m[1]);
|
|
781
|
+
}
|
|
782
|
+
for (const name of fnNames) {
|
|
783
|
+
if (!bodyMap[name]) {
|
|
784
|
+
// don't overwrite a more precise entry
|
|
785
|
+
bodyMap[name] = fileFields;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
walk(cwd, 0);
|
|
793
|
+
return bodyMap;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function extractHandlerName(layer) {
|
|
797
|
+
const handlers = layer.route.stack.map((h) => h.handle || h);
|
|
798
|
+
|
|
799
|
+
const SKIP_NAMES = new Set([
|
|
800
|
+
"anonymous",
|
|
801
|
+
"",
|
|
802
|
+
"bound dispatch",
|
|
803
|
+
"middleware",
|
|
804
|
+
"protect",
|
|
805
|
+
"admin",
|
|
806
|
+
"auth",
|
|
807
|
+
"verify",
|
|
808
|
+
"validate",
|
|
809
|
+
"isAuth",
|
|
810
|
+
"isAdmin",
|
|
811
|
+
"checkAuth",
|
|
812
|
+
"authenticate",
|
|
813
|
+
]);
|
|
814
|
+
|
|
815
|
+
// Try from last to first, skip known middleware names
|
|
816
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
817
|
+
const fn = handlers[i];
|
|
818
|
+
const name = fn.name || "";
|
|
819
|
+
if (name && !SKIP_NAMES.has(name) && !name.startsWith("bound ")) {
|
|
820
|
+
return name;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function convertNextJsSegment(segment) {
|
|
827
|
+
// Skip catch-all [...slug] and optional [[...slug]]
|
|
828
|
+
if (/^\[?\[\.\.\./.test(segment)) return null;
|
|
829
|
+
// Convert [projectId] → :projectId (Next.js / Nuxt / SvelteKit)
|
|
830
|
+
if (/^\[([^\]]+)\]$/.test(segment))
|
|
831
|
+
return segment.replace(/^\[([^\]]+)\]$/, ":$1");
|
|
832
|
+
// Convert $projectId → :projectId (Remix)
|
|
833
|
+
if (/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/.test(segment))
|
|
834
|
+
return segment.replace(/^\$/, ":");
|
|
835
|
+
return segment;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function extractParamPositions(segments) {
|
|
839
|
+
const paramMap = {};
|
|
840
|
+
segments.forEach(function (segment, index) {
|
|
841
|
+
if (segment && segment.startsWith(":")) {
|
|
842
|
+
const paramName = segment.slice(1);
|
|
843
|
+
paramMap[paramName] = index;
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
return paramMap;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function scanConfigBasedRoutes(cwd) {
|
|
850
|
+
const fs = require("fs");
|
|
851
|
+
const path = require("path");
|
|
852
|
+
const patterns = [];
|
|
853
|
+
const seen = new Set();
|
|
854
|
+
|
|
855
|
+
// Files to scan for route definitions
|
|
856
|
+
const filesToCheck = [
|
|
857
|
+
// React Router — common file names
|
|
858
|
+
path.join(cwd, "src", "App.jsx"),
|
|
859
|
+
path.join(cwd, "src", "App.tsx"),
|
|
860
|
+
path.join(cwd, "src", "App.js"),
|
|
861
|
+
path.join(cwd, "src", "router.jsx"),
|
|
862
|
+
path.join(cwd, "src", "router.tsx"),
|
|
863
|
+
path.join(cwd, "src", "router.js"),
|
|
864
|
+
path.join(cwd, "src", "routes.jsx"),
|
|
865
|
+
path.join(cwd, "src", "routes.tsx"),
|
|
866
|
+
path.join(cwd, "src", "routes.js"),
|
|
867
|
+
path.join(cwd, "src", "Router.jsx"),
|
|
868
|
+
path.join(cwd, "src", "Router.tsx"),
|
|
869
|
+
// Vue Router
|
|
870
|
+
path.join(cwd, "src", "router", "index.js"),
|
|
871
|
+
path.join(cwd, "src", "router", "index.ts"),
|
|
872
|
+
path.join(cwd, "src", "router.js"),
|
|
873
|
+
path.join(cwd, "src", "router.ts"),
|
|
874
|
+
// Angular
|
|
875
|
+
path.join(cwd, "src", "app", "app-routing.module.ts"),
|
|
876
|
+
path.join(cwd, "src", "app", "app.routes.ts"),
|
|
877
|
+
];
|
|
878
|
+
|
|
879
|
+
for (const filePath of filesToCheck) {
|
|
880
|
+
if (!fs.existsSync(filePath)) continue;
|
|
881
|
+
|
|
882
|
+
let content;
|
|
883
|
+
try {
|
|
884
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
885
|
+
} catch {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Pattern 1 — React Router JSX: <Route path="/:projectId/dashboard" />
|
|
890
|
+
const jsxRouteMatches = content.matchAll(
|
|
891
|
+
/<Route[^>]+path=["']([^"']+)["']/g,
|
|
892
|
+
);
|
|
893
|
+
for (const match of jsxRouteMatches) {
|
|
894
|
+
addConfigPattern(match[1], seen, patterns);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Pattern 2 — React Router / Vue Router object: { path: '/:projectId/dashboard' }
|
|
898
|
+
const objectRouteMatches = content.matchAll(/path\s*:\s*["']([^"']+)["']/g);
|
|
899
|
+
for (const match of objectRouteMatches) {
|
|
900
|
+
addConfigPattern(match[1], seen, patterns);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Pattern 3 — Angular: { path: ':projectId/dashboard' }
|
|
904
|
+
const angularRouteMatches = content.matchAll(
|
|
905
|
+
/\{\s*path\s*:\s*["']([^"']+)["']/g,
|
|
906
|
+
);
|
|
907
|
+
for (const match of angularRouteMatches) {
|
|
908
|
+
addConfigPattern(match[1], seen, patterns);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return patterns;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function addConfigPattern(routePath, seen, patterns) {
|
|
916
|
+
// Skip empty, wildcard and catch-all routes
|
|
917
|
+
if (!routePath || routePath === "*" || routePath === "**") return;
|
|
918
|
+
// Skip routes with no dynamic params
|
|
919
|
+
if (!routePath.includes(":") && !routePath.includes("$")) return;
|
|
920
|
+
|
|
921
|
+
// Normalize — ensure leading slash
|
|
922
|
+
const normalized = routePath.startsWith("/") ? routePath : "/" + routePath;
|
|
923
|
+
|
|
924
|
+
if (seen.has(normalized)) return;
|
|
925
|
+
seen.add(normalized);
|
|
926
|
+
|
|
927
|
+
// Extract params and their positions
|
|
928
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
929
|
+
const paramMap = {};
|
|
930
|
+
segments.forEach(function (segment, index) {
|
|
931
|
+
if (segment.startsWith(":")) {
|
|
932
|
+
paramMap[segment.slice(1)] = index;
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
if (Object.keys(paramMap).length === 0) return;
|
|
937
|
+
|
|
938
|
+
patterns.push({ pattern: normalized, params: paramMap });
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function scanFrontendRoutes(cwd) {
|
|
942
|
+
const fs = require("fs");
|
|
943
|
+
const path = require("path");
|
|
944
|
+
const patterns = [];
|
|
945
|
+
const seen = new Set();
|
|
946
|
+
|
|
947
|
+
const dirsToScan = [
|
|
948
|
+
// Next.js Pages Router
|
|
949
|
+
path.join(cwd, "pages"),
|
|
950
|
+
path.join(cwd, "src", "pages"),
|
|
951
|
+
// Next.js App Router
|
|
952
|
+
path.join(cwd, "app"),
|
|
953
|
+
path.join(cwd, "src", "app"),
|
|
954
|
+
// Nuxt.js
|
|
955
|
+
path.join(cwd, "pages"),
|
|
956
|
+
// SvelteKit
|
|
957
|
+
path.join(cwd, "src", "routes"),
|
|
958
|
+
path.join(cwd, "routes"),
|
|
959
|
+
// Remix
|
|
960
|
+
path.join(cwd, "app", "routes"),
|
|
961
|
+
];
|
|
962
|
+
|
|
963
|
+
function walkDir(dir, routeSegments) {
|
|
964
|
+
if (!fs.existsSync(dir)) return;
|
|
965
|
+
let entries;
|
|
966
|
+
try {
|
|
967
|
+
entries = fs.readdirSync(dir);
|
|
968
|
+
} catch {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
entries.forEach(function (file) {
|
|
973
|
+
const fullPath = path.join(dir, file);
|
|
974
|
+
let stat;
|
|
975
|
+
try {
|
|
976
|
+
stat = fs.statSync(fullPath);
|
|
977
|
+
} catch {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (stat.isDirectory()) {
|
|
982
|
+
if (file === "api") return; // skip API routes
|
|
983
|
+
if (file.startsWith("_")) return; // skip Next.js internals
|
|
984
|
+
if (/^\(.*\)$/.test(file)) {
|
|
985
|
+
// route groups like (marketing) — don't add to URL
|
|
986
|
+
walkDir(fullPath, routeSegments);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const segment = convertNextJsSegment(file);
|
|
990
|
+
if (!segment) return;
|
|
991
|
+
walkDir(fullPath, routeSegments.concat(segment));
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (!/\.(js|ts|jsx|tsx|vue|svelte)$/.test(file)) return;
|
|
996
|
+
if (file.startsWith("_")) return;
|
|
997
|
+
|
|
998
|
+
const routeName = file.replace(/\.(js|ts|jsx|tsx|vue|svelte)$/, "");
|
|
999
|
+
|
|
1000
|
+
// Skip non-page files
|
|
1001
|
+
if (
|
|
1002
|
+
["layout", "loading", "error", "template", "not-found"].includes(
|
|
1003
|
+
routeName,
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
1006
|
+
return;
|
|
1007
|
+
|
|
1008
|
+
// SvelteKit uses +page.svelte — skip +layout, +error etc.
|
|
1009
|
+
if (routeName.startsWith("+") && routeName !== "+page") return;
|
|
1010
|
+
|
|
1011
|
+
// Remix uses dots as path separators — e.g. $projectId.dashboard.tsx → /:projectId/dashboard
|
|
1012
|
+
const isRemixRoute =
|
|
1013
|
+
routeName.includes(".") && !routeName.startsWith("+");
|
|
1014
|
+
let finalSegments;
|
|
1015
|
+
|
|
1016
|
+
if (isRemixRoute) {
|
|
1017
|
+
// Split by dots and convert each segment
|
|
1018
|
+
const remixSegments = routeName
|
|
1019
|
+
.split(".")
|
|
1020
|
+
.map((s) => convertNextJsSegment(s) || s);
|
|
1021
|
+
finalSegments = routeSegments.concat(remixSegments);
|
|
1022
|
+
} else if (
|
|
1023
|
+
routeName === "index" ||
|
|
1024
|
+
routeName === "page" ||
|
|
1025
|
+
routeName === "+page"
|
|
1026
|
+
) {
|
|
1027
|
+
finalSegments = routeSegments;
|
|
1028
|
+
} else {
|
|
1029
|
+
finalSegments = routeSegments.concat(
|
|
1030
|
+
convertNextJsSegment(routeName) || routeName,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const pattern = "/" + finalSegments.filter(Boolean).join("/");
|
|
1035
|
+
if (seen.has(pattern)) return;
|
|
1036
|
+
seen.add(pattern);
|
|
1037
|
+
|
|
1038
|
+
const paramMap = extractParamPositions(finalSegments);
|
|
1039
|
+
if (Object.keys(paramMap).length === 0) return; // no dynamic params, skip static routes
|
|
1040
|
+
|
|
1041
|
+
patterns.push({ pattern, params: paramMap });
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
dirsToScan.forEach(function (dir) {
|
|
1046
|
+
if (fs.existsSync(dir)) walkDir(dir, []);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// Also scan config-based frameworks (React Router, Vue Router, Angular)
|
|
1050
|
+
const configPatterns = scanConfigBasedRoutes(cwd);
|
|
1051
|
+
configPatterns.forEach(function (p) {
|
|
1052
|
+
if (!seen.has(p.pattern)) {
|
|
1053
|
+
seen.add(p.pattern);
|
|
1054
|
+
patterns.push(p);
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
return patterns;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
358
1061
|
module.exports = {
|
|
359
1062
|
scanExpressRoutes,
|
|
360
1063
|
scanNextJsRoutes,
|
|
1064
|
+
scanNextJsAppRoutes,
|
|
361
1065
|
extractPathParams,
|
|
1066
|
+
scanFrontendRoutes,
|
|
362
1067
|
};
|