apiforge-analyzer 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * APIForge Analyzer — main entry point (dual ESM/CJS)
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.detectArchitectureType = exports.detectFramework = exports.uploadRoutes = exports.exportToJSON = exports.analyzeNestJS = exports.analyzeReactRouter = exports.analyzeVite = exports.analyzeNextApp = exports.analyzeNextPages = exports.analyzeExpress = void 0;
6
+ exports.detectArchitectureType = exports.detectFramework = exports.uploadRoutes = exports.exportToJSON = exports.analyzeFastify = exports.analyzeNestJS = exports.analyzeReactRouter = exports.analyzeVite = exports.analyzeNextApp = exports.analyzeNextPages = exports.analyzeExpress = void 0;
7
7
  exports.analyze = analyze;
8
8
  const fs_1 = require("fs");
9
9
  const path_1 = require("path");
@@ -19,6 +19,8 @@ const react_router_js_1 = require("./frameworks/react-router.js");
19
19
  Object.defineProperty(exports, "analyzeReactRouter", { enumerable: true, get: function () { return react_router_js_1.analyzeReactRouter; } });
20
20
  const nestjs_js_1 = require("./frameworks/nestjs.js");
21
21
  Object.defineProperty(exports, "analyzeNestJS", { enumerable: true, get: function () { return nestjs_js_1.analyzeNestJS; } });
22
+ const fastify_js_1 = require("./frameworks/fastify.js");
23
+ Object.defineProperty(exports, "analyzeFastify", { enumerable: true, get: function () { return fastify_js_1.analyzeFastify; } });
22
24
  const detect_js_1 = require("./detect.js");
23
25
  const export_js_1 = require("./export.js");
24
26
  const uploader_js_1 = require("./uploader.js");
@@ -117,6 +119,15 @@ async function analyze(app, options) {
117
119
  framework = (0, detect_js_1.detectFramework)(app).name;
118
120
  console.log(`📦 Detected framework: ${framework}`);
119
121
  }
122
+ // Reject loudly on an unsupported framework instead of falling through to
123
+ // `return []` — that return value is indistinguishable from "no routes",
124
+ // "no API key", "debounced", etc. A caller needs to be able to tell them apart.
125
+ if (!detect_js_1.SUPPORTED_FRAMEWORKS.has(framework)) {
126
+ const msg = `Framework "${framework}" not supported yet (${[...detect_js_1.SUPPORTED_FRAMEWORKS].join(', ')})`;
127
+ console.error(`❌ APIForge: ${msg}`);
128
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
129
+ throw new Error(msg);
130
+ }
120
131
  try {
121
132
  switch (framework) {
122
133
  case 'nestjs':
@@ -141,6 +152,20 @@ async function analyze(app, options) {
141
152
  }
142
153
  }
143
154
  break;
155
+ case 'fastify':
156
+ if (!app) {
157
+ const msg = 'Fastify app instance required — pass your app object to analyze()';
158
+ console.error(`❌ ${msg}`);
159
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
160
+ return [];
161
+ }
162
+ routes = await (0, fastify_js_1.analyzeFastify)(app, {
163
+ scopeCatalogPath: options.scopeCatalogPath,
164
+ errorCatalogPath: options.errorCatalogPath,
165
+ envelopes: options.envelopes,
166
+ annotationsPath: options.annotationsPath,
167
+ });
168
+ break;
144
169
  case 'nextjs-pages':
145
170
  routes = await (0, nextjs_pages_js_1.analyzeNextPages)(options.nextDir || 'pages/api');
146
171
  break;
@@ -153,11 +178,8 @@ async function analyze(app, options) {
153
178
  case 'react-router':
154
179
  routes = await (0, react_router_js_1.analyzeReactRouter)(options.nextDir || 'src/routes.tsx');
155
180
  break;
156
- default:
157
- const msg = `Framework "${framework}" not supported yet (express, nextjs-pages, nextjs-app, vite, react-router)`;
158
- console.warn(`⚠️ APIForge: ${msg}`);
159
- (0, uploader_js_1.writeStatusFile)('error', { error: msg });
160
- return [];
181
+ // No default: unsupported frameworks are rejected above, before this
182
+ // try block, so a thrown error here isn't silently swallowed into `[]`.
161
183
  }
162
184
  }
163
185
  catch (err) {
@@ -213,6 +235,7 @@ exports.default = {
213
235
  analyzeExpress: express_js_1.analyzeExpress,
214
236
  analyzeNextPages: nextjs_pages_js_1.analyzeNextPages,
215
237
  analyzeNextApp: nextjs_app_js_1.analyzeNextApp,
238
+ analyzeFastify: fastify_js_1.analyzeFastify,
216
239
  analyzeVite: vite_js_1.analyzeVite,
217
240
  analyzeReactRouter: react_router_js_1.analyzeReactRouter,
218
241
  analyzeNestJS: nestjs_js_1.analyzeNestJS,
@@ -1 +1 @@
1
- {"type":"commonjs"}
1
+ {"type":"commonjs"}
package/dist/esm/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { group, intro, outro, text, select, password, confirm, tasks, note, log, isCancel, cancel, spinner, } from '@clack/prompts';
3
3
  import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
4
- import { join } from 'path';
4
+ import { join, basename } from 'path';
5
5
  import { spawn } from 'child_process';
6
6
  import axios from 'axios';
7
7
  import { detectArchitectureType } from './arch-detect.js';
@@ -22,7 +22,7 @@ const green = (s) => `${gn}${s}${r}`;
22
22
  const yellow = (s) => `${yl}${s}${r}`;
23
23
  const red = (s) => `${rd}${s}${r}`;
24
24
  const white = (s) => `${wh}${s}${r}`;
25
- const VERSION = '1.3.1';
25
+ const VERSION = '1.3.2';
26
26
  // Built-in URLs — users never need to configure these
27
27
  const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
28
28
  const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
@@ -219,11 +219,18 @@ async function main() {
219
219
  if (serverResult.status === 'error') {
220
220
  s.stop(red(`✗ ${serverResult.message}`));
221
221
  // Give a targeted hint for common failure modes
222
- if (serverResult.message.includes('API key')) {
223
- log.warn(yellow('Hint: The CLI wrote your key to .env.local — but your server may not load it.'));
224
- log.warn(yellow(' Add this at the top of your entry file before calling analyze():'));
225
- log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
226
- log.warn(yellow(' Or install dotenv: npm install dotenv'));
222
+ if (serverResult.message.includes('API key') || serverResult.message.includes('Invalid API key')) {
223
+ log.warn(yellow('Hint: Your API key was rejected by the server. Common causes:'));
224
+ log.warn(yellow(''));
225
+ log.warn(yellow(' 1. Key not yet created — go to your dashboard and generate one:'));
226
+ log.info(` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`);
227
+ log.warn(yellow(''));
228
+ log.warn(yellow(' 2. Key belongs to a different account — make sure you\'re logged in'));
229
+ log.warn(yellow(' to the same account that owns this project.'));
230
+ log.warn(yellow(''));
231
+ log.warn(yellow(' 3. Server not loading .env.local — add this at the top of your entry file:'));
232
+ log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
233
+ log.warn(yellow(' Or install dotenv: npm install dotenv'));
227
234
  }
228
235
  else if (serverResult.message.includes('No routes')) {
229
236
  log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
@@ -233,6 +240,14 @@ async function main() {
233
240
  log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
234
241
  log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
235
242
  }
243
+ // Show captured server output to help debug the root cause
244
+ const capturedOutput = serverResult.serverOutput;
245
+ if (capturedOutput) {
246
+ log.info('');
247
+ log.info(dim('─── Server output ───────────────────────────────'));
248
+ capturedOutput.split('\n').slice(0, 25).forEach((line) => log.info(dim(` ${line}`)));
249
+ log.info(dim('─────────────────────────────────────────────────'));
250
+ }
236
251
  await offerCleanup(entryFile, injected);
237
252
  outro(red('Analysis failed.'));
238
253
  process.exit(1);
@@ -390,6 +405,158 @@ function hasDockerCompose(cwd) {
390
405
  return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
391
406
  .some(f => existsSync(join(cwd, f)));
392
407
  }
408
+ /**
409
+ * Find the gateway among the discovered services (by name or by proxy-middleware
410
+ * usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
411
+ * Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
412
+ * pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
413
+ * or no recognizable mapping is found — callers should treat that as "no rewrite."
414
+ */
415
+ function detectGatewayPrefixMap(cwd, services) {
416
+ const prefixMap = {};
417
+ for (const svc of services) {
418
+ const svcDir = svc.dir.replace(/^\.\//, '');
419
+ const relEntry = svc.entryFile ?? findEntryInDir(join(cwd, svcDir));
420
+ if (!relEntry)
421
+ continue;
422
+ let source = '';
423
+ try {
424
+ source = readFileSync(join(cwd, svcDir, relEntry), 'utf-8');
425
+ }
426
+ catch {
427
+ continue;
428
+ }
429
+ const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
430
+ if (!looksLikeGateway)
431
+ continue;
432
+ // '/auth': process.env.AUTH_SERVICE_URL || `http://...`
433
+ const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
434
+ let m;
435
+ while ((m = re.exec(source))) {
436
+ const prefix = m[1];
437
+ const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
438
+ if (!prefixMap[serviceName])
439
+ prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
440
+ }
441
+ if (Object.keys(prefixMap).length > 0)
442
+ break;
443
+ }
444
+ return prefixMap;
445
+ }
446
+ function withGatewayPrefix(prefix, routePath) {
447
+ if (routePath === '/')
448
+ return prefix;
449
+ return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
450
+ }
451
+ // ─── Postman collection enrichment ─────────────────────────────────────────────
452
+ // Runtime introspection only sees field NAMES (via regex over handler source),
453
+ // never real example values — a Postman collection, when present, already has
454
+ // hand-written example bodies and descriptions. We fold those in as enrichment
455
+ // only: matched by method + path, never adds or removes an endpoint.
456
+ function findFileRecursive(dir, matches, maxDepth, depth = 0) {
457
+ if (depth > maxDepth)
458
+ return null;
459
+ let entries;
460
+ try {
461
+ entries = readdirSync(dir, { withFileTypes: true });
462
+ }
463
+ catch {
464
+ return null;
465
+ }
466
+ for (const e of entries) {
467
+ if (e.isFile() && matches(e.name))
468
+ return join(dir, e.name);
469
+ }
470
+ for (const e of entries) {
471
+ if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
472
+ const found = findFileRecursive(join(dir, e.name), matches, maxDepth, depth + 1);
473
+ if (found)
474
+ return found;
475
+ }
476
+ }
477
+ return null;
478
+ }
479
+ function findPostmanCollection(cwd) {
480
+ const isCollection = (name) => name.endsWith('.postman_collection.json');
481
+ for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
482
+ const dirPath = join(cwd, dir);
483
+ if (!existsSync(dirPath))
484
+ continue;
485
+ try {
486
+ const match = readdirSync(dirPath).find(isCollection);
487
+ if (match)
488
+ return join(dirPath, match);
489
+ }
490
+ catch { }
491
+ }
492
+ return findFileRecursive(cwd, isCollection, 3);
493
+ }
494
+ /** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
495
+ function postmanRawToRoutePath(raw) {
496
+ let p = raw.trim();
497
+ p = p.replace(/^\{\{[^}]+\}\}/, '');
498
+ p = p.split('?')[0];
499
+ p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
500
+ if (!p)
501
+ p = '/';
502
+ if (!p.startsWith('/'))
503
+ p = '/' + p;
504
+ if (p !== '/' && p.endsWith('/'))
505
+ p = p.slice(0, -1);
506
+ return p;
507
+ }
508
+ /** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
509
+ function walkPostmanItems(items, index) {
510
+ for (const node of items ?? []) {
511
+ if (Array.isArray(node.item))
512
+ walkPostmanItems(node.item, index);
513
+ if (!node.request)
514
+ continue;
515
+ const req = node.request;
516
+ const method = String(req.method || 'GET').toUpperCase();
517
+ const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
518
+ if (!rawUrl)
519
+ continue;
520
+ const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
521
+ if (index.has(key))
522
+ continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
523
+ const entry = {};
524
+ if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
525
+ entry.bodyExample = req.body.raw.trim();
526
+ }
527
+ if (typeof req.description === 'string' && req.description.trim()) {
528
+ entry.description = req.description.trim();
529
+ }
530
+ if (entry.bodyExample || entry.description)
531
+ index.set(key, entry);
532
+ }
533
+ }
534
+ /** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
535
+ function enrichRoutesFromPostman(cwd, routes) {
536
+ const collectionPath = findPostmanCollection(cwd);
537
+ if (!collectionPath)
538
+ return;
539
+ try {
540
+ const collection = JSON.parse(readFileSync(collectionPath, 'utf-8'));
541
+ const index = new Map();
542
+ walkPostmanItems(collection.item, index);
543
+ let enriched = 0;
544
+ for (const route of routes) {
545
+ const match = index.get(`${route.method} ${route.path}`);
546
+ if (!match)
547
+ continue;
548
+ if (match.bodyExample)
549
+ route.bodyExample = match.bodyExample;
550
+ if (match.description && !route.description)
551
+ route.description = match.description;
552
+ enriched++;
553
+ }
554
+ if (enriched > 0) {
555
+ log.info(dim(`Enriched ${enriched} routes with examples from ${basename(collectionPath)}`));
556
+ }
557
+ }
558
+ catch { }
559
+ }
393
560
  async function microservicesFlow(config, opts) {
394
561
  const cwd = process.cwd();
395
562
  const allRoutes = [];
@@ -432,6 +599,14 @@ async function microservicesFlow(config, opts) {
432
599
  '',
433
600
  dim('Each service is started individually and its routes are collected.'),
434
601
  ].join('\n'), 'Multi-service analysis');
602
+ // Routes are collected at each service's own internal paths (e.g. auth's /register),
603
+ // not the path clients actually call through the gateway (/auth/register) — Express
604
+ // strips the mount prefix before the service ever sees the request. Rewrite paths
605
+ // here so the docs match what's actually callable.
606
+ const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
607
+ if (Object.keys(gatewayPrefixes).length > 0) {
608
+ log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
609
+ }
435
610
  for (const svc of config.services) {
436
611
  const svcSpin = spinner();
437
612
  svcSpin.start(`${bold(svc.name)} — starting…`);
@@ -460,13 +635,14 @@ async function microservicesFlow(config, opts) {
460
635
  detached: false,
461
636
  });
462
637
  // Capture output so failures are debuggable without re-running the service by hand.
463
- // Bounded to the last ~4000 chars so a chatty/looping service can't blow up memory.
638
+ // Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
639
+ // first, then unwinds the call stack — a long stack trace must not push that
640
+ // opening line out of the window, so stop growing once capped instead of sliding.
464
641
  let output = '';
465
- const MAX_OUTPUT = 4000;
642
+ const MAX_OUTPUT = 6000;
466
643
  const capture = (chunk) => {
467
- output += chunk.toString();
468
- if (output.length > MAX_OUTPUT)
469
- output = output.slice(-MAX_OUTPUT);
644
+ if (output.length < MAX_OUTPUT)
645
+ output += chunk.toString();
470
646
  };
471
647
  child.stdout?.on('data', capture);
472
648
  child.stderr?.on('data', capture);
@@ -479,7 +655,12 @@ async function microservicesFlow(config, opts) {
479
655
  catch { }
480
656
  tryUnlink(join(cwd, statusFile));
481
657
  if (routes && routes.length > 0) {
482
- const annotated = routes.map((r) => ({ ...r, service: svc.name }));
658
+ const prefix = gatewayPrefixes[svc.name];
659
+ const annotated = routes.map((r) => ({
660
+ ...r,
661
+ service: svc.name,
662
+ path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
663
+ }));
483
664
  allRoutes.push(...annotated);
484
665
  serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
485
666
  svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
@@ -488,7 +669,7 @@ async function microservicesFlow(config, opts) {
488
669
  svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
489
670
  const trimmed = output.trim();
490
671
  if (trimmed) {
491
- log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(-20).map(l => ` ${l}`).join('\n')));
672
+ log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
492
673
  }
493
674
  }
494
675
  // Remove injected code
@@ -498,6 +679,7 @@ async function microservicesFlow(config, opts) {
498
679
  // Safety: clean up any remaining injections (e.g. if process was interrupted)
499
680
  for (const f of injectedFiles)
500
681
  removeInjectedCode(f);
682
+ enrichRoutesFromPostman(cwd, allRoutes);
501
683
  if (allRoutes.length === 0) {
502
684
  log.error(red('No routes were collected from any service.'));
503
685
  outro(red('Analysis failed.'));
@@ -577,11 +759,20 @@ function startAndWaitForAnalysis(command, timeoutMs) {
577
759
  const statusPath = join(process.cwd(), STATUS_FILE);
578
760
  const child = spawn(command, {
579
761
  cwd: process.cwd(),
580
- stdio: 'ignore',
762
+ stdio: ['ignore', 'pipe', 'pipe'],
581
763
  shell: true,
582
764
  env: { ...process.env },
583
765
  detached: false,
584
766
  });
767
+ // Capture server output (head-capped) so we can show it on failure
768
+ let serverOutput = '';
769
+ const MAX_OUTPUT = 4000;
770
+ const captureOutput = (chunk) => {
771
+ if (serverOutput.length < MAX_OUTPUT)
772
+ serverOutput += chunk.toString();
773
+ };
774
+ child.stdout?.on('data', captureOutput);
775
+ child.stderr?.on('data', captureOutput);
585
776
  let resolved = false;
586
777
  function finish(result) {
587
778
  if (resolved)
@@ -593,7 +784,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
593
784
  child.kill('SIGTERM');
594
785
  }
595
786
  catch { }
596
- resolve(result);
787
+ resolve({ ...result, serverOutput: serverOutput.trim() });
597
788
  }
598
789
  // Poll for the status file every 500 ms
599
790
  const poller = setInterval(() => {
@@ -1018,13 +1209,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1018
1209
  ` upload: true,`,
1019
1210
  ` runOnce: true,`,
1020
1211
  ].join('\n');
1212
+ // Collection mode: some services only mount routes inside an async bootstrap
1213
+ // gated behind a DB/broker connect (which may hang/fail outside Docker).
1214
+ // Poll briefly for app._router to be populated (Express-shaped apps only)
1215
+ // before giving up and collecting whatever is there — bounded well under
1216
+ // the CLI's 30s per-service timeout so a hung connect still surfaces.
1217
+ const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1218
+ const call = collectionOpts
1219
+ ? [
1220
+ ` let __apiforgeTries = 0`,
1221
+ ` const __apiforgeTryCollect = () => {`,
1222
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1223
+ ` ? true`,
1224
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1225
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1226
+ ` ${runAnalyze}`,
1227
+ ` } else {`,
1228
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1229
+ ` }`,
1230
+ ` }`,
1231
+ ` __apiforgeTryCollect()`,
1232
+ ].join('\n')
1233
+ : ` ${runAnalyze}`;
1021
1234
  if (isESM) {
1022
1235
  return [
1023
1236
  `// APIForge — https://apiforge.brainfogagency.com`,
1024
1237
  `import('apiforge-analyzer').then(({ default: APIForge }) => {`,
1025
- ` APIForge.analyze(${appVar}, {`,
1026
- opts,
1027
- ` })`,
1238
+ call,
1028
1239
  `}).catch(() => {})`,
1029
1240
  '',
1030
1241
  ].join('\n');
@@ -1033,9 +1244,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1033
1244
  `// APIForge — https://apiforge.brainfogagency.com`,
1034
1245
  `Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
1035
1246
  ` const APIForge = mod.default || mod`,
1036
- ` APIForge.analyze(${appVar}, {`,
1037
- opts,
1038
- ` })`,
1247
+ call,
1039
1248
  `}).catch(() => {})`,
1040
1249
  '',
1041
1250
  ].join('\n');
@@ -1059,6 +1268,12 @@ function detectAppVariable(content) {
1059
1268
  /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1060
1269
  /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1061
1270
  /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
1271
+ // Inline `require('fastify')(...)` / `require('express')(...)` — the app
1272
+ // factory is never bound to a local name, only its return value is. This
1273
+ // is at least as common as the named-import style above (it's the pattern
1274
+ // in Fastify's own project generator output), and the patterns above never
1275
+ // match it because there's no `fastify`/`express` identifier in the source.
1276
+ /(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['"](?:fastify|express)['"]\s*\)\s*(?:\.default\s*)?\(/,
1062
1277
  ];
1063
1278
  for (const re of patterns) {
1064
1279
  const m = content.match(re);
@@ -1,5 +1,19 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { join } from 'path';
3
+ /**
4
+ * Frameworks analyze() can actually dispatch to. Detection can identify more
5
+ * frameworks than this (koa, hapi) — those are named so the detector is honest
6
+ * about what it saw, but `supported: false` tells callers not to expect output.
7
+ */
8
+ export const SUPPORTED_FRAMEWORKS = new Set([
9
+ 'express',
10
+ 'fastify',
11
+ 'nestjs',
12
+ 'nextjs-pages',
13
+ 'nextjs-app',
14
+ 'vite',
15
+ 'react-router',
16
+ ]);
3
17
  const cwd = () => process.cwd();
4
18
  function fileExists(rel) {
5
19
  return existsSync(join(cwd(), rel));
@@ -18,16 +32,19 @@ function tryResolve(mod) {
18
32
  * Detect the web framework from the live app instance + filesystem hints.
19
33
  * Confidence is used to pick the best match when multiple signals fire.
20
34
  */
35
+ function mk(name, confidence) {
36
+ return { name, confidence, supported: SUPPORTED_FRAMEWORKS.has(name) };
37
+ }
21
38
  export function detectFramework(app) {
22
39
  // ── Instance-based detection (highest confidence) ─────────────────────────
23
40
  if (app && typeof app === 'function') {
24
41
  // Express: _router or lazyrouter
25
42
  if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
26
- return { name: 'express', confidence: 1.0 };
43
+ return mk('express', 1.0);
27
44
  }
28
45
  // Koa: middleware array
29
46
  if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
30
- return { name: 'koa', confidence: 0.95 };
47
+ return mk('koa', 0.95);
31
48
  }
32
49
  }
33
50
  // Fastify: inject method and route method
@@ -35,7 +52,7 @@ export function detectFramework(app) {
35
52
  typeof app === 'object' &&
36
53
  typeof app.inject === 'function' &&
37
54
  typeof app.route === 'function') {
38
- return { name: 'fastify', confidence: 1.0 };
55
+ return mk('fastify', 1.0);
39
56
  }
40
57
  // Hapi: server with route + start methods
41
58
  if (app &&
@@ -43,43 +60,43 @@ export function detectFramework(app) {
43
60
  typeof app.route === 'function' &&
44
61
  typeof app.start === 'function' &&
45
62
  typeof app.stop === 'function') {
46
- return { name: 'hapi', confidence: 0.95 };
63
+ return mk('hapi', 0.95);
47
64
  }
48
65
  // NestJS application
49
66
  if (app &&
50
67
  typeof app === 'object' &&
51
68
  typeof app.getHttpServer === 'function' &&
52
69
  typeof app.listen === 'function') {
53
- return { name: 'nestjs', confidence: 0.95 };
70
+ return mk('nestjs', 0.95);
54
71
  }
55
72
  // ── Filesystem detection ──────────────────────────────────────────────────
56
73
  // Next.js App Router
57
74
  if (fileExists('app') && (fileExists('app/layout.tsx') || fileExists('app/layout.js'))) {
58
- return { name: 'nextjs-app', confidence: 0.92 };
75
+ return mk('nextjs-app', 0.92);
59
76
  }
60
77
  // Next.js Pages Router
61
78
  if (fileExists('pages/api') || fileExists('src/pages/api')) {
62
- return { name: 'nextjs-pages', confidence: 0.90 };
79
+ return mk('nextjs-pages', 0.90);
63
80
  }
64
81
  if (fileExists('next.config.js') ||
65
82
  fileExists('next.config.mjs') ||
66
83
  fileExists('next.config.ts')) {
67
- return { name: 'nextjs-pages', confidence: 0.75 };
84
+ return mk('nextjs-pages', 0.75);
68
85
  }
69
86
  // ── Dependency-based detection (lowest confidence) ────────────────────────
70
87
  if (tryResolve('express'))
71
- return { name: 'express', confidence: 0.70 };
88
+ return mk('express', 0.70);
72
89
  if (tryResolve('fastify'))
73
- return { name: 'fastify', confidence: 0.70 };
90
+ return mk('fastify', 0.70);
74
91
  if (tryResolve('koa'))
75
- return { name: 'koa', confidence: 0.70 };
92
+ return mk('koa', 0.70);
76
93
  if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
77
- return { name: 'hapi', confidence: 0.70 };
94
+ return mk('hapi', 0.70);
78
95
  if (tryResolve('@nestjs/core'))
79
- return { name: 'nestjs', confidence: 0.70 };
96
+ return mk('nestjs', 0.70);
80
97
  if (tryResolve('vite'))
81
- return { name: 'vite', confidence: 0.60 };
98
+ return mk('vite', 0.60);
82
99
  if (tryResolve('react-router-dom'))
83
- return { name: 'react-router', confidence: 0.55 };
84
- return { name: 'unknown', confidence: 0 };
100
+ return mk('react-router', 0.55);
101
+ return mk('unknown', 0);
85
102
  }