create-backlist 7.4.0 → 9.0.1
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/index.js +629 -212
- package/bin/qa.js +157 -69
- package/package.json +25 -20
- package/src/ai-agent.js +581 -124
- package/src/analyzer.js +661 -522
- package/src/qa/qa-engine.js +1068 -790
package/src/analyzer.js
CHANGED
|
@@ -1,73 +1,151 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Backlist Frontend Analyzer — analyzer.js v8.0
|
|
3
|
+
// AST-driven frontend API surface extractor
|
|
4
|
+
// Copyright (c) W.A.H.ISHAN — MIT License
|
|
5
|
+
//
|
|
6
|
+
// NEW in v8.0:
|
|
7
|
+
// ✦ Parallel file processing (configurable concurrency)
|
|
8
|
+
// ✦ Enhanced type inference (Date, Email, UUID, Enum detection)
|
|
9
|
+
// ✦ GraphQL fetch detection (gql + fetch hybrid)
|
|
10
|
+
// ✦ React Query / SWR / tRPC call extraction
|
|
11
|
+
// ✦ Deep DOM Sync — maps <input type> + aria labels to field types
|
|
12
|
+
// ✦ Env file (.env, .env.local) auto-loading
|
|
13
|
+
// ✦ Incremental analysis with file-hash caching
|
|
14
|
+
// ✦ Typed result objects with JSDoc
|
|
15
|
+
// ✦ Structured warnings instead of silent skips
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
import fs from 'fs-extra';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { glob } from 'glob';
|
|
22
|
+
|
|
23
|
+
import parser from '@babel/parser';
|
|
24
|
+
import _traverse from '@babel/traverse';
|
|
7
25
|
const traverse = _traverse.default || _traverse;
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
|
|
10
30
|
|
|
11
31
|
const STATIC_ASSET_EXTENSIONS = new Set([
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
|
|
33
|
+
'.css', '.scss', '.less', '.woff', '.woff2', '.ttf', '.eot',
|
|
34
|
+
'.mp3', '.mp4', '.webm', '.pdf', '.zip', '.gz',
|
|
15
35
|
]);
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
37
|
+
const NEXTJS_HTTP_EXPORTS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
|
|
38
|
+
|
|
39
|
+
const PARSE_OPTIONS = {
|
|
40
|
+
sourceType: 'module',
|
|
41
|
+
plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
|
|
42
|
+
errorRecovery: true, // don't throw on minor syntax errors
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const PARALLEL_LIMIT = 20; // max files processed simultaneously
|
|
46
|
+
|
|
47
|
+
// ── Type inference ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** @typedef {'String'|'Number'|'Boolean'|'Date'|'Email'|'UUID'|'Array'|'Object'|'Null'|'Enum'} FieldType */
|
|
50
|
+
|
|
51
|
+
const EMAIL_PATTERN = /email/i;
|
|
52
|
+
const DATE_PATTERN = /date|time|at|_on|createdAt|updatedAt/i;
|
|
53
|
+
const UUID_PATTERN = /id$|uuid|guid/i;
|
|
54
|
+
const BOOL_PATTERN = /^(is|has|can|should|enabled|active|visible|flag)/i;
|
|
55
|
+
const ARRAY_PATTERN = /s$|list|items|tags|ids|array/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer a field type from its name + AST node value.
|
|
59
|
+
* @param {string} fieldName
|
|
60
|
+
* @param {object} valueNode
|
|
61
|
+
* @returns {FieldType}
|
|
62
|
+
*/
|
|
63
|
+
function inferFieldType(fieldName, valueNode) {
|
|
64
|
+
// Node-type-based inference first
|
|
65
|
+
if (valueNode) {
|
|
66
|
+
switch (valueNode.type) {
|
|
67
|
+
case 'StringLiteral':
|
|
68
|
+
if (EMAIL_PATTERN.test(fieldName)) return 'Email';
|
|
69
|
+
if (DATE_PATTERN.test(fieldName)) return 'Date';
|
|
70
|
+
if (UUID_PATTERN.test(fieldName)) return 'UUID';
|
|
71
|
+
return 'String';
|
|
72
|
+
case 'NumericLiteral': return 'Number';
|
|
73
|
+
case 'BooleanLiteral': return 'Boolean';
|
|
74
|
+
case 'NullLiteral': return 'Null';
|
|
75
|
+
case 'ArrayExpression': return 'Array';
|
|
76
|
+
case 'ObjectExpression':return 'Object';
|
|
77
|
+
case 'NewExpression':
|
|
78
|
+
if (valueNode.callee?.name === 'Date') return 'Date';
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Name-pattern-based inference as fallback
|
|
84
|
+
if (EMAIL_PATTERN.test(fieldName)) return 'Email';
|
|
85
|
+
if (DATE_PATTERN.test(fieldName)) return 'Date';
|
|
86
|
+
if (UUID_PATTERN.test(fieldName)) return 'UUID';
|
|
87
|
+
if (BOOL_PATTERN.test(fieldName)) return 'Boolean';
|
|
88
|
+
if (ARRAY_PATTERN.test(fieldName)) return 'Array';
|
|
89
|
+
|
|
90
|
+
return 'String';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── String utilities ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
20
95
|
function normalizeSlashes(p) {
|
|
21
|
-
return String(p
|
|
96
|
+
return String(p ?? '').replace(/\\/g, '/');
|
|
22
97
|
}
|
|
23
98
|
|
|
24
99
|
function toTitleCase(str) {
|
|
25
|
-
if (!str) return
|
|
100
|
+
if (!str) return 'Default';
|
|
26
101
|
return String(str)
|
|
27
102
|
.replace(/[-_]+(\w)/g, (_, c) => c.toUpperCase())
|
|
28
|
-
.replace(/^\w/,
|
|
29
|
-
.replace(/[^a-zA-Z0-9]/g,
|
|
103
|
+
.replace(/^\w/, c => c.toUpperCase())
|
|
104
|
+
.replace(/[^a-zA-Z0-9]/g, '');
|
|
30
105
|
}
|
|
31
106
|
|
|
32
|
-
// Convert `/api/users/{id}` -> `/api/users/:id`
|
|
33
107
|
function normalizeRouteForBackend(urlValue) {
|
|
34
|
-
return String(urlValue
|
|
108
|
+
return String(urlValue ?? '').replace(/\{(\w+)\}/g, ':$1');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function singularize(word) {
|
|
112
|
+
if (!word) return word;
|
|
113
|
+
if (word.endsWith('ies')) return word.slice(0, -3) + 'y';
|
|
114
|
+
if (/ses$|xes$|zes$/.test(word)) return word.slice(0, -2);
|
|
115
|
+
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
|
|
116
|
+
return word;
|
|
35
117
|
}
|
|
36
118
|
|
|
119
|
+
// ── URL utilities ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
37
121
|
function extractApiPath(urlValue, envMap = new Map()) {
|
|
38
122
|
if (!urlValue) return null;
|
|
39
123
|
|
|
40
|
-
//
|
|
41
|
-
const idx = urlValue.indexOf(
|
|
124
|
+
// Direct /api/ match
|
|
125
|
+
const idx = urlValue.indexOf('/api/');
|
|
42
126
|
if (idx !== -1) return urlValue.slice(idx);
|
|
43
127
|
|
|
44
|
-
//
|
|
45
|
-
let resolved = urlValue
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return envMap.get(varName) || "";
|
|
50
|
-
});
|
|
128
|
+
// Resolve env vars
|
|
129
|
+
let resolved = urlValue.replace(
|
|
130
|
+
/\{(NEXT_PUBLIC_|REACT_APP_|VITE_)[^}]+\}/g,
|
|
131
|
+
match => envMap.get(match.slice(1, -1)) ?? ''
|
|
132
|
+
);
|
|
51
133
|
|
|
52
|
-
|
|
53
|
-
const idx2 = resolved.indexOf("/api/");
|
|
134
|
+
const idx2 = resolved.indexOf('/api/');
|
|
54
135
|
if (idx2 !== -1) return resolved.slice(idx2);
|
|
55
136
|
|
|
56
|
-
//
|
|
137
|
+
// Parse absolute URL
|
|
57
138
|
if (/^https?:\/\//.test(resolved)) {
|
|
58
139
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
if (pathname && pathname !== "/") return pathname;
|
|
140
|
+
const { pathname } = new URL(resolved);
|
|
141
|
+
if (pathname && pathname !== '/') return pathname;
|
|
62
142
|
} catch {}
|
|
63
143
|
}
|
|
64
144
|
|
|
65
|
-
//
|
|
66
|
-
if (resolved.startsWith(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (STATIC_ASSET_EXTENSIONS.has(ext)) return null;
|
|
70
|
-
return resolved;
|
|
145
|
+
// Accept relative paths — filter static assets
|
|
146
|
+
if (resolved.startsWith('/') && resolved.length > 1) {
|
|
147
|
+
const ext = path.extname(resolved.split('?')[0]).toLowerCase();
|
|
148
|
+
return STATIC_ASSET_EXTENSIONS.has(ext) ? null : resolved;
|
|
71
149
|
}
|
|
72
150
|
|
|
73
151
|
return null;
|
|
@@ -78,529 +156,536 @@ function extractPathParams(route) {
|
|
|
78
156
|
const re = /[:{]([a-zA-Z0-9_]+)[}]/g;
|
|
79
157
|
let m;
|
|
80
158
|
while ((m = re.exec(route))) params.push(m[1]);
|
|
81
|
-
return
|
|
159
|
+
return [...new Set(params)];
|
|
82
160
|
}
|
|
83
161
|
|
|
84
|
-
function
|
|
162
|
+
function extractQueryParams(urlValue) {
|
|
85
163
|
try {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.split("&")
|
|
91
|
-
.map((p) => p.split("=")[0])
|
|
92
|
-
.filter(Boolean);
|
|
93
|
-
} catch {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
164
|
+
const qi = urlValue.indexOf('?');
|
|
165
|
+
if (qi === -1) return [];
|
|
166
|
+
return urlValue.slice(qi + 1).split('&').map(p => p.split('=')[0]).filter(Boolean);
|
|
167
|
+
} catch { return []; }
|
|
96
168
|
}
|
|
97
169
|
|
|
98
|
-
function
|
|
99
|
-
const apiPath = extractApiPath(urlValue)
|
|
100
|
-
const parts = String(apiPath).split(
|
|
101
|
-
const
|
|
102
|
-
|
|
170
|
+
function deriveControllerName(urlValue) {
|
|
171
|
+
const apiPath = extractApiPath(urlValue) ?? urlValue;
|
|
172
|
+
const parts = String(apiPath).split('/').filter(Boolean);
|
|
173
|
+
const apiIdx = parts.indexOf('api');
|
|
103
174
|
let seg = null;
|
|
104
|
-
if (
|
|
105
|
-
seg = parts[
|
|
106
|
-
|
|
107
|
-
// skip version segment (v1, v2, v10...)
|
|
108
|
-
if (seg && /^v\d+$/i.test(seg)) {
|
|
109
|
-
seg = parts[apiIndex + 2] || seg;
|
|
110
|
-
}
|
|
175
|
+
if (apiIdx >= 0) {
|
|
176
|
+
seg = parts[apiIdx + 1] ?? null;
|
|
177
|
+
if (seg && /^v\d+$/i.test(seg)) seg = parts[apiIdx + 2] ?? seg;
|
|
111
178
|
} else {
|
|
112
|
-
seg = parts[0]
|
|
179
|
+
seg = parts[0] ?? null;
|
|
113
180
|
}
|
|
114
|
-
|
|
115
181
|
return toTitleCase(seg);
|
|
116
182
|
}
|
|
117
183
|
|
|
118
184
|
function deriveActionName(method, route) {
|
|
119
|
-
const cleaned = String(route).replace(/^\/api\//,
|
|
120
|
-
const last = cleaned.trim().split(/\s+/).filter(Boolean).pop()
|
|
185
|
+
const cleaned = String(route).replace(/^\/api\//, '/').replace(/[/:{}-]/g, ' ');
|
|
186
|
+
const last = cleaned.trim().split(/\s+/).filter(Boolean).pop() ?? 'Action';
|
|
121
187
|
return `${String(method).toLowerCase()}${toTitleCase(last)}`;
|
|
122
188
|
}
|
|
123
189
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
190
|
+
// ── Env file loader ───────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Load .env / .env.local / .env.development from project root.
|
|
194
|
+
* Returns a Map of variable name → value.
|
|
195
|
+
* @param {string} rootDir
|
|
196
|
+
* @returns {Map<string, string>}
|
|
197
|
+
*/
|
|
198
|
+
export async function loadEnvFiles(rootDir) {
|
|
199
|
+
const envMap = new Map();
|
|
200
|
+
const candidates = ['.env', '.env.local', '.env.development', '.env.production'];
|
|
201
|
+
|
|
202
|
+
for (const fname of candidates) {
|
|
203
|
+
const fpath = path.join(rootDir, fname);
|
|
204
|
+
if (!fs.existsSync(fpath)) continue;
|
|
205
|
+
try {
|
|
206
|
+
const content = await fs.readFile(fpath, 'utf-8');
|
|
207
|
+
for (const line of content.split('\n')) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
210
|
+
const eq = trimmed.indexOf('=');
|
|
211
|
+
if (eq === -1) continue;
|
|
212
|
+
const key = trimmed.slice(0, eq).trim();
|
|
213
|
+
const val = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
214
|
+
if (key) envMap.set(key, val);
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
return envMap;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── AST helpers ───────────────────────────────────────────────────────────
|
|
222
|
+
|
|
127
223
|
function extractEnvVarName(node) {
|
|
128
|
-
// process.env.
|
|
224
|
+
// process.env.X
|
|
129
225
|
if (
|
|
130
|
-
node.type ===
|
|
131
|
-
node.object?.type ===
|
|
132
|
-
node.object.object?.type ===
|
|
133
|
-
node.object.object.name ===
|
|
134
|
-
node.object.property?.name ===
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
// import.meta.env.VITE_API_URL
|
|
226
|
+
node.type === 'MemberExpression' &&
|
|
227
|
+
node.object?.type === 'MemberExpression' &&
|
|
228
|
+
node.object.object?.type === 'Identifier' &&
|
|
229
|
+
node.object.object.name === 'process' &&
|
|
230
|
+
node.object.property?.name === 'env'
|
|
231
|
+
) return node.property?.name ?? null;
|
|
232
|
+
|
|
233
|
+
// import.meta.env.X
|
|
140
234
|
if (
|
|
141
|
-
node.type ===
|
|
142
|
-
node.object?.type ===
|
|
143
|
-
node.object.object?.type ===
|
|
144
|
-
node.object.property?.name ===
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return node.property.name;
|
|
148
|
-
}
|
|
235
|
+
node.type === 'MemberExpression' &&
|
|
236
|
+
node.object?.type === 'MemberExpression' &&
|
|
237
|
+
node.object.object?.type === 'MetaProperty' &&
|
|
238
|
+
node.object.property?.name === 'env'
|
|
239
|
+
) return node.property?.name ?? null;
|
|
240
|
+
|
|
149
241
|
return null;
|
|
150
242
|
}
|
|
151
243
|
|
|
152
244
|
function getUrlValue(urlNode, envMap = new Map()) {
|
|
153
245
|
if (!urlNode) return null;
|
|
154
246
|
|
|
155
|
-
if (urlNode.type ===
|
|
247
|
+
if (urlNode.type === 'StringLiteral') return urlNode.value;
|
|
156
248
|
|
|
157
|
-
if (urlNode.type ===
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
let out = "";
|
|
249
|
+
if (urlNode.type === 'TemplateLiteral') {
|
|
250
|
+
let out = '';
|
|
251
|
+
const { quasis = [], expressions = [] } = urlNode;
|
|
161
252
|
for (let i = 0; i < quasis.length; i++) {
|
|
162
253
|
out += quasis[i].value.raw;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
} else {
|
|
176
|
-
out += `{param${i + 1}}`;
|
|
177
|
-
}
|
|
254
|
+
const expr = expressions[i];
|
|
255
|
+
if (!expr) continue;
|
|
256
|
+
if (expr.type === 'Identifier') {
|
|
257
|
+
out += `{${expr.name}}`;
|
|
258
|
+
} else if (expr.type === 'MemberExpression') {
|
|
259
|
+
const envName = extractEnvVarName(expr);
|
|
260
|
+
out += envName
|
|
261
|
+
? (envMap.get(envName) ?? `{${envName}}`)
|
|
262
|
+
: `{param${i + 1}}`;
|
|
263
|
+
} else {
|
|
264
|
+
out += `{param${i + 1}}`;
|
|
178
265
|
}
|
|
179
266
|
}
|
|
180
267
|
return out;
|
|
181
268
|
}
|
|
182
269
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
if (left || right) return (left || "") + (right || "");
|
|
270
|
+
if (urlNode.type === 'BinaryExpression' && urlNode.operator === '+') {
|
|
271
|
+
const l = getUrlValue(urlNode.left, envMap);
|
|
272
|
+
const r = getUrlValue(urlNode.right, envMap);
|
|
273
|
+
return (l ?? '') + (r ?? '');
|
|
188
274
|
}
|
|
189
275
|
|
|
190
|
-
|
|
191
|
-
if (urlNode.type === "MemberExpression") {
|
|
276
|
+
if (urlNode.type === 'MemberExpression') {
|
|
192
277
|
const envName = extractEnvVarName(urlNode);
|
|
193
|
-
if (envName
|
|
194
|
-
return envMap.get(envName);
|
|
195
|
-
}
|
|
196
|
-
if (envName) {
|
|
197
|
-
return `{${envName}}`;
|
|
198
|
-
}
|
|
278
|
+
if (envName) return envMap.get(envName) ?? `{${envName}}`;
|
|
199
279
|
}
|
|
200
280
|
|
|
201
281
|
return null;
|
|
202
282
|
}
|
|
203
283
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const prop = node.callee.property;
|
|
212
|
-
if (!prop || prop.type !== "Identifier") return null;
|
|
213
|
-
|
|
214
|
-
const name = prop.name.toLowerCase();
|
|
215
|
-
if (!HTTP_METHODS.has(name)) return null;
|
|
216
|
-
|
|
217
|
-
return name.toUpperCase();
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// -------------------------
|
|
221
|
-
// Request body schema (simple + identifier tracing)
|
|
222
|
-
// -------------------------
|
|
223
|
-
function inferTypeFromNode(node) {
|
|
224
|
-
if (!node) return "String";
|
|
225
|
-
switch (node.type) {
|
|
226
|
-
case "StringLiteral":
|
|
227
|
-
return "String";
|
|
228
|
-
case "NumericLiteral":
|
|
229
|
-
return "Number";
|
|
230
|
-
case "BooleanLiteral":
|
|
231
|
-
return "Boolean";
|
|
232
|
-
case "NullLiteral":
|
|
233
|
-
return "String";
|
|
234
|
-
default:
|
|
235
|
-
return "String";
|
|
236
|
-
}
|
|
284
|
+
function isJSONStringify(node) {
|
|
285
|
+
return (
|
|
286
|
+
node?.type === 'CallExpression' &&
|
|
287
|
+
node.callee?.type === 'MemberExpression' &&
|
|
288
|
+
node.callee.object?.name === 'JSON' &&
|
|
289
|
+
node.callee.property?.name === 'stringify'
|
|
290
|
+
);
|
|
237
291
|
}
|
|
238
292
|
|
|
239
293
|
function extractObjectSchema(objExpr) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
294
|
+
if (!objExpr || objExpr.type !== 'ObjectExpression') return null;
|
|
295
|
+
const fields = {};
|
|
243
296
|
for (const prop of objExpr.properties) {
|
|
244
|
-
if (prop.type !==
|
|
245
|
-
|
|
297
|
+
if (prop.type !== 'ObjectProperty') continue;
|
|
246
298
|
const key =
|
|
247
|
-
prop.key.type ===
|
|
248
|
-
|
|
249
|
-
: prop.key.type === "StringLiteral"
|
|
250
|
-
? prop.key.value
|
|
251
|
-
: null;
|
|
252
|
-
|
|
299
|
+
prop.key.type === 'Identifier' ? prop.key.name :
|
|
300
|
+
prop.key.type === 'StringLiteral' ? prop.key.value : null;
|
|
253
301
|
if (!key) continue;
|
|
254
|
-
|
|
302
|
+
fields[key] = inferFieldType(key, prop.value);
|
|
255
303
|
}
|
|
256
|
-
return
|
|
304
|
+
return Object.keys(fields).length ? fields : null;
|
|
257
305
|
}
|
|
258
306
|
|
|
259
|
-
function
|
|
307
|
+
function resolveIdentifier(callPath, name) {
|
|
260
308
|
try {
|
|
261
|
-
const binding = callPath.scope.getBinding(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (declPath.node.type === "VariableDeclarator") return declPath.node.init || null;
|
|
267
|
-
return null;
|
|
268
|
-
} catch {
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
309
|
+
const binding = callPath.scope.getBinding(name);
|
|
310
|
+
const decl = binding?.path?.node;
|
|
311
|
+
return decl?.type === 'VariableDeclarator' ? (decl.init ?? null) : null;
|
|
312
|
+
} catch { return null; }
|
|
271
313
|
}
|
|
272
314
|
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
node.type === "CallExpression" &&
|
|
278
|
-
node.callee &&
|
|
279
|
-
node.callee.type === "MemberExpression" &&
|
|
280
|
-
node.callee.object &&
|
|
281
|
-
node.callee.object.type === "Identifier" &&
|
|
282
|
-
node.callee.object.name === "JSON" &&
|
|
283
|
-
node.callee.property &&
|
|
284
|
-
node.callee.property.type === "Identifier" &&
|
|
285
|
-
node.callee.property.name === "stringify"
|
|
286
|
-
);
|
|
315
|
+
function detectAxiosMethod(node) {
|
|
316
|
+
if (node.callee?.type !== 'MemberExpression') return null;
|
|
317
|
+
const name = node.callee.property?.name?.toLowerCase();
|
|
318
|
+
return HTTP_METHODS.has(name) ? name.toUpperCase() : null;
|
|
287
319
|
}
|
|
288
320
|
|
|
289
|
-
//
|
|
290
|
-
// DB insights: guess db + infer models + seeds
|
|
291
|
-
// -------------------------
|
|
292
|
-
function guessDbTypeFromRepo(rootDir, endpoints = []) {
|
|
293
|
-
try {
|
|
294
|
-
const pkgPath = path.join(rootDir, "package.json");
|
|
295
|
-
if (!fs.existsSync(pkgPath)) return heuristicallyGuessDB(endpoints);
|
|
296
|
-
|
|
297
|
-
const pkg = fs.readJsonSync(pkgPath);
|
|
298
|
-
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
321
|
+
// ── File-hash incremental cache ───────────────────────────────────────────
|
|
299
322
|
|
|
300
|
-
|
|
301
|
-
if (deps.prisma || deps["@prisma/client"]) return "sql-prisma";
|
|
302
|
-
if (deps.sequelize) return "sql-sequelize";
|
|
303
|
-
if (deps.typeorm) return "sql-typeorm";
|
|
323
|
+
const _fileHashCache = new Map();
|
|
304
324
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
325
|
+
async function getFileHash(filepath) {
|
|
326
|
+
const stat = await fs.stat(filepath);
|
|
327
|
+
const key = `${filepath}:${stat.mtimeMs}`;
|
|
328
|
+
if (_fileHashCache.has(key)) return _fileHashCache.get(key);
|
|
329
|
+
const content = await fs.readFile(filepath);
|
|
330
|
+
const hash = createHash('sha1').update(content).digest('hex').slice(0, 12);
|
|
331
|
+
_fileHashCache.set(key, hash);
|
|
332
|
+
return hash;
|
|
309
333
|
}
|
|
310
334
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
// ── Parallel runner ───────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Run async tasks with bounded concurrency.
|
|
339
|
+
* @template T
|
|
340
|
+
* @param {T[]} items
|
|
341
|
+
* @param {(item: T) => Promise<unknown>} fn
|
|
342
|
+
* @param {number} limit
|
|
343
|
+
*/
|
|
344
|
+
async function withConcurrency(items, fn, limit = PARALLEL_LIMIT) {
|
|
345
|
+
const results = [];
|
|
346
|
+
let i = 0;
|
|
347
|
+
|
|
348
|
+
async function worker() {
|
|
349
|
+
while (i < items.length) {
|
|
350
|
+
const idx = i++;
|
|
351
|
+
try { results[idx] = await fn(items[idx]); }
|
|
352
|
+
catch { results[idx] = null; }
|
|
319
353
|
}
|
|
320
354
|
}
|
|
321
|
-
|
|
355
|
+
|
|
356
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
357
|
+
return results;
|
|
322
358
|
}
|
|
323
359
|
|
|
324
|
-
|
|
325
|
-
|
|
360
|
+
// ── Single-file endpoint extractor ────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Extract all API calls from one JS/TS/JSX/TSX file.
|
|
364
|
+
* @param {string} filepath
|
|
365
|
+
* @param {Map<string,string>} envMap
|
|
366
|
+
* @returns {{ endpoints: object[], warnings: object[] }}
|
|
367
|
+
*/
|
|
368
|
+
export async function extractEndpointsFromFile(filepath, envMap = new Map()) {
|
|
369
|
+
const endpoints = [];
|
|
370
|
+
const warnings = [];
|
|
371
|
+
|
|
372
|
+
let code;
|
|
373
|
+
try { code = await fs.readFile(filepath, 'utf-8'); }
|
|
374
|
+
catch (err) {
|
|
375
|
+
warnings.push({ file: filepath, type: 'READ_ERROR', message: err.message });
|
|
376
|
+
return { endpoints, warnings };
|
|
377
|
+
}
|
|
326
378
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
sources: new Set(),
|
|
335
|
-
endpoints: [],
|
|
336
|
-
});
|
|
337
|
-
}
|
|
379
|
+
let ast;
|
|
380
|
+
try {
|
|
381
|
+
ast = parser.parse(code, PARSE_OPTIONS);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
warnings.push({ file: filepath, type: 'PARSE_ERROR', message: err.message });
|
|
384
|
+
return { endpoints, warnings };
|
|
385
|
+
}
|
|
338
386
|
|
|
339
|
-
|
|
340
|
-
m.endpoints.push({ method: ep.method, route: ep.route });
|
|
341
|
-
if (ep.sourceFile) m.sources.add(ep.sourceFile);
|
|
387
|
+
const seen = new Set();
|
|
342
388
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
389
|
+
traverse(ast, {
|
|
390
|
+
CallExpression(callPath) {
|
|
391
|
+
const node = callPath.node;
|
|
392
|
+
|
|
393
|
+
const isFetch = node.callee?.type === 'Identifier' && node.callee.name === 'fetch';
|
|
394
|
+
const axiosMeth = detectAxiosMethod(node);
|
|
395
|
+
if (!isFetch && !axiosMeth) return;
|
|
396
|
+
|
|
397
|
+
let urlValue = null;
|
|
398
|
+
let method = 'GET';
|
|
399
|
+
let schemaFields = null;
|
|
400
|
+
|
|
401
|
+
// ── fetch(url, options) ──────────────────────────────────────────
|
|
402
|
+
if (isFetch) {
|
|
403
|
+
urlValue = getUrlValue(node.arguments[0], envMap);
|
|
404
|
+
const optsNode = node.arguments[1];
|
|
405
|
+
|
|
406
|
+
if (optsNode?.type === 'ObjectExpression') {
|
|
407
|
+
const methodProp = optsNode.properties.find(
|
|
408
|
+
p => p.type === 'ObjectProperty' && p.key?.name === 'method'
|
|
409
|
+
);
|
|
410
|
+
if (methodProp?.value?.type === 'StringLiteral') {
|
|
411
|
+
method = methodProp.value.value.toUpperCase();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
415
|
+
const bodyProp = optsNode.properties.find(
|
|
416
|
+
p => p.type === 'ObjectProperty' && p.key?.name === 'body'
|
|
417
|
+
);
|
|
418
|
+
if (bodyProp) {
|
|
419
|
+
const v = bodyProp.value;
|
|
420
|
+
if (isJSONStringify(v)) {
|
|
421
|
+
const arg = v.arguments[0];
|
|
422
|
+
if (arg?.type === 'ObjectExpression') {
|
|
423
|
+
schemaFields = extractObjectSchema(arg);
|
|
424
|
+
} else if (arg?.type === 'Identifier') {
|
|
425
|
+
const init = resolveIdentifier(callPath, arg.name);
|
|
426
|
+
schemaFields = extractObjectSchema(init);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
347
432
|
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
433
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
434
|
+
// ── axios.get/post/etc. ─────────────────────────────────────────
|
|
435
|
+
if (axiosMeth) {
|
|
436
|
+
method = axiosMeth;
|
|
437
|
+
urlValue = getUrlValue(node.arguments[0], envMap);
|
|
438
|
+
|
|
439
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
440
|
+
const dataArg = node.arguments[1];
|
|
441
|
+
if (dataArg?.type === 'ObjectExpression') {
|
|
442
|
+
schemaFields = extractObjectSchema(dataArg);
|
|
443
|
+
} else if (dataArg?.type === 'Identifier') {
|
|
444
|
+
const init = resolveIdentifier(callPath, dataArg.name);
|
|
445
|
+
schemaFields = extractObjectSchema(init);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
358
449
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
450
|
+
const apiPath = extractApiPath(urlValue, envMap);
|
|
451
|
+
if (!apiPath) return;
|
|
452
|
+
|
|
453
|
+
const route = normalizeRouteForBackend(apiPath.split('?')[0]);
|
|
454
|
+
const normalizedRoute = route.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
455
|
+
const key = `${method}:${normalizedRoute}`;
|
|
456
|
+
|
|
457
|
+
if (seen.has(key)) return;
|
|
458
|
+
seen.add(key);
|
|
459
|
+
|
|
460
|
+
endpoints.push({
|
|
461
|
+
path: apiPath,
|
|
462
|
+
route: normalizedRoute,
|
|
463
|
+
method,
|
|
464
|
+
controllerName: deriveControllerName(apiPath),
|
|
465
|
+
actionName: deriveActionName(method, normalizedRoute),
|
|
466
|
+
pathParams: extractPathParams(normalizedRoute),
|
|
467
|
+
queryParams: extractQueryParams(apiPath),
|
|
468
|
+
schemaFields,
|
|
469
|
+
requestBody: schemaFields ? { fields: schemaFields } : null,
|
|
470
|
+
sourceFile: normalizeSlashes(filepath),
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
return { endpoints, warnings };
|
|
368
476
|
}
|
|
369
477
|
|
|
478
|
+
// ── Relationship detector ─────────────────────────────────────────────────
|
|
479
|
+
|
|
370
480
|
export function detectRelationships(endpoints) {
|
|
371
481
|
const seen = new Set();
|
|
372
|
-
const
|
|
482
|
+
const rels = [];
|
|
373
483
|
|
|
374
484
|
for (const ep of endpoints) {
|
|
375
|
-
const
|
|
376
|
-
const segments = route.split("/").filter(Boolean);
|
|
485
|
+
const segments = (ep.route ?? ep.path ?? '').split('/').filter(Boolean);
|
|
377
486
|
|
|
378
487
|
for (let i = 0; i < segments.length - 2; i++) {
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
parentSegment === "api" ||
|
|
385
|
-
/^v\d+$/i.test(parentSegment) ||
|
|
386
|
-
!paramSegment.startsWith(":")
|
|
387
|
-
) continue;
|
|
488
|
+
const parent = segments[i];
|
|
489
|
+
const param = segments[i + 1];
|
|
490
|
+
const child = segments[i + 2];
|
|
388
491
|
|
|
389
|
-
|
|
390
|
-
const childName = toTitleCase(singularize(childSegment));
|
|
492
|
+
if (parent === 'api' || /^v\d+$/i.test(parent) || !param.startsWith(':')) continue;
|
|
391
493
|
|
|
494
|
+
const parentName = toTitleCase(singularize(parent));
|
|
495
|
+
const childName = toTitleCase(singularize(child));
|
|
392
496
|
if (!parentName || !childName || parentName === childName) continue;
|
|
393
497
|
|
|
394
498
|
const key = `${parentName}:${childName}`;
|
|
395
499
|
if (seen.has(key)) continue;
|
|
396
500
|
seen.add(key);
|
|
397
501
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
type: "oneToMany",
|
|
404
|
-
foreignKey,
|
|
502
|
+
rels.push({
|
|
503
|
+
parent: parentName,
|
|
504
|
+
child: childName,
|
|
505
|
+
type: 'oneToMany',
|
|
506
|
+
foreignKey: `${parentName.charAt(0).toLowerCase()}${parentName.slice(1)}Id`,
|
|
405
507
|
});
|
|
406
508
|
}
|
|
407
509
|
}
|
|
510
|
+
return rels;
|
|
511
|
+
}
|
|
408
512
|
|
|
409
|
-
|
|
513
|
+
// ── DB heuristics ────────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
export function guessDbType(rootDir, endpoints = []) {
|
|
516
|
+
try {
|
|
517
|
+
const pkgPath = path.join(rootDir, 'package.json');
|
|
518
|
+
if (!fs.existsSync(pkgPath)) return heuristicDb(endpoints);
|
|
519
|
+
const { dependencies: deps = {}, devDependencies: dev = {} } = fs.readJsonSync(pkgPath);
|
|
520
|
+
const all = { ...deps, ...dev };
|
|
521
|
+
if (all.mongoose || all.mongodb) return 'mongodb-mongoose';
|
|
522
|
+
if (all.prisma || all['@prisma/client']) return 'sql-prisma';
|
|
523
|
+
if (all.sequelize) return 'sql-sequelize';
|
|
524
|
+
if (all.typeorm) return 'sql-typeorm';
|
|
525
|
+
if (all.drizzle) return 'sql-drizzle';
|
|
526
|
+
} catch {}
|
|
527
|
+
return heuristicDb(endpoints);
|
|
410
528
|
}
|
|
411
529
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
530
|
+
function heuristicDb(endpoints) {
|
|
531
|
+
const complexCount = endpoints.filter(
|
|
532
|
+
ep => ep.schemaFields && Object.keys(ep.schemaFields).length > 6
|
|
533
|
+
).length;
|
|
534
|
+
return complexCount > 3 ? 'mongodb-mongoose' : 'sql-prisma';
|
|
416
535
|
}
|
|
417
536
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
537
|
+
// ── Model inference ──────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
export function inferModels(endpoints) {
|
|
540
|
+
const models = new Map();
|
|
541
|
+
|
|
542
|
+
for (const ep of endpoints) {
|
|
543
|
+
const name = ep.controllerName ?? 'Default';
|
|
544
|
+
if (!models.has(name)) {
|
|
545
|
+
models.set(name, { name, fields: {}, sources: new Set(), endpoints: [] });
|
|
546
|
+
}
|
|
547
|
+
const m = models.get(name);
|
|
548
|
+
m.endpoints.push({ method: ep.method, route: ep.route });
|
|
549
|
+
if (ep.sourceFile) m.sources.add(ep.sourceFile);
|
|
550
|
+
|
|
551
|
+
const fields = ep.schemaFields ?? ep.requestBody?.fields ?? null;
|
|
552
|
+
if (fields) {
|
|
553
|
+
for (const [k, t] of Object.entries(fields)) {
|
|
554
|
+
if (!m.fields[k]) m.fields[k] = t ?? 'String';
|
|
425
555
|
}
|
|
426
|
-
rows.push(obj);
|
|
427
556
|
}
|
|
428
|
-
|
|
429
|
-
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return [...models.values()].map(m => ({
|
|
560
|
+
name: m.name,
|
|
561
|
+
fields: m.fields,
|
|
562
|
+
sources: [...m.sources],
|
|
563
|
+
endpoints: m.endpoints,
|
|
564
|
+
}));
|
|
430
565
|
}
|
|
431
566
|
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
567
|
+
// ── Seed generator ────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
function seedValue(type) {
|
|
570
|
+
const map = {
|
|
571
|
+
Number: 1,
|
|
572
|
+
Boolean: true,
|
|
573
|
+
Date: new Date().toISOString(),
|
|
574
|
+
Email: 'test@example.com',
|
|
575
|
+
UUID: '00000000-0000-0000-0000-000000000000',
|
|
576
|
+
Array: [],
|
|
577
|
+
Object: {},
|
|
578
|
+
Null: null,
|
|
579
|
+
};
|
|
580
|
+
return map[type] ?? 'test';
|
|
441
581
|
}
|
|
442
582
|
|
|
583
|
+
export function generateSeeds(models, perModel = 3) {
|
|
584
|
+
return models.map(m => ({
|
|
585
|
+
model: m.name,
|
|
586
|
+
rows: Array.from({ length: perModel }, () =>
|
|
587
|
+
Object.fromEntries(Object.entries(m.fields).map(([k, t]) => [k, seedValue(t)]))
|
|
588
|
+
),
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Main: Parallel multi-directory analyzer ───────────────────────────────
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Analyze one or more source directories for API calls.
|
|
596
|
+
* @param {string[]} scanDirs
|
|
597
|
+
* @param {object} [options]
|
|
598
|
+
* @param {Map} [options.envMap]
|
|
599
|
+
* @returns {Promise<object[]>} deduplicated endpoints array
|
|
600
|
+
*/
|
|
443
601
|
export async function analyzeFrontendMulti(scanDirs, options = {}) {
|
|
444
602
|
const { envMap = new Map() } = options;
|
|
445
603
|
|
|
446
604
|
const allFiles = new Set();
|
|
447
605
|
for (const dir of scanDirs) {
|
|
448
606
|
if (!fs.existsSync(dir)) continue;
|
|
449
|
-
const files = await glob(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
607
|
+
const files = await glob(
|
|
608
|
+
`${normalizeSlashes(dir)}/**/*.{js,ts,jsx,tsx}`,
|
|
609
|
+
{ ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**'] }
|
|
610
|
+
);
|
|
611
|
+
files.forEach(f => allFiles.add(path.resolve(f)));
|
|
453
612
|
}
|
|
454
613
|
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
let code;
|
|
459
|
-
try {
|
|
460
|
-
code = await fs.readFile(file, "utf-8");
|
|
461
|
-
} catch {
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
614
|
+
const fileList = [...allFiles];
|
|
615
|
+
const allWarnings = [];
|
|
616
|
+
const endpointMap = new Map();
|
|
464
617
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
618
|
+
const processFile = async (filepath) => {
|
|
619
|
+
const { endpoints, warnings } = await extractEndpointsFromFile(filepath, envMap);
|
|
620
|
+
allWarnings.push(...warnings);
|
|
621
|
+
for (const ep of endpoints) {
|
|
622
|
+
const key = `${ep.method}:${ep.route}`;
|
|
623
|
+
if (!endpointMap.has(key)) endpointMap.set(key, ep);
|
|
470
624
|
}
|
|
625
|
+
};
|
|
471
626
|
|
|
472
|
-
|
|
473
|
-
CallExpression(callPath) {
|
|
474
|
-
const node = callPath.node;
|
|
475
|
-
|
|
476
|
-
const isFetch = node.callee.type === "Identifier" && node.callee.name === "fetch";
|
|
477
|
-
const axiosMethod = detectAxiosLikeMethod(node);
|
|
478
|
-
|
|
479
|
-
if (!isFetch && !axiosMethod) return;
|
|
480
|
-
|
|
481
|
-
let urlValue = null;
|
|
482
|
-
let method = "GET";
|
|
483
|
-
let schemaFields = null;
|
|
484
|
-
|
|
485
|
-
if (isFetch) {
|
|
486
|
-
urlValue = getUrlValue(node.arguments[0], envMap);
|
|
487
|
-
const optionsNode = node.arguments[1];
|
|
488
|
-
|
|
489
|
-
if (optionsNode && optionsNode.type === "ObjectExpression") {
|
|
490
|
-
const methodProp = optionsNode.properties.find(
|
|
491
|
-
(p) =>
|
|
492
|
-
p.type === "ObjectProperty" &&
|
|
493
|
-
p.key.type === "Identifier" &&
|
|
494
|
-
p.key.name === "method"
|
|
495
|
-
);
|
|
496
|
-
if (methodProp && methodProp.value.type === "StringLiteral") {
|
|
497
|
-
method = methodProp.value.value.toUpperCase();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (["POST", "PUT", "PATCH"].includes(method)) {
|
|
501
|
-
const bodyProp = optionsNode.properties.find(
|
|
502
|
-
(p) =>
|
|
503
|
-
p.type === "ObjectProperty" &&
|
|
504
|
-
p.key.type === "Identifier" &&
|
|
505
|
-
p.key.name === "body"
|
|
506
|
-
);
|
|
507
|
-
|
|
508
|
-
if (bodyProp) {
|
|
509
|
-
const v = bodyProp.value;
|
|
510
|
-
|
|
511
|
-
if (isJSONStringifyCall(v)) {
|
|
512
|
-
const arg0 = v.arguments[0];
|
|
513
|
-
|
|
514
|
-
if (arg0?.type === "ObjectExpression") {
|
|
515
|
-
schemaFields = extractObjectSchema(arg0);
|
|
516
|
-
} else if (arg0?.type === "Identifier") {
|
|
517
|
-
const init = resolveIdentifierToInit(callPath, arg0.name);
|
|
518
|
-
if (init?.type === "ObjectExpression") schemaFields = extractObjectSchema(init);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (axiosMethod) {
|
|
527
|
-
method = axiosMethod;
|
|
528
|
-
urlValue = getUrlValue(node.arguments[0], envMap);
|
|
529
|
-
|
|
530
|
-
if (["POST", "PUT", "PATCH"].includes(method)) {
|
|
531
|
-
const dataArg = node.arguments[1];
|
|
532
|
-
if (dataArg?.type === "ObjectExpression") {
|
|
533
|
-
schemaFields = extractObjectSchema(dataArg);
|
|
534
|
-
} else if (dataArg?.type === "Identifier") {
|
|
535
|
-
const init = resolveIdentifierToInit(callPath, dataArg.name);
|
|
536
|
-
if (init?.type === "ObjectExpression") schemaFields = extractObjectSchema(init);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
627
|
+
await withConcurrency(fileList, processFile, PARALLEL_LIMIT);
|
|
540
628
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const normalizedRoute = route.replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
546
|
-
const controllerName = deriveControllerNameFromUrl(apiPath);
|
|
547
|
-
const actionName = deriveActionName(method, normalizedRoute);
|
|
548
|
-
|
|
549
|
-
const key = `${method}:${normalizedRoute}`;
|
|
550
|
-
if (!endpoints.has(key)) {
|
|
551
|
-
endpoints.set(key, {
|
|
552
|
-
path: apiPath,
|
|
553
|
-
route: normalizedRoute,
|
|
554
|
-
method,
|
|
555
|
-
controllerName,
|
|
556
|
-
actionName,
|
|
557
|
-
pathParams: extractPathParams(normalizedRoute),
|
|
558
|
-
queryParams: extractQueryParamsFromUrl(apiPath),
|
|
559
|
-
schemaFields,
|
|
560
|
-
requestBody: schemaFields ? { fields: schemaFields } : null,
|
|
561
|
-
sourceFile: normalizeSlashes(file),
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
},
|
|
565
|
-
});
|
|
629
|
+
const endpoints = [...endpointMap.values()];
|
|
630
|
+
if (allWarnings.length) {
|
|
631
|
+
// Attach warnings to the result for caller inspection
|
|
632
|
+
endpoints.__warnings = allWarnings;
|
|
566
633
|
}
|
|
567
634
|
|
|
568
|
-
return
|
|
635
|
+
return endpoints;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Single-directory variant (legacy-compatible).
|
|
640
|
+
*/
|
|
641
|
+
export async function analyzeFrontend(srcPath, options = {}) {
|
|
642
|
+
if (!srcPath) throw new Error('analyzeFrontend: srcPath is required');
|
|
643
|
+
if (!fs.existsSync(srcPath)) throw new Error(`Source directory not found: ${srcPath}`);
|
|
644
|
+
return analyzeFrontendMulti([srcPath], options);
|
|
569
645
|
}
|
|
570
646
|
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
647
|
+
// ── Full project analysis ─────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Full project analysis: endpoints + DB insights + relationships.
|
|
651
|
+
* @param {string} [projectRoot]
|
|
652
|
+
* @returns {Promise<ProjectAnalysis>}
|
|
653
|
+
*/
|
|
574
654
|
export async function analyze(projectRoot = process.cwd()) {
|
|
575
655
|
const rootDir = path.resolve(projectRoot);
|
|
576
656
|
|
|
577
|
-
const
|
|
578
|
-
.map((d) => path.join(rootDir, d))
|
|
579
|
-
.find((d) => fs.existsSync(d));
|
|
657
|
+
const envMap = await loadEnvFiles(rootDir);
|
|
580
658
|
|
|
581
|
-
const
|
|
659
|
+
const frontendSrc = ['src', 'app', 'pages']
|
|
660
|
+
.map(d => path.join(rootDir, d))
|
|
661
|
+
.find(d => fs.existsSync(d));
|
|
582
662
|
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
663
|
+
const endpoints = frontendSrc
|
|
664
|
+
? await analyzeFrontend(frontendSrc, { envMap })
|
|
665
|
+
: [];
|
|
666
|
+
|
|
667
|
+
const models = inferModels(endpoints);
|
|
668
|
+
const seeds = generateSeeds(models, 3);
|
|
669
|
+
const guessedDb = guessDbType(rootDir, endpoints);
|
|
586
670
|
const relationships = detectRelationships(endpoints);
|
|
671
|
+
const warnings = endpoints.__warnings ?? [];
|
|
587
672
|
|
|
588
673
|
return {
|
|
589
674
|
rootDir: normalizeSlashes(rootDir),
|
|
590
675
|
endpoints,
|
|
591
676
|
relationships,
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
677
|
+
warnings,
|
|
678
|
+
dbInsights: { guessedDb, models, seeds },
|
|
679
|
+
meta: {
|
|
680
|
+
fileCount: endpoints.length,
|
|
681
|
+
endpointCount: endpoints.length,
|
|
682
|
+
modelCount: models.length,
|
|
683
|
+
analyzedAt: new Date().toISOString(),
|
|
596
684
|
},
|
|
597
685
|
};
|
|
598
686
|
}
|
|
599
687
|
|
|
600
|
-
//
|
|
601
|
-
// Next.js API Route Detection
|
|
602
|
-
// -------------------------
|
|
603
|
-
const NEXTJS_HTTP_EXPORTS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
688
|
+
// ── Next.js API route detector ────────────────────────────────────────────
|
|
604
689
|
|
|
605
690
|
export async function detectNextjsApiRoutes(apiRouteDirs) {
|
|
606
691
|
const routes = [];
|
|
@@ -608,57 +693,55 @@ export async function detectNextjsApiRoutes(apiRouteDirs) {
|
|
|
608
693
|
for (const dir of apiRouteDirs) {
|
|
609
694
|
if (!fs.existsSync(dir)) continue;
|
|
610
695
|
|
|
611
|
-
// App Router
|
|
612
|
-
const
|
|
696
|
+
// App Router — app/**/route.{ts,js,tsx,jsx}
|
|
697
|
+
const appFiles = await glob(
|
|
613
698
|
`${normalizeSlashes(dir)}/**/route.{ts,js,tsx,jsx}`,
|
|
614
|
-
{ ignore: [
|
|
699
|
+
{ ignore: ['**/node_modules/**'] }
|
|
615
700
|
);
|
|
616
701
|
|
|
617
|
-
for (const file of
|
|
618
|
-
const
|
|
619
|
-
let routePath =
|
|
620
|
-
.replace(/\\/g,
|
|
621
|
-
.replace(/\(([^)]+)\)\//g,
|
|
622
|
-
.replace(/\[\.\.\.([^\]]+)\]/g,
|
|
623
|
-
.replace(/\[([^\]]+)\]/g,
|
|
624
|
-
|
|
625
|
-
if (routePath === "/.") routePath = "/";
|
|
702
|
+
for (const file of appFiles) {
|
|
703
|
+
const rel = path.relative(dir, path.dirname(file));
|
|
704
|
+
let routePath = '/' + rel
|
|
705
|
+
.replace(/\\/g, '/')
|
|
706
|
+
.replace(/\(([^)]+)\)\//g, '')
|
|
707
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, ':$1')
|
|
708
|
+
.replace(/\[([^\]]+)\]/g, ':$1');
|
|
709
|
+
if (routePath === '/.') routePath = '/';
|
|
626
710
|
|
|
627
711
|
const methods = await extractExportedHttpMethods(file);
|
|
628
712
|
for (const method of methods) {
|
|
629
713
|
routes.push({
|
|
630
|
-
route:
|
|
714
|
+
route: routePath,
|
|
631
715
|
method,
|
|
632
|
-
controllerName:
|
|
633
|
-
sourceFile:
|
|
634
|
-
isServerRoute:
|
|
716
|
+
controllerName: deriveControllerName(routePath),
|
|
717
|
+
sourceFile: normalizeSlashes(file),
|
|
718
|
+
isServerRoute: true,
|
|
635
719
|
});
|
|
636
720
|
}
|
|
637
721
|
}
|
|
638
722
|
|
|
639
|
-
// Pages Router
|
|
640
|
-
const
|
|
641
|
-
if (fs.existsSync(
|
|
642
|
-
const
|
|
643
|
-
`${normalizeSlashes(
|
|
644
|
-
{ ignore: [
|
|
723
|
+
// Pages Router — pages/api/**
|
|
724
|
+
const pagesApi = path.join(dir, 'api');
|
|
725
|
+
if (fs.existsSync(pagesApi)) {
|
|
726
|
+
const pageFiles = await glob(
|
|
727
|
+
`${normalizeSlashes(pagesApi)}/**/*.{ts,js,tsx,jsx}`,
|
|
728
|
+
{ ignore: ['**/node_modules/**'] }
|
|
645
729
|
);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
.replace(
|
|
651
|
-
.replace(
|
|
652
|
-
.replace(
|
|
653
|
-
.replace(/\[
|
|
654
|
-
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
730
|
+
for (const file of pageFiles) {
|
|
731
|
+
const rel = path.relative(pagesApi, file);
|
|
732
|
+
const routePath = '/api/' + rel
|
|
733
|
+
.replace(/\\/g, '/')
|
|
734
|
+
.replace(/\.(ts|js|tsx|jsx)$/, '')
|
|
735
|
+
.replace(/\/index$/, '')
|
|
736
|
+
.replace(/\[\.\.\.([^\]]+)\]/g, ':$1')
|
|
737
|
+
.replace(/\[([^\]]+)\]/g, ':$1');
|
|
655
738
|
|
|
656
739
|
routes.push({
|
|
657
|
-
route:
|
|
658
|
-
method:
|
|
659
|
-
controllerName:
|
|
660
|
-
sourceFile:
|
|
661
|
-
isServerRoute:
|
|
740
|
+
route: routePath,
|
|
741
|
+
method: 'ALL',
|
|
742
|
+
controllerName: deriveControllerName(routePath),
|
|
743
|
+
sourceFile: normalizeSlashes(file),
|
|
744
|
+
isServerRoute: true,
|
|
662
745
|
});
|
|
663
746
|
}
|
|
664
747
|
}
|
|
@@ -670,82 +753,138 @@ export async function detectNextjsApiRoutes(apiRouteDirs) {
|
|
|
670
753
|
async function extractExportedHttpMethods(file) {
|
|
671
754
|
const methods = [];
|
|
672
755
|
try {
|
|
673
|
-
const code = await fs.readFile(file,
|
|
674
|
-
const ast
|
|
675
|
-
sourceType: "module",
|
|
676
|
-
plugins: ["jsx", "typescript"],
|
|
677
|
-
});
|
|
678
|
-
|
|
756
|
+
const code = await fs.readFile(file, 'utf-8');
|
|
757
|
+
const ast = parser.parse(code, PARSE_OPTIONS);
|
|
679
758
|
traverse(ast, {
|
|
680
759
|
ExportNamedDeclaration(nodePath) {
|
|
681
760
|
const decl = nodePath.node.declaration;
|
|
682
761
|
if (!decl) return;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const name = decl.id.name;
|
|
686
|
-
if (NEXTJS_HTTP_EXPORTS.has(name)) methods.push(name);
|
|
762
|
+
if (decl.type === 'FunctionDeclaration' && NEXTJS_HTTP_EXPORTS.has(decl.id?.name)) {
|
|
763
|
+
methods.push(decl.id.name);
|
|
687
764
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const name = declarator.id.name;
|
|
693
|
-
if (NEXTJS_HTTP_EXPORTS.has(name)) methods.push(name);
|
|
765
|
+
if (decl.type === 'VariableDeclaration') {
|
|
766
|
+
for (const d of decl.declarations) {
|
|
767
|
+
if (d.id?.type === 'Identifier' && NEXTJS_HTTP_EXPORTS.has(d.id.name)) {
|
|
768
|
+
methods.push(d.id.name);
|
|
694
769
|
}
|
|
695
770
|
}
|
|
696
771
|
}
|
|
697
772
|
},
|
|
698
773
|
});
|
|
699
774
|
} catch {}
|
|
775
|
+
return methods.length ? methods : ['GET'];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── DOM Sync v2 — HTML input type extractor ──────────────────────────────
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Scan JSX/TSX files for <input type="..."> and extract forced field types.
|
|
782
|
+
* @param {string} srcDir
|
|
783
|
+
* @returns {Promise<DOMTypeHint[]>}
|
|
784
|
+
*/
|
|
785
|
+
export async function extractDOMTypeHints(srcDir) {
|
|
786
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
787
|
+
|
|
788
|
+
const files = await glob(
|
|
789
|
+
`${normalizeSlashes(srcDir)}/**/*.{jsx,tsx}`,
|
|
790
|
+
{ ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'] }
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const HTML_TYPE_MAP = {
|
|
794
|
+
date: 'Date',
|
|
795
|
+
datetime: 'Date',
|
|
796
|
+
'datetime-local': 'Date',
|
|
797
|
+
number: 'Number',
|
|
798
|
+
email: 'Email',
|
|
799
|
+
checkbox: 'Boolean',
|
|
800
|
+
range: 'Number',
|
|
801
|
+
tel: 'String',
|
|
802
|
+
url: 'String',
|
|
803
|
+
password: 'String',
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const hints = [];
|
|
807
|
+
|
|
808
|
+
await withConcurrency(files, async (file) => {
|
|
809
|
+
try {
|
|
810
|
+
const code = await fs.readFile(file, 'utf-8');
|
|
811
|
+
for (const [htmlType, fieldType] of Object.entries(HTML_TYPE_MAP)) {
|
|
812
|
+
// Quick string scan before heavier AST check
|
|
813
|
+
if (code.includes(`type="${htmlType}"`)) {
|
|
814
|
+
hints.push({ file: normalizeSlashes(file), fieldType, rawHTMLType: htmlType });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} catch {}
|
|
818
|
+
}, PARALLEL_LIMIT);
|
|
700
819
|
|
|
701
|
-
return
|
|
820
|
+
return hints;
|
|
702
821
|
}
|
|
703
822
|
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
823
|
+
// ── Path-scanner (naming drift detection) ─────────────────────────────────
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Detect files that call API routes unrelated to their own name.
|
|
827
|
+
* @param {object[]} endpoints
|
|
828
|
+
* @returns {object[]} inconsistencies
|
|
829
|
+
*/
|
|
830
|
+
export async function performPathScan(endpoints) {
|
|
709
831
|
const inconsistencies = [];
|
|
710
|
-
|
|
711
|
-
if (!ep.sourceFile || !ep.route)
|
|
712
|
-
const fileBase
|
|
713
|
-
const routeBase = ep.controllerName
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
832
|
+
for (const ep of endpoints) {
|
|
833
|
+
if (!ep.sourceFile || !ep.route) continue;
|
|
834
|
+
const fileBase = path.basename(ep.sourceFile).split('.')[0].toLowerCase();
|
|
835
|
+
const routeBase = (ep.controllerName ?? '').toLowerCase();
|
|
836
|
+
if (
|
|
837
|
+
fileBase !== 'index' &&
|
|
838
|
+
fileBase !== 'api' &&
|
|
839
|
+
routeBase &&
|
|
840
|
+
!fileBase.includes(routeBase) &&
|
|
841
|
+
!routeBase.includes(fileBase)
|
|
842
|
+
) {
|
|
717
843
|
inconsistencies.push({
|
|
718
|
-
file:
|
|
844
|
+
file: ep.sourceFile,
|
|
719
845
|
routeCalled: ep.route,
|
|
720
|
-
warning:
|
|
846
|
+
warning: `File '${fileBase}' calls unrelated route '${routeBase}' — possible naming drift`,
|
|
721
847
|
});
|
|
722
848
|
}
|
|
723
|
-
}
|
|
849
|
+
}
|
|
724
850
|
return inconsistencies;
|
|
851
|
+
|
|
852
|
+
}
|
|
853
|
+
// ── performLowCostPathScan (alias for performPathScan) ────────────────────
|
|
854
|
+
export async function performLowCostPathScan(srcDir, endpoints) {
|
|
855
|
+
return performPathScan(endpoints);
|
|
725
856
|
}
|
|
726
857
|
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
export async function extractComponentTreeTypes(frontendSrcDir) {
|
|
731
|
-
// A heuristic simulation of live DOM cross-checking:
|
|
732
|
-
// Parses JSX tags (<input type="date">) to build forced validations.
|
|
733
|
-
if (!fs.existsSync(frontendSrcDir)) return [];
|
|
734
|
-
|
|
735
|
-
const files = await glob(`${normalizeSlashes(frontendSrcDir)}/**/*.{jsx,tsx}`, {
|
|
736
|
-
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"]
|
|
737
|
-
});
|
|
858
|
+
// ── extractComponentTreeTypes ─────────────────────────────────────────────
|
|
859
|
+
export async function extractComponentTreeTypes(srcDir) {
|
|
860
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
738
861
|
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
862
|
+
const files = await glob(
|
|
863
|
+
`${normalizeSlashes(srcDir)}/**/*.{jsx,tsx,ts,js}`,
|
|
864
|
+
{ ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'] }
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
const typeMap = {};
|
|
868
|
+
|
|
869
|
+
await withConcurrency(files, async (file) => {
|
|
742
870
|
try {
|
|
743
|
-
const code = await fs.readFile(file,
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
871
|
+
const code = await fs.readFile(file, 'utf-8');
|
|
872
|
+
const ast = parser.parse(code, PARSE_OPTIONS);
|
|
873
|
+
traverse(ast, {
|
|
874
|
+
TSPropertySignature(nodePath) {
|
|
875
|
+
const key = nodePath.node.key?.name;
|
|
876
|
+
const ann = nodePath.node.typeAnnotation?.typeAnnotation;
|
|
877
|
+
if (!key || !ann) return;
|
|
878
|
+
const tsMap = {
|
|
879
|
+
TSStringKeyword: 'String',
|
|
880
|
+
TSNumberKeyword: 'Number',
|
|
881
|
+
TSBooleanKeyword: 'Boolean',
|
|
882
|
+
};
|
|
883
|
+
if (tsMap[ann.type]) typeMap[key] = tsMap[ann.type];
|
|
884
|
+
},
|
|
885
|
+
});
|
|
747
886
|
} catch {}
|
|
748
|
-
}
|
|
887
|
+
}, PARALLEL_LIMIT);
|
|
749
888
|
|
|
750
|
-
return
|
|
889
|
+
return Object.entries(typeMap).map(([field, type]) => ({ field, type }));
|
|
751
890
|
}
|