create-backlist 7.4.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analyzer.js CHANGED
@@ -1,73 +1,151 @@
1
- import fs from "fs-extra";
2
- import path from "node:path";
3
- import { glob } from "glob";
4
-
5
- import parser from "@babel/parser";
6
- import _traverse from "@babel/traverse";
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
- const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete"]);
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
- ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico",
13
- ".css", ".scss", ".less", ".woff", ".woff2", ".ttf", ".eot",
14
- ".mp3", ".mp4", ".webm", ".pdf",
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
- // Utils
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 || "").replace(/\\/g, "/");
96
+ return String(p ?? '').replace(/\\/g, '/');
22
97
  }
23
98
 
24
99
  function toTitleCase(str) {
25
- if (!str) return "Default";
100
+ if (!str) return 'Default';
26
101
  return String(str)
27
102
  .replace(/[-_]+(\w)/g, (_, c) => c.toUpperCase())
28
- .replace(/^\w/, (c) => c.toUpperCase())
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 || "").replace(/\{(\w+)\}/g, ":$1");
108
+ return String(urlValue ?? '').replace(/\{(\w+)\}/g, ':$1');
35
109
  }
36
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;
117
+ }
118
+
119
+ // ── URL utilities ─────────────────────────────────────────────────────────
120
+
37
121
  function extractApiPath(urlValue, envMap = new Map()) {
38
122
  if (!urlValue) return null;
39
123
 
40
- // 1. Original behavior: if URL contains /api/, extract from there
41
- const idx = urlValue.indexOf("/api/");
124
+ // Direct /api/ match
125
+ const idx = urlValue.indexOf('/api/');
42
126
  if (idx !== -1) return urlValue.slice(idx);
43
127
 
44
- // 2. Resolve env variable placeholders left by getUrlValue
45
- let resolved = urlValue;
46
- const envPattern = /\{(NEXT_PUBLIC_|REACT_APP_|VITE_)[^}]+\}/g;
47
- resolved = resolved.replace(envPattern, (match) => {
48
- const varName = match.slice(1, -1);
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
- // Re-check after resolution
53
- const idx2 = resolved.indexOf("/api/");
134
+ const idx2 = resolved.indexOf('/api/');
54
135
  if (idx2 !== -1) return resolved.slice(idx2);
55
136
 
56
- // 3. If resolved URL starts with http(s), extract the path portion
137
+ // Parse absolute URL
57
138
  if (/^https?:\/\//.test(resolved)) {
58
139
  try {
59
- const url = new URL(resolved);
60
- const pathname = url.pathname;
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
- // 4. If it's a relative path starting with /, accept it
66
- if (resolved.startsWith("/") && resolved.length > 1) {
67
- // Filter out static assets
68
- const ext = path.extname(resolved.split("?")[0]).toLowerCase();
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 Array.from(new Set(params));
159
+ return [...new Set(params)];
82
160
  }
83
161
 
84
- function extractQueryParamsFromUrl(urlValue) {
162
+ function extractQueryParams(urlValue) {
85
163
  try {
86
- const qIndex = urlValue.indexOf("?");
87
- if (qIndex === -1) return [];
88
- const qs = urlValue.slice(qIndex + 1);
89
- return qs
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 deriveControllerNameFromUrl(urlValue) {
99
- const apiPath = extractApiPath(urlValue) || urlValue;
100
- const parts = String(apiPath).split("/").filter(Boolean); // ["api","v1","products"]
101
- const apiIndex = parts.indexOf("api");
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 (apiIndex >= 0) {
105
- seg = parts[apiIndex + 1] || null;
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] || null;
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\//, "/").replace(/[/:{}-]/g, " ");
120
- const last = cleaned.trim().split(/\s+/).filter(Boolean).pop() || "Action";
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
- // URL extraction
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.NEXT_PUBLIC_API_URL
224
+ // process.env.X
129
225
  if (
130
- node.type === "MemberExpression" &&
131
- node.object?.type === "MemberExpression" &&
132
- node.object.object?.type === "Identifier" &&
133
- node.object.object.name === "process" &&
134
- node.object.property?.name === "env" &&
135
- node.property?.type === "Identifier"
136
- ) {
137
- return node.property.name;
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 === "MemberExpression" &&
142
- node.object?.type === "MemberExpression" &&
143
- node.object.object?.type === "MetaProperty" &&
144
- node.object.property?.name === "env" &&
145
- node.property?.type === "Identifier"
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 === "StringLiteral") return urlNode.value;
247
+ if (urlNode.type === 'StringLiteral') return urlNode.value;
156
248
 
157
- if (urlNode.type === "TemplateLiteral") {
158
- const quasis = urlNode.quasis || [];
159
- const exprs = urlNode.expressions || [];
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
- if (exprs[i]) {
164
- if (exprs[i].type === "Identifier") {
165
- out += `{${exprs[i].name}}`;
166
- } else if (exprs[i].type === "MemberExpression") {
167
- const envName = extractEnvVarName(exprs[i]);
168
- if (envName && envMap.has(envName)) {
169
- out += envMap.get(envName);
170
- } else if (envName) {
171
- out += `{${envName}}`;
172
- } else {
173
- out += `{param${i + 1}}`;
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
- // Handle string concatenation: baseUrl + "/users"
184
- if (urlNode.type === "BinaryExpression" && urlNode.operator === "+") {
185
- const left = getUrlValue(urlNode.left, envMap);
186
- const right = getUrlValue(urlNode.right, envMap);
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
- // Handle process.env.X / import.meta.env.X directly as URL
191
- if (urlNode.type === "MemberExpression") {
276
+ if (urlNode.type === 'MemberExpression') {
192
277
  const envName = extractEnvVarName(urlNode);
193
- if (envName && envMap.has(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
- // axios-like detection
206
- // -------------------------
207
- function detectAxiosLikeMethod(node) {
208
- // axios.get(...) / api.get(...) / httpClient.post(...) etc
209
- if (!node.callee || node.callee.type !== "MemberExpression") return null;
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
- const schemaFields = {};
241
- if (!objExpr || objExpr.type !== "ObjectExpression") return null;
242
-
294
+ if (!objExpr || objExpr.type !== 'ObjectExpression') return null;
295
+ const fields = {};
243
296
  for (const prop of objExpr.properties) {
244
- if (prop.type !== "ObjectProperty") continue;
245
-
297
+ if (prop.type !== 'ObjectProperty') continue;
246
298
  const key =
247
- prop.key.type === "Identifier"
248
- ? prop.key.name
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
- schemaFields[key] = inferTypeFromNode(prop.value);
302
+ fields[key] = inferFieldType(key, prop.value);
255
303
  }
256
- return schemaFields;
304
+ return Object.keys(fields).length ? fields : null;
257
305
  }
258
306
 
259
- function resolveIdentifierToInit(callPath, identifierName) {
307
+ function resolveIdentifier(callPath, name) {
260
308
  try {
261
- const binding = callPath.scope.getBinding(identifierName);
262
- if (!binding) return null;
263
- const declPath = binding.path;
264
- if (!declPath || !declPath.node) return null;
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 isJSONStringifyCall(node) {
274
- // JSON.stringify(x)
275
- return (
276
- node &&
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
- if (deps.mongoose || deps.mongodb) return "mongodb-mongoose";
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
- return heuristicallyGuessDB(endpoints);
306
- } catch {
307
- return heuristicallyGuessDB(endpoints);
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
- function heuristicallyGuessDB(endpoints) {
312
- // Free Tier / Default Intelligence:
313
- // Analyze data complexity. If highly nested schemas are prominent, default NoSQL.
314
- // If many flat, relational-looking fields exist, default SQL.
315
- let maxNesting = 0;
316
- for (const ep of endpoints) {
317
- if (ep.schemaFields && Object.keys(ep.schemaFields).length > 6) {
318
- maxNesting++;
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
- return maxNesting > 3 ? "mongodb-mongoose" : "sql-prisma";
355
+
356
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
357
+ return results;
322
358
  }
323
359
 
324
- function inferModelsFromEndpoints(endpoints) {
325
- const models = new Map();
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
- for (const ep of endpoints) {
328
- const modelName = ep.controllerName || "Default";
329
-
330
- if (!models.has(modelName)) {
331
- models.set(modelName, {
332
- name: modelName,
333
- fields: {}, // merged fields from bodies
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
- const m = models.get(modelName);
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
- const fields = ep.schemaFields || (ep.requestBody && ep.requestBody.fields) || null;
344
- if (fields) {
345
- for (const [k, t] of Object.entries(fields)) {
346
- if (!m.fields[k]) m.fields[k] = t || "String";
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
- return Array.from(models.values()).map((m) => ({
352
- name: m.name,
353
- fields: m.fields,
354
- sources: Array.from(m.sources),
355
- endpoints: m.endpoints,
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
- // Relationship detection from nested routes
361
- // -------------------------
362
- function singularize(word) {
363
- if (!word) return word;
364
- if (word.endsWith("ies")) return word.slice(0, -3) + "y";
365
- if (word.endsWith("ses") || word.endsWith("xes") || word.endsWith("zes")) return word.slice(0, -2);
366
- if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
367
- return word;
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 relationships = [];
482
+ const rels = [];
373
483
 
374
484
  for (const ep of endpoints) {
375
- const route = ep.route || ep.path || "";
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 parentSegment = segments[i];
380
- const paramSegment = segments[i + 1];
381
- const childSegment = segments[i + 2];
488
+ const parent = segments[i];
489
+ const param = segments[i + 1];
490
+ const child = segments[i + 2];
382
491
 
383
- if (
384
- parentSegment === "api" ||
385
- /^v\d+$/i.test(parentSegment) ||
386
- !paramSegment.startsWith(":")
387
- ) continue;
388
-
389
- const parentName = toTitleCase(singularize(parentSegment));
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
- const foreignKey = parentName.charAt(0).toLowerCase() + parentName.slice(1) + "Id";
399
-
400
- relationships.push({
401
- parent: parentName,
402
- child: childName,
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
+ }
512
+
513
+ // ── DB heuristics ────────────────────────────────────────────────────────
408
514
 
409
- return relationships;
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 seedValueForType(t) {
413
- if (t === "Number") return 1;
414
- if (t === "Boolean") return true;
415
- return "test"; // String default
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
- function generateSeedsFromModels(models, perModel = 3) {
419
- return models.map((m) => {
420
- const rows = [];
421
- for (let i = 0; i < perModel; i++) {
422
- const obj = {};
423
- for (const [k, t] of Object.entries(m.fields || {})) {
424
- obj[k] = seedValueForType(t);
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
- return { model: m.name, rows };
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
- // MAIN frontend analyzer
434
- // -------------------------
435
- export async function analyzeFrontend(srcPath, options = {}) {
436
- if (!srcPath) throw new Error("analyzeFrontend: srcPath is required");
437
- if (!fs.existsSync(srcPath)) {
438
- throw new Error(`The source directory '${srcPath}' does not exist.`);
439
- }
440
- return analyzeFrontendMulti([srcPath], options);
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(`${normalizeSlashes(dir)}/**/*.{js,ts,jsx,tsx}`, {
450
- ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**", "**/coverage/**"],
451
- });
452
- files.forEach((f) => allFiles.add(path.resolve(f)));
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 endpoints = new Map();
614
+ const fileList = [...allFiles];
615
+ const allWarnings = [];
616
+ const endpointMap = new Map();
456
617
 
457
- for (const file of allFiles) {
458
- let code;
459
- try {
460
- code = await fs.readFile(file, "utf-8");
461
- } catch {
462
- continue;
463
- }
464
-
465
- let ast;
466
- try {
467
- ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
468
- } catch {
469
- continue;
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
- traverse(ast, {
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
- const apiPath = extractApiPath(urlValue, envMap);
542
- if (!apiPath) return;
543
-
544
- const route = normalizeRouteForBackend(apiPath.split("?")[0]);
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 Array.from(endpoints.values());
635
+ return endpoints;
569
636
  }
570
637
 
571
- // -------------------------
572
- // Optional: full project analyze (endpoints + db insights)
573
- // -------------------------
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);
645
+ }
646
+
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 frontendSrc = ["src", "app", "pages"]
578
- .map((d) => path.join(rootDir, d))
579
- .find((d) => fs.existsSync(d));
657
+ const envMap = await loadEnvFiles(rootDir);
658
+
659
+ const frontendSrc = ['src', 'app', 'pages']
660
+ .map(d => path.join(rootDir, d))
661
+ .find(d => fs.existsSync(d));
580
662
 
581
- const endpoints = frontendSrc ? await analyzeFrontend(frontendSrc) : [];
663
+ const endpoints = frontendSrc
664
+ ? await analyzeFrontend(frontendSrc, { envMap })
665
+ : [];
582
666
 
583
- const models = inferModelsFromEndpoints(endpoints);
584
- const seeds = generateSeedsFromModels(models, 3);
585
- const guessedDb = guessDbTypeFromRepo(rootDir, endpoints);
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
- dbInsights: {
593
- guessedDb,
594
- models,
595
- seeds,
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: app/**/route.{ts,js}
612
- const appRouterFiles = await glob(
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: ["**/node_modules/**"] }
699
+ { ignore: ['**/node_modules/**'] }
615
700
  );
616
701
 
617
- for (const file of appRouterFiles) {
618
- const relativePath = path.relative(dir, path.dirname(file));
619
- let routePath = "/" + relativePath
620
- .replace(/\\/g, "/")
621
- .replace(/\(([^)]+)\)\//g, "")
622
- .replace(/\[\.\.\.([^\]]+)\]/g, ":$1")
623
- .replace(/\[([^\]]+)\]/g, ":$1");
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: routePath,
714
+ route: routePath,
631
715
  method,
632
- controllerName: deriveControllerNameFromUrl(routePath),
633
- sourceFile: normalizeSlashes(file),
634
- isServerRoute: true,
716
+ controllerName: deriveControllerName(routePath),
717
+ sourceFile: normalizeSlashes(file),
718
+ isServerRoute: true,
635
719
  });
636
720
  }
637
721
  }
638
722
 
639
- // Pages Router: pages/api/**/*.{ts,js}
640
- const pagesApiDir = path.join(dir, "api");
641
- if (fs.existsSync(pagesApiDir)) {
642
- const pagesApiFiles = await glob(
643
- `${normalizeSlashes(pagesApiDir)}/**/*.{ts,js,tsx,jsx}`,
644
- { ignore: ["**/node_modules/**"] }
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
- for (const file of pagesApiFiles) {
648
- const relativePath = path.relative(pagesApiDir, file);
649
- let routePath = "/api/" + relativePath
650
- .replace(/\\/g, "/")
651
- .replace(/\.(ts|js|tsx|jsx)$/, "")
652
- .replace(/\/index$/, "")
653
- .replace(/\[\.\.\.([^\]]+)\]/g, ":$1")
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: routePath,
658
- method: "ALL",
659
- controllerName: deriveControllerNameFromUrl(routePath),
660
- sourceFile: normalizeSlashes(file),
661
- isServerRoute: true,
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,99 @@ 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, "utf-8");
674
- const ast = parser.parse(code, {
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
- if (decl.type === "FunctionDeclaration" && decl.id) {
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
- if (decl.type === "VariableDeclaration") {
690
- for (const declarator of decl.declarations) {
691
- if (declarator.id?.type === "Identifier") {
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 ──────────────────────────────
700
779
 
701
- return methods.length > 0 ? methods : ["GET"];
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);
819
+
820
+ return hints;
702
821
  }
703
822
 
704
- // -------------------------
705
- // NEW v7.0: Low-Cost Path Scanner (Standard Tier)
706
- // -------------------------
707
- export async function performLowCostPathScan(frontendSrcDir, endpoints) {
708
- // Ensures routes match frontend expectations
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
- endpoints.forEach(ep => {
711
- if (!ep.sourceFile || !ep.route) return;
712
- const fileBase = path.basename(ep.sourceFile).split('.')[0].toLowerCase();
713
- const routeBase = ep.controllerName ? ep.controllerName.toLowerCase() : '';
714
-
715
- // If the file containing the fetch is named 'AdminPanel' but route points to 'Products', note it.
716
- if (fileBase !== 'index' && fileBase !== 'api' && routeBase && !fileBase.includes(routeBase) && !routeBase.includes(fileBase)) {
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: ep.sourceFile,
844
+ file: ep.sourceFile,
719
845
  routeCalled: ep.route,
720
- warning: `Path Scanner: File '${fileBase}' calls unrelated route '${routeBase}'. Potential naming drift.`
846
+ warning: `File '${fileBase}' calls unrelated route '${routeBase}' possible naming drift`,
721
847
  });
722
848
  }
723
- });
724
- return inconsistencies;
725
- }
726
-
727
- // -------------------------
728
- // NEW v7.0: Component Component Tree Extractor (DOM Sync Level 2)
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
- });
738
-
739
- const extractedTypes = [];
740
-
741
- for (const file of files) {
742
- try {
743
- const code = await fs.readFile(file, "utf-8");
744
- if (code.includes('type="date"')) extractedTypes.push({ file, fieldType: 'Date', rawHTMLType: 'date' });
745
- if (code.includes('type="number"')) extractedTypes.push({ file, fieldType: 'Number', rawHTMLType: 'number' });
746
- if (code.includes('type="email"')) extractedTypes.push({ file, fieldType: 'Email', rawHTMLType: 'email' });
747
- } catch {}
748
849
  }
749
-
750
- return extractedTypes;
850
+ return inconsistencies;
751
851
  }