apiforge-analyzer 1.3.9 → 1.3.10

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/cli.js CHANGED
@@ -687,7 +687,7 @@ async function microservicesFlow(config, opts) {
687
687
  cwd,
688
688
  stdio: ['ignore', 'pipe', 'pipe'],
689
689
  shell: true,
690
- env: { ...process.env },
690
+ env: withExpressMountHook({ ...process.env }),
691
691
  detached: false,
692
692
  });
693
693
  // Capture output so failures are debuggable without re-running the service by hand.
@@ -1668,6 +1668,26 @@ function suppressNodemonSelfRestart(cwd) {
1668
1668
  catch { }
1669
1669
  };
1670
1670
  }
1671
+ /**
1672
+ * Preloads the Express mount hook (express-preload.ts) into the user's server, so router / sub-app mount
1673
+ * paths are recorded as they're registered — see express-mount-hook.ts. Without the built preload file
1674
+ * (e.g. running from source) the env is returned unchanged and prefixes are read off the stack as before.
1675
+ */
1676
+ function withExpressMountHook(env) {
1677
+ let preload;
1678
+ try {
1679
+ // The bin is dist/esm/cli.js (or dist/cjs/cli.js); the preload must be the CommonJS build.
1680
+ preload = (0, path_1.join)((0, path_1.dirname)((0, fs_1.realpathSync)(process.argv[1])), '..', 'cjs', 'express-preload.js');
1681
+ }
1682
+ catch {
1683
+ return env;
1684
+ }
1685
+ if (!(0, fs_1.existsSync)(preload))
1686
+ return env;
1687
+ // Forward slashes: NODE_OPTIONS treats a backslash inside quotes as an escape.
1688
+ const flag = `--require "${preload.replace(/\\/g, '/')}"`;
1689
+ return { ...env, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${flag}` : flag };
1690
+ }
1671
1691
  function startAndWaitForAnalysis(command, timeoutMs) {
1672
1692
  return new Promise(resolve => {
1673
1693
  const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
@@ -1675,7 +1695,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
1675
1695
  cwd: process.cwd(),
1676
1696
  stdio: ['ignore', 'pipe', 'pipe'],
1677
1697
  shell: true,
1678
- env: { ...process.env },
1698
+ env: withExpressMountHook({ ...process.env }),
1679
1699
  detached: false,
1680
1700
  });
1681
1701
  // Capture server output (head-capped) so we can show it on failure
@@ -47,10 +47,19 @@ function mk(name, confidence) {
47
47
  function detectFramework(app) {
48
48
  // ── Instance-based detection (highest confidence) ─────────────────────────
49
49
  if (app && typeof app === 'function') {
50
- // Express: _router or lazyrouter
50
+ // Express 4: _router or lazyrouter
51
51
  if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
52
52
  return mk('express', 1.0);
53
53
  }
54
+ // Express 5 dropped both; the router is behind an `app.router` getter (which throws in Express 4).
55
+ // Without this, an Express 5 app in a project that also has an app/ folder was mistaken for Next.js.
56
+ try {
57
+ if (app.router && Array.isArray(app.router.stack))
58
+ return mk('express', 1.0);
59
+ }
60
+ catch {
61
+ // not Express 5
62
+ }
54
63
  // Koa: middleware array
55
64
  if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
56
65
  return mk('koa', 0.95);
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * Records where every router / sub-app is mounted, at the moment it is mounted.
4
+ *
5
+ * The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
6
+ * - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
7
+ * router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
8
+ * - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
9
+ * so none of its routes were found.
10
+ * - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
11
+ *
12
+ * Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
13
+ * sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
14
+ * Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.SUB_APP = exports.MOUNT_PATH = void 0;
18
+ exports.appStack = appStack;
19
+ exports.installExpressMountHook = installExpressMountHook;
20
+ exports.MOUNT_PATH = '__apiforgeMountPath';
21
+ exports.SUB_APP = '__apiforgeSubApp';
22
+ const INSTALLED = Symbol.for('apiforge.expressMountHook');
23
+ /** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
24
+ function appStack(app) {
25
+ const legacy = app?._router?.stack;
26
+ if (Array.isArray(legacy))
27
+ return legacy;
28
+ try {
29
+ const stack = app?.router?.stack;
30
+ return Array.isArray(stack) ? stack : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ /** The mount path `use()` was given, or undefined when the first argument is already a handler (Express's own rule). */
37
+ function mountPathArg(args) {
38
+ let arg = args[0];
39
+ while (Array.isArray(arg) && arg.length !== 0)
40
+ arg = arg[0];
41
+ return typeof arg === 'function' ? undefined : args[0];
42
+ }
43
+ function isApp(fn) {
44
+ return !!fn && typeof fn.handle === 'function' && typeof fn.set === 'function';
45
+ }
46
+ function wrapUse(target, stackOf) {
47
+ const original = target.use;
48
+ if (typeof original !== 'function')
49
+ return;
50
+ target.use = function apiforgeUse(...args) {
51
+ const before = stackOf(this)?.length ?? 0;
52
+ const result = original.apply(this, args);
53
+ try {
54
+ const added = (stackOf(this) ?? []).slice(before);
55
+ const path = mountPathArg(args) ?? '/';
56
+ const handlers = (mountPathArg(args) === undefined ? args : args.slice(1)).flat(Infinity);
57
+ const subApps = handlers.filter(isApp);
58
+ let next = 0;
59
+ for (const layer of added) {
60
+ layer[exports.MOUNT_PATH] = path;
61
+ if (layer.handle?.name === 'mounted_app' && next < subApps.length)
62
+ layer[exports.SUB_APP] = subApps[next++];
63
+ }
64
+ }
65
+ catch {
66
+ // Never let documentation tagging break the user's app.
67
+ }
68
+ return result;
69
+ };
70
+ }
71
+ function installExpressMountHook(express) {
72
+ if (!express || express[INSTALLED])
73
+ return;
74
+ express[INSTALLED] = true;
75
+ // Express 5: Router is a class (router@2). Express 4: Router is the proto object itself.
76
+ const Router = express.Router;
77
+ const routerProto = typeof Router?.prototype?.use === 'function' ? Router.prototype : Router;
78
+ if (routerProto)
79
+ wrapUse(routerProto, (self) => (Array.isArray(self?.stack) ? self.stack : undefined));
80
+ if (express.application)
81
+ wrapUse(express.application, appStack);
82
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Preloaded into the user's server by the CLI (`NODE_OPTIONS=--require …/dist/cjs/express-preload.js`) so
5
+ * the mount hook is in place before any route is registered. Loads the project's own express (resolved
6
+ * from the working directory); a project without express is left untouched.
7
+ */
8
+ const module_1 = require("module");
9
+ const path_1 = require("path");
10
+ const express_mount_hook_js_1 = require("./express-mount-hook.js");
11
+ try {
12
+ const projectRequire = (0, module_1.createRequire)((0, path_1.join)(process.cwd(), 'noop.js'));
13
+ (0, express_mount_hook_js_1.installExpressMountHook)(projectRequire('express'));
14
+ }
15
+ catch {
16
+ // No express here (npm, nodemon, a non-Express project): nothing to record.
17
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.analyzeExpress = analyzeExpress;
7
+ const express_mount_hook_js_1 = require("../express-mount-hook.js");
7
8
  // ─── Path helpers ─────────────────────────────────────────────────────────────
8
9
  function normalizePath(raw) {
9
10
  return raw
@@ -52,29 +53,48 @@ function analyzeExpress(app) {
52
53
  console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
53
54
  return routes;
54
55
  }
55
- walkStack(stack, '', routes, seen);
56
+ const skipped = [];
57
+ walkStack(stack, '', routes, seen, skipped);
58
+ if (skipped.length) {
59
+ // Same method + path registered more than once: Express only ever serves the first, so it is
60
+ // documented once — but say so, so the count can be checked against the code.
61
+ console.warn(`⚠️ Skipped ${skipped.length} duplicate route(s) (same method + path registered twice):`);
62
+ for (const s of skipped)
63
+ console.warn(` ${s}`);
64
+ }
56
65
  return routes;
57
66
  }
58
- function walkStack(stack, basePath, out, seen) {
67
+ function walkStack(stack, basePath, out, seen, skipped) {
59
68
  for (const layer of stack) {
60
69
  if (layer.route) {
61
- const r = buildRoute(layer.route, basePath);
62
- if (r && !seen.has(r.method + r.path)) {
70
+ for (const r of buildRoutes(layer.route, basePath)) {
71
+ const key = `${r.method} ${r.path}`;
72
+ if (seen.has(key)) {
73
+ skipped.push(key);
74
+ continue;
75
+ }
63
76
  out.push(r);
64
- seen.add(r.method + r.path);
77
+ seen.add(key);
65
78
  }
66
79
  }
67
- else if (layer.handle?.stack) {
68
- // Router or sub-app mounted at a prefix
80
+ else {
81
+ // Router mounted at a prefix, or a sub-app (its layer only holds a `mounted_app` wrapper, so the
82
+ // mount hook hands over the app itself)
83
+ const subStack = layer.handle?.stack ?? (layer[express_mount_hook_js_1.SUB_APP] ? (0, express_mount_hook_js_1.appStack)(layer[express_mount_hook_js_1.SUB_APP]) : undefined);
84
+ if (!subStack)
85
+ continue;
86
+ // The path as written to use(), when the mount hook saw it; otherwise read back off the layer
69
87
  let prefix = '';
70
- if (layer.regexp?.source)
88
+ if (typeof layer[express_mount_hook_js_1.MOUNT_PATH] === 'string')
89
+ prefix = normalizePath(layer[express_mount_hook_js_1.MOUNT_PATH]);
90
+ else if (layer.regexp?.source)
71
91
  prefix = normalizePath(layer.regexp.source);
72
92
  else if (layer.path)
73
93
  prefix = normalizePath(layer.path);
74
94
  else if (typeof layer.route === 'string')
75
95
  prefix = normalizePath(layer.route);
76
96
  const combined = combinePaths(basePath, prefix);
77
- walkStack(layer.handle.stack, combined, out, seen);
97
+ walkStack(subStack, combined, out, seen, skipped);
78
98
  }
79
99
  }
80
100
  }
@@ -91,14 +111,14 @@ const AUTH_NAMES = new Set([
91
111
  'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
92
112
  'passport.authenticate',
93
113
  ]);
94
- function buildRoute(route, basePath) {
114
+ function buildRoutes(route, basePath) {
95
115
  if (!route.path)
96
- return null;
116
+ return [];
97
117
  const methods = Object.keys(route.methods || {})
98
118
  .filter(m => route.methods[m])
99
119
  .map(m => m.toUpperCase());
100
120
  if (!methods.length)
101
- return null;
121
+ return [];
102
122
  const cleanBase = normalizePath(basePath);
103
123
  const cleanRoute = normalizePath(route.path);
104
124
  let fullPath = (cleanBase === '/' ? '' : cleanBase) + cleanRoute;
@@ -142,19 +162,18 @@ function buildRoute(route, basePath) {
142
162
  // ── Auth detection ─────────────────────────────────────────────────────────
143
163
  const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
144
164
  /req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
145
- const method = methods[0];
146
165
  const tag = tagFromPath(fullPath);
147
- const summary = generateSummary(method, fullPath);
148
- const operationId = `${method.toLowerCase()}_${fullPath
149
- .replace(/[^a-zA-Z0-9]/g, '_')
150
- .replace(/_+/g, '_')
151
- .replace(/^_|_$/, '')}`;
152
- return {
166
+ // One route per method: router.route('/x').get(...).put(...) registers a single layer
167
+ // carrying every method, and each one is its own endpoint.
168
+ return methods.map(method => ({
153
169
  method,
154
170
  path: fullPath,
155
171
  tag,
156
- operationId,
157
- summary,
172
+ operationId: `${method.toLowerCase()}_${fullPath
173
+ .replace(/[^a-zA-Z0-9]/g, '_')
174
+ .replace(/_+/g, '_')
175
+ .replace(/^_|_$/, '')}`,
176
+ summary: generateSummary(method, fullPath),
158
177
  middleware,
159
178
  authRequired: hasAuth,
160
179
  pathParams,
@@ -165,7 +184,7 @@ function buildRoute(route, basePath) {
165
184
  framework: 'express',
166
185
  responseStatuses: responseStatuses.length ? responseStatuses : undefined,
167
186
  headerParams: headerParams.length ? headerParams : undefined,
168
- };
187
+ }));
169
188
  }
170
189
  // ─── Extraction helpers ───────────────────────────────────────────────────────
171
190
  function matchAll(src, re) {
package/dist/esm/cli.js CHANGED
@@ -1,8 +1,8 @@
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
- import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
3
+ import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync, realpathSync } from 'fs';
4
4
  import { tmpdir } from 'os';
5
- import { join, basename, relative, isAbsolute } from 'path';
5
+ import { join, basename, relative, isAbsolute, dirname } from 'path';
6
6
  import { spawn, spawnSync } from 'child_process';
7
7
  import axios from 'axios';
8
8
  import { detectArchitectureType } from './arch-detect.js';
@@ -682,7 +682,7 @@ async function microservicesFlow(config, opts) {
682
682
  cwd,
683
683
  stdio: ['ignore', 'pipe', 'pipe'],
684
684
  shell: true,
685
- env: { ...process.env },
685
+ env: withExpressMountHook({ ...process.env }),
686
686
  detached: false,
687
687
  });
688
688
  // Capture output so failures are debuggable without re-running the service by hand.
@@ -1663,6 +1663,26 @@ function suppressNodemonSelfRestart(cwd) {
1663
1663
  catch { }
1664
1664
  };
1665
1665
  }
1666
+ /**
1667
+ * Preloads the Express mount hook (express-preload.ts) into the user's server, so router / sub-app mount
1668
+ * paths are recorded as they're registered — see express-mount-hook.ts. Without the built preload file
1669
+ * (e.g. running from source) the env is returned unchanged and prefixes are read off the stack as before.
1670
+ */
1671
+ function withExpressMountHook(env) {
1672
+ let preload;
1673
+ try {
1674
+ // The bin is dist/esm/cli.js (or dist/cjs/cli.js); the preload must be the CommonJS build.
1675
+ preload = join(dirname(realpathSync(process.argv[1])), '..', 'cjs', 'express-preload.js');
1676
+ }
1677
+ catch {
1678
+ return env;
1679
+ }
1680
+ if (!existsSync(preload))
1681
+ return env;
1682
+ // Forward slashes: NODE_OPTIONS treats a backslash inside quotes as an escape.
1683
+ const flag = `--require "${preload.replace(/\\/g, '/')}"`;
1684
+ return { ...env, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${flag}` : flag };
1685
+ }
1666
1686
  function startAndWaitForAnalysis(command, timeoutMs) {
1667
1687
  return new Promise(resolve => {
1668
1688
  const statusPath = join(process.cwd(), STATUS_FILE);
@@ -1670,7 +1690,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
1670
1690
  cwd: process.cwd(),
1671
1691
  stdio: ['ignore', 'pipe', 'pipe'],
1672
1692
  shell: true,
1673
- env: { ...process.env },
1693
+ env: withExpressMountHook({ ...process.env }),
1674
1694
  detached: false,
1675
1695
  });
1676
1696
  // Capture server output (head-capped) so we can show it on failure
@@ -41,10 +41,19 @@ function mk(name, confidence) {
41
41
  export function detectFramework(app) {
42
42
  // ── Instance-based detection (highest confidence) ─────────────────────────
43
43
  if (app && typeof app === 'function') {
44
- // Express: _router or lazyrouter
44
+ // Express 4: _router or lazyrouter
45
45
  if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
46
46
  return mk('express', 1.0);
47
47
  }
48
+ // Express 5 dropped both; the router is behind an `app.router` getter (which throws in Express 4).
49
+ // Without this, an Express 5 app in a project that also has an app/ folder was mistaken for Next.js.
50
+ try {
51
+ if (app.router && Array.isArray(app.router.stack))
52
+ return mk('express', 1.0);
53
+ }
54
+ catch {
55
+ // not Express 5
56
+ }
48
57
  // Koa: middleware array
49
58
  if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
50
59
  return mk('koa', 0.95);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Records where every router / sub-app is mounted, at the moment it is mounted.
3
+ *
4
+ * The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
5
+ * - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
6
+ * router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
7
+ * - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
8
+ * so none of its routes were found.
9
+ * - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
10
+ *
11
+ * Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
12
+ * sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
13
+ * Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
14
+ */
15
+ export const MOUNT_PATH = '__apiforgeMountPath';
16
+ export const SUB_APP = '__apiforgeSubApp';
17
+ const INSTALLED = Symbol.for('apiforge.expressMountHook');
18
+ /** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
19
+ export function appStack(app) {
20
+ const legacy = app?._router?.stack;
21
+ if (Array.isArray(legacy))
22
+ return legacy;
23
+ try {
24
+ const stack = app?.router?.stack;
25
+ return Array.isArray(stack) ? stack : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ /** The mount path `use()` was given, or undefined when the first argument is already a handler (Express's own rule). */
32
+ function mountPathArg(args) {
33
+ let arg = args[0];
34
+ while (Array.isArray(arg) && arg.length !== 0)
35
+ arg = arg[0];
36
+ return typeof arg === 'function' ? undefined : args[0];
37
+ }
38
+ function isApp(fn) {
39
+ return !!fn && typeof fn.handle === 'function' && typeof fn.set === 'function';
40
+ }
41
+ function wrapUse(target, stackOf) {
42
+ const original = target.use;
43
+ if (typeof original !== 'function')
44
+ return;
45
+ target.use = function apiforgeUse(...args) {
46
+ const before = stackOf(this)?.length ?? 0;
47
+ const result = original.apply(this, args);
48
+ try {
49
+ const added = (stackOf(this) ?? []).slice(before);
50
+ const path = mountPathArg(args) ?? '/';
51
+ const handlers = (mountPathArg(args) === undefined ? args : args.slice(1)).flat(Infinity);
52
+ const subApps = handlers.filter(isApp);
53
+ let next = 0;
54
+ for (const layer of added) {
55
+ layer[MOUNT_PATH] = path;
56
+ if (layer.handle?.name === 'mounted_app' && next < subApps.length)
57
+ layer[SUB_APP] = subApps[next++];
58
+ }
59
+ }
60
+ catch {
61
+ // Never let documentation tagging break the user's app.
62
+ }
63
+ return result;
64
+ };
65
+ }
66
+ export function installExpressMountHook(express) {
67
+ if (!express || express[INSTALLED])
68
+ return;
69
+ express[INSTALLED] = true;
70
+ // Express 5: Router is a class (router@2). Express 4: Router is the proto object itself.
71
+ const Router = express.Router;
72
+ const routerProto = typeof Router?.prototype?.use === 'function' ? Router.prototype : Router;
73
+ if (routerProto)
74
+ wrapUse(routerProto, (self) => (Array.isArray(self?.stack) ? self.stack : undefined));
75
+ if (express.application)
76
+ wrapUse(express.application, appStack);
77
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Preloaded into the user's server by the CLI (`NODE_OPTIONS=--require …/dist/cjs/express-preload.js`) so
3
+ * the mount hook is in place before any route is registered. Loads the project's own express (resolved
4
+ * from the working directory); a project without express is left untouched.
5
+ */
6
+ import { createRequire } from 'module';
7
+ import { join } from 'path';
8
+ import { installExpressMountHook } from './express-mount-hook.js';
9
+ try {
10
+ const projectRequire = createRequire(join(process.cwd(), 'noop.js'));
11
+ installExpressMountHook(projectRequire('express'));
12
+ }
13
+ catch {
14
+ // No express here (npm, nodemon, a non-Express project): nothing to record.
15
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Express route analyzer — deep extraction of routes, parameters, and auth patterns
3
3
  */
4
+ import { MOUNT_PATH, SUB_APP, appStack } from '../express-mount-hook.js';
4
5
  // ─── Path helpers ─────────────────────────────────────────────────────────────
5
6
  function normalizePath(raw) {
6
7
  return raw
@@ -49,29 +50,48 @@ export function analyzeExpress(app) {
49
50
  console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
50
51
  return routes;
51
52
  }
52
- walkStack(stack, '', routes, seen);
53
+ const skipped = [];
54
+ walkStack(stack, '', routes, seen, skipped);
55
+ if (skipped.length) {
56
+ // Same method + path registered more than once: Express only ever serves the first, so it is
57
+ // documented once — but say so, so the count can be checked against the code.
58
+ console.warn(`⚠️ Skipped ${skipped.length} duplicate route(s) (same method + path registered twice):`);
59
+ for (const s of skipped)
60
+ console.warn(` ${s}`);
61
+ }
53
62
  return routes;
54
63
  }
55
- function walkStack(stack, basePath, out, seen) {
64
+ function walkStack(stack, basePath, out, seen, skipped) {
56
65
  for (const layer of stack) {
57
66
  if (layer.route) {
58
- const r = buildRoute(layer.route, basePath);
59
- if (r && !seen.has(r.method + r.path)) {
67
+ for (const r of buildRoutes(layer.route, basePath)) {
68
+ const key = `${r.method} ${r.path}`;
69
+ if (seen.has(key)) {
70
+ skipped.push(key);
71
+ continue;
72
+ }
60
73
  out.push(r);
61
- seen.add(r.method + r.path);
74
+ seen.add(key);
62
75
  }
63
76
  }
64
- else if (layer.handle?.stack) {
65
- // Router or sub-app mounted at a prefix
77
+ else {
78
+ // Router mounted at a prefix, or a sub-app (its layer only holds a `mounted_app` wrapper, so the
79
+ // mount hook hands over the app itself)
80
+ const subStack = layer.handle?.stack ?? (layer[SUB_APP] ? appStack(layer[SUB_APP]) : undefined);
81
+ if (!subStack)
82
+ continue;
83
+ // The path as written to use(), when the mount hook saw it; otherwise read back off the layer
66
84
  let prefix = '';
67
- if (layer.regexp?.source)
85
+ if (typeof layer[MOUNT_PATH] === 'string')
86
+ prefix = normalizePath(layer[MOUNT_PATH]);
87
+ else if (layer.regexp?.source)
68
88
  prefix = normalizePath(layer.regexp.source);
69
89
  else if (layer.path)
70
90
  prefix = normalizePath(layer.path);
71
91
  else if (typeof layer.route === 'string')
72
92
  prefix = normalizePath(layer.route);
73
93
  const combined = combinePaths(basePath, prefix);
74
- walkStack(layer.handle.stack, combined, out, seen);
94
+ walkStack(subStack, combined, out, seen, skipped);
75
95
  }
76
96
  }
77
97
  }
@@ -88,14 +108,14 @@ const AUTH_NAMES = new Set([
88
108
  'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
89
109
  'passport.authenticate',
90
110
  ]);
91
- function buildRoute(route, basePath) {
111
+ function buildRoutes(route, basePath) {
92
112
  if (!route.path)
93
- return null;
113
+ return [];
94
114
  const methods = Object.keys(route.methods || {})
95
115
  .filter(m => route.methods[m])
96
116
  .map(m => m.toUpperCase());
97
117
  if (!methods.length)
98
- return null;
118
+ return [];
99
119
  const cleanBase = normalizePath(basePath);
100
120
  const cleanRoute = normalizePath(route.path);
101
121
  let fullPath = (cleanBase === '/' ? '' : cleanBase) + cleanRoute;
@@ -139,19 +159,18 @@ function buildRoute(route, basePath) {
139
159
  // ── Auth detection ─────────────────────────────────────────────────────────
140
160
  const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
141
161
  /req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
142
- const method = methods[0];
143
162
  const tag = tagFromPath(fullPath);
144
- const summary = generateSummary(method, fullPath);
145
- const operationId = `${method.toLowerCase()}_${fullPath
146
- .replace(/[^a-zA-Z0-9]/g, '_')
147
- .replace(/_+/g, '_')
148
- .replace(/^_|_$/, '')}`;
149
- return {
163
+ // One route per method: router.route('/x').get(...).put(...) registers a single layer
164
+ // carrying every method, and each one is its own endpoint.
165
+ return methods.map(method => ({
150
166
  method,
151
167
  path: fullPath,
152
168
  tag,
153
- operationId,
154
- summary,
169
+ operationId: `${method.toLowerCase()}_${fullPath
170
+ .replace(/[^a-zA-Z0-9]/g, '_')
171
+ .replace(/_+/g, '_')
172
+ .replace(/^_|_$/, '')}`,
173
+ summary: generateSummary(method, fullPath),
155
174
  middleware,
156
175
  authRequired: hasAuth,
157
176
  pathParams,
@@ -162,7 +181,7 @@ function buildRoute(route, basePath) {
162
181
  framework: 'express',
163
182
  responseStatuses: responseStatuses.length ? responseStatuses : undefined,
164
183
  headerParams: headerParams.length ? headerParams : undefined,
165
- };
184
+ }));
166
185
  }
167
186
  // ─── Extraction helpers ───────────────────────────────────────────────────────
168
187
  function matchAll(src, re) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Records where every router / sub-app is mounted, at the moment it is mounted.
3
+ *
4
+ * The Express analyzer reads mount prefixes back off the router stack after the fact, which loses them:
5
+ * - Express 5 (router@2) keeps no path on a layer at all (only compiled matchers), so every mounted
6
+ * router's routes came out without their prefix, and same-looking ones collapsed as duplicates.
7
+ * - A sub-app (`app.use('/admin', express())`) is wrapped in a `mounted_app` function with no `stack`,
8
+ * so none of its routes were found.
9
+ * - Express 4's mount regexp for a parameterised prefix (`/users/:id/posts`) doesn't turn back into the path.
10
+ *
11
+ * Wrapping `use()` tags each layer it adds with the mount path as written, and a sub-app's layer with the
12
+ * sub-app itself; express.ts prefers those tags and falls back to the old regexp reading without them.
13
+ * Must run before the app registers its routes — the CLI preloads it (express-preload.ts).
14
+ */
15
+ export declare const MOUNT_PATH = "__apiforgeMountPath";
16
+ export declare const SUB_APP = "__apiforgeSubApp";
17
+ /** An app's router stack: `_router` in Express 4, the `router` getter in Express 5 (which throws in 4). */
18
+ export declare function appStack(app: any): any[] | undefined;
19
+ export declare function installExpressMountHook(express: any): void;
@@ -0,0 +1 @@
1
+ export {};