apiforge-analyzer 1.3.2 β†’ 1.3.5

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
@@ -215,15 +215,34 @@ async function main() {
215
215
  ].join('\n'), 'Running');
216
216
  const s = spinner();
217
217
  s.start('Starting server and analyzing routes…');
218
- const serverResult = await startAndWaitForAnalysis(runCommand, 90000);
218
+ // If the dev command runs under nodemon, our own STATUS_FILE/LOCK_FILE
219
+ // writes (both plain .json files inside the project root) are themselves
220
+ // inside nodemon's default watch glob β€” writing them mid-analysis makes
221
+ // nodemon restart the very server we're trying to analyze, racing the old
222
+ // and new process for the port (EADDRINUSE) and hanging the whole run.
223
+ const restoreNodemonIgnore = suppressNodemonSelfRestart(process.cwd());
224
+ let serverResult;
225
+ try {
226
+ serverResult = await startAndWaitForAnalysis(runCommand, 90000);
227
+ }
228
+ finally {
229
+ restoreNodemonIgnore();
230
+ }
219
231
  if (serverResult.status === 'error') {
220
232
  s.stop(red(`βœ— ${serverResult.message}`));
221
233
  // 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'));
234
+ if (serverResult.message.includes('API key') || serverResult.message.includes('Invalid API key')) {
235
+ log.warn(yellow('Hint: Your API key was rejected by the server. Common causes:'));
236
+ log.warn(yellow(''));
237
+ log.warn(yellow(' 1. Key not yet created β€” go to your dashboard and generate one:'));
238
+ log.info(` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`);
239
+ log.warn(yellow(''));
240
+ log.warn(yellow(' 2. Key belongs to a different account β€” make sure you\'re logged in'));
241
+ log.warn(yellow(' to the same account that owns this project.'));
242
+ log.warn(yellow(''));
243
+ log.warn(yellow(' 3. Server not loading .env.local β€” add this at the top of your entry file:'));
244
+ log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
245
+ log.warn(yellow(' Or install dotenv: npm install dotenv'));
227
246
  }
228
247
  else if (serverResult.message.includes('No routes')) {
229
248
  log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
@@ -233,6 +252,14 @@ async function main() {
233
252
  log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
234
253
  log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
235
254
  }
255
+ // Show captured server output to help debug the root cause
256
+ const capturedOutput = serverResult.serverOutput;
257
+ if (capturedOutput) {
258
+ log.info('');
259
+ log.info(dim('─── Server output ───────────────────────────────'));
260
+ capturedOutput.split('\n').slice(0, 25).forEach((line) => log.info(dim(` ${line}`)));
261
+ log.info(dim('─────────────────────────────────────────────────'));
262
+ }
236
263
  await offerCleanup(entryFile, injected);
237
264
  outro(red('Analysis failed.'));
238
265
  process.exit(1);
@@ -633,11 +660,9 @@ async function microservicesFlow(config, opts) {
633
660
  child.stderr?.on('data', capture);
634
661
  // Wait up to 30 s for the status file to appear
635
662
  const routes = await waitForServiceStatusFile(statusFile, 30000);
636
- // Kill service process
637
- try {
638
- child.kill('SIGTERM');
639
- }
640
- catch { }
663
+ // Kill service process (and its full descendant tree β€” see killProcessTree)
664
+ if (child.pid)
665
+ killProcessTree(child.pid);
641
666
  tryUnlink(join(cwd, statusFile));
642
667
  if (routes && routes.length > 0) {
643
668
  const prefix = gatewayPrefixes[svc.name];
@@ -739,16 +764,103 @@ async function microservicesFlow(config, opts) {
739
764
  }[approval.status];
740
765
  outro(outroMsg);
741
766
  }
767
+ // ─── Server subprocess ────────────────────────────────────────────────────────
768
+ /**
769
+ * Kill the whole process tree rooted at `pid`, not just that one process.
770
+ *
771
+ * The dev server is started via `spawn(command, { shell: true })`, so `pid`
772
+ * is the shell (cmd.exe /c "npm run dev") β€” `child.kill()` only signals that
773
+ * shell. On Windows, that leaves the real descendants (npm β†’ nodemon β†’ node)
774
+ * running and still holding the port, so the very next run immediately hits
775
+ * EADDRINUSE. `taskkill /T` kills the shell's entire descendant tree instead.
776
+ * On POSIX this isn't needed the same way (SIGTERM propagates to the shell's
777
+ * job, and the analyzer process itself exits cleanly), so a plain kill there
778
+ * is enough.
779
+ */
780
+ function killProcessTree(pid) {
781
+ if (process.platform === 'win32') {
782
+ try {
783
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
784
+ }
785
+ catch { }
786
+ }
787
+ else {
788
+ try {
789
+ process.kill(pid, 'SIGTERM');
790
+ }
791
+ catch { }
792
+ }
793
+ }
794
+ const NODEMON_IGNORE_PATTERNS = ['.apiforge-status.json', '.apiforge-last-run.json', '.env.local'];
795
+ /**
796
+ * If the project depends on nodemon, make sure it won't watch our own
797
+ * .apiforge-status.json / .apiforge-last-run.json / .env.local writes β€”
798
+ * nodemon's default watch glob includes *.json, so without this it restarts
799
+ * the app mid-analysis (racing the old and new process for the port) every
800
+ * time analyze() writes its completion signal.
801
+ *
802
+ * Uses nodemon's own `.nodemonignore` file (read automatically if present,
803
+ * no config changes needed). Creates it if absent, or appends any missing
804
+ * patterns if it already exists β€” then returns a function that restores
805
+ * whatever was there before (or removes the file if we created it).
806
+ */
807
+ function suppressNodemonSelfRestart(cwd) {
808
+ try {
809
+ const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
810
+ const usesNodemon = !!(pkg.dependencies?.nodemon || pkg.devDependencies?.nodemon);
811
+ if (!usesNodemon)
812
+ return () => { };
813
+ }
814
+ catch {
815
+ return () => { };
816
+ }
817
+ const ignorePath = join(cwd, '.nodemonignore');
818
+ const existed = existsSync(ignorePath);
819
+ const original = existed ? readFileSync(ignorePath, 'utf-8') : '';
820
+ const existingLines = new Set(original.split('\n').map(l => l.trim()).filter(Boolean));
821
+ const missing = NODEMON_IGNORE_PATTERNS.filter(p => !existingLines.has(p));
822
+ if (missing.length === 0)
823
+ return () => { };
824
+ try {
825
+ const next = existed
826
+ ? original.trimEnd() + '\n' + missing.join('\n') + '\n'
827
+ : missing.join('\n') + '\n';
828
+ writeFileSync(ignorePath, next, 'utf-8');
829
+ }
830
+ catch {
831
+ return () => { };
832
+ }
833
+ return () => {
834
+ try {
835
+ if (existed) {
836
+ writeFileSync(ignorePath, original, 'utf-8');
837
+ }
838
+ else {
839
+ unlinkSync(ignorePath);
840
+ }
841
+ }
842
+ catch { }
843
+ };
844
+ }
742
845
  function startAndWaitForAnalysis(command, timeoutMs) {
743
846
  return new Promise(resolve => {
744
847
  const statusPath = join(process.cwd(), STATUS_FILE);
745
848
  const child = spawn(command, {
746
849
  cwd: process.cwd(),
747
- stdio: 'ignore',
850
+ stdio: ['ignore', 'pipe', 'pipe'],
748
851
  shell: true,
749
852
  env: { ...process.env },
750
853
  detached: false,
751
854
  });
855
+ // Capture server output (head-capped) so we can show it on failure
856
+ let serverOutput = '';
857
+ const MAX_OUTPUT = 4000;
858
+ const captureOutput = (chunk) => {
859
+ if (serverOutput.length < MAX_OUTPUT)
860
+ serverOutput += chunk.toString();
861
+ };
862
+ child.stdout?.on('data', captureOutput);
863
+ child.stderr?.on('data', captureOutput);
752
864
  let resolved = false;
753
865
  function finish(result) {
754
866
  if (resolved)
@@ -756,11 +868,9 @@ function startAndWaitForAnalysis(command, timeoutMs) {
756
868
  resolved = true;
757
869
  clearInterval(poller);
758
870
  clearTimeout(timeout);
759
- try {
760
- child.kill('SIGTERM');
761
- }
762
- catch { }
763
- resolve(result);
871
+ if (child.pid)
872
+ killProcessTree(child.pid);
873
+ resolve({ ...result, serverOutput: serverOutput.trim() });
764
874
  }
765
875
  // Poll for the status file every 500 ms
766
876
  const poller = setInterval(() => {
@@ -870,6 +980,28 @@ function removeInjectedCode(filePath) {
870
980
  return false;
871
981
  }
872
982
  }
983
+ // ─── Entry file detection ─────────────────────────────────────────────────────
984
+ /**
985
+ * If `candidate` looks like compiled build output (dist/build/out/lib) and a
986
+ * TypeScript source with the same base name exists under src/, prefer that
987
+ * instead of the build artifact.
988
+ *
989
+ * Injecting into dist/index.js is worse than pointless when the project has
990
+ * a watch compiler (tsc --watch, common in a "dev" script alongside
991
+ * nodemon watching that same dist folder): the compiler immediately
992
+ * overwrites the file with a fresh, uninjected build, and that overwrite
993
+ * itself looks like a file change to nodemon, which restarts the server β€”
994
+ * colliding with the still-running instance that had the injected snippet
995
+ * and racing it for the port (EADDRINUSE). The injected code also can't be
996
+ * cleaned up afterward because tsc already erased it before cleanup ran.
997
+ */
998
+ function preferSourceOverBuildOutput(cwd, candidate) {
999
+ const match = candidate.match(/^(?:dist|build|out|lib)[\\/](.+)\.(?:js|mjs|cjs)$/);
1000
+ if (!match)
1001
+ return candidate;
1002
+ const srcCandidate = join('src', `${match[1]}.ts`);
1003
+ return existsSync(join(cwd, srcCandidate)) ? srcCandidate : candidate;
1004
+ }
873
1005
  /** Look for a server entry inside a single directory (checks src/ then root). */
874
1006
  function findEntryInDir(absDir) {
875
1007
  const names = ['index', 'server', 'app', 'main'];
@@ -895,12 +1027,12 @@ function detectMainEntry(cwd) {
895
1027
  try {
896
1028
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
897
1029
  if (pkg.main && existsSync(join(cwd, pkg.main)))
898
- return pkg.main;
1030
+ return preferSourceOverBuildOutput(cwd, pkg.main);
899
1031
  for (const s of ['start', 'dev', 'serve']) {
900
1032
  const cmd = pkg.scripts?.[s] ?? '';
901
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1033
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
902
1034
  if (m && existsSync(join(cwd, m[1])))
903
- return m[1];
1035
+ return preferSourceOverBuildOutput(cwd, m[1]);
904
1036
  }
905
1037
  }
906
1038
  catch { }
@@ -991,12 +1123,12 @@ function detectEntryFile(archType) {
991
1123
  try {
992
1124
  const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
993
1125
  if (pkg.main && existsSync(join(cwd, pkg.main)))
994
- return pkg.main;
1126
+ return preferSourceOverBuildOutput(cwd, pkg.main);
995
1127
  for (const s of ['start', 'dev', 'serve']) {
996
1128
  const cmd = pkg.scripts?.[s] ?? '';
997
- const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+([^\s&|;]+\.(js|ts|mjs|cjs))/);
1129
+ const m = cmd.match(/(?:node|tsx|ts-node|nodemon)\s+(?:-\S+\s+)*([^\s&|;]+\.(js|ts|mjs|cjs))/);
998
1130
  if (m && existsSync(join(cwd, m[1])))
999
- return m[1];
1131
+ return preferSourceOverBuildOutput(cwd, m[1]);
1000
1132
  }
1001
1133
  }
1002
1134
  catch { }
@@ -1187,24 +1319,37 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
1187
1319
  ].join('\n');
1188
1320
  // Collection mode: some services only mount routes inside an async bootstrap
1189
1321
  // gated behind a DB/broker connect (which may hang/fail outside Docker).
1190
- // Poll briefly for app._router to be populated (Express-shaped apps only)
1191
- // before giving up and collecting whatever is there β€” bounded well under
1192
- // the CLI's 30s per-service timeout so a hung connect still surfaces.
1322
+ //
1323
+ // Express exposes no "I'm done registering routes" signal, so for
1324
+ // Express-shaped apps we poll app._router.stack briefly before giving up
1325
+ // and collecting whatever is there β€” bounded well under the CLI's 30s
1326
+ // per-service timeout so a hung connect still surfaces.
1327
+ //
1328
+ // Fastify DOES expose a real completion signal (`app.ready()`, resolved
1329
+ // once every plugin/route has finished registering), so Fastify apps use
1330
+ // that instead of the Express-shaped poll β€” polling `._router.stack` on a
1331
+ // Fastify instance always looks "empty" (Fastify doesn't have that
1332
+ // property), which used to make hasRoutes evaluate true immediately and
1333
+ // fire analyze() before async-registered routes existed yet.
1193
1334
  const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
1194
1335
  const call = collectionOpts
1195
1336
  ? [
1196
- ` let __apiforgeTries = 0`,
1197
- ` const __apiforgeTryCollect = () => {`,
1198
- ` const hasRoutes = !${appVar} || !${appVar}._router`,
1199
- ` ? true`,
1200
- ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1201
- ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1202
- ` ${runAnalyze}`,
1203
- ` } else {`,
1204
- ` setTimeout(__apiforgeTryCollect, 300)`,
1337
+ ` if (${appVar} && typeof ${appVar}.ready === 'function') {`,
1338
+ ` ${appVar}.ready().then(() => { ${runAnalyze} }).catch(() => { ${runAnalyze} })`,
1339
+ ` } else {`,
1340
+ ` let __apiforgeTries = 0`,
1341
+ ` const __apiforgeTryCollect = () => {`,
1342
+ ` const hasRoutes = !${appVar} || !${appVar}._router`,
1343
+ ` ? true`,
1344
+ ` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
1345
+ ` if (hasRoutes || __apiforgeTries++ >= 26) {`,
1346
+ ` ${runAnalyze}`,
1347
+ ` } else {`,
1348
+ ` setTimeout(__apiforgeTryCollect, 300)`,
1349
+ ` }`,
1205
1350
  ` }`,
1351
+ ` __apiforgeTryCollect()`,
1206
1352
  ` }`,
1207
- ` __apiforgeTryCollect()`,
1208
1353
  ].join('\n')
1209
1354
  : ` ${runAnalyze}`;
1210
1355
  if (isESM) {
@@ -1239,11 +1384,23 @@ function detectESM(filePath, content) {
1239
1384
  return /^(?:import|export)\s+/m.test(content);
1240
1385
  }
1241
1386
  function detectAppVariable(content) {
1387
+ // Optional TS type annotation between the variable name and `=`, e.g.
1388
+ // `const app: FastifyInstance = fastify()` β€” common in Fastify+TS
1389
+ // projects (Fastify has first-class TS typings, unlike bare Express).
1390
+ // Without this, none of the patterns below matched a type-annotated
1391
+ // Fastify app at all.
1392
+ const typeAnn = `(?:\\s*:\\s*[\\w.$]+(?:<[^>]*>)?)?`;
1242
1393
  const patterns = [
1243
- /(?:const|let|var)\s+(\w+)\s*=\s*express\s*(?:\.default)?\s*\(/,
1244
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1245
- /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1246
- /(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
1394
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*express\\s*(?:\\.default)?\\s*\\(`),
1395
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:fastify|Fastify)\\s*\\(`),
1396
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*new\\s+Koa\\s*\\(`),
1397
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*(?:Router|createApp|createServer)\\s*\\(`),
1398
+ // Inline `require('fastify')(...)` / `require('express')(...)` β€” the app
1399
+ // factory is never bound to a local name, only its return value is. This
1400
+ // is at least as common as the named-import style above (it's the pattern
1401
+ // in Fastify's own project generator output), and the patterns above never
1402
+ // match it because there's no `fastify`/`express` identifier in the source.
1403
+ new RegExp(`(?:const|let|var)\\s+(\\w+)${typeAnn}\\s*=\\s*require\\s*\\(\\s*['"](?:fastify|express)['"]\\s*\\)\\s*(?:\\.default\\s*)?\\(`),
1247
1404
  ];
1248
1405
  for (const re of patterns) {
1249
1406
  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
  }