apiforge-analyzer 1.3.8 → 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
@@ -111,6 +111,22 @@ async function main() {
111
111
  (0, prompts_1.cancel)('Cancelled.');
112
112
  process.exit(0);
113
113
  }
114
+ // ── Who can see the docs (APIForge spec §6.1 levels, chosen by the creator per §6.3) ──────────────────
115
+ const visibilityChoice = await (0, prompts_1.select)({
116
+ message: 'Who can see these docs?',
117
+ initialValue: 'PRIVATE', // "Only me" — the backend's default too
118
+ options: [
119
+ { value: 'ORG_WIDE', label: `${bold('Everyone in the org')} ${dim('— every member of your organization')}` },
120
+ { value: 'TEAM', label: `${bold('Team')} ${dim('— people you add, plus admins and the Superadmin')}` },
121
+ { value: 'PRIVATE', label: `${bold('Only me')} ${dim('— you, people you add, and the Superadmin')}` },
122
+ ],
123
+ });
124
+ if ((0, prompts_1.isCancel)(visibilityChoice)) {
125
+ (0, prompts_1.cancel)('Cancelled.');
126
+ process.exit(0);
127
+ }
128
+ // Read by uploadRoutes — in this process and in the server this CLI starts (it inherits the environment)
129
+ process.env.APIFORGE_VISIBILITY = visibilityChoice;
114
130
  const isLocal = backendChoice === 'local';
115
131
  const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
116
132
  const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
@@ -671,7 +687,7 @@ async function microservicesFlow(config, opts) {
671
687
  cwd,
672
688
  stdio: ['ignore', 'pipe', 'pipe'],
673
689
  shell: true,
674
- env: { ...process.env },
690
+ env: withExpressMountHook({ ...process.env }),
675
691
  detached: false,
676
692
  });
677
693
  // Capture output so failures are debuggable without re-running the service by hand.
@@ -1652,6 +1668,26 @@ function suppressNodemonSelfRestart(cwd) {
1652
1668
  catch { }
1653
1669
  };
1654
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
+ }
1655
1691
  function startAndWaitForAnalysis(command, timeoutMs) {
1656
1692
  return new Promise(resolve => {
1657
1693
  const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
@@ -1659,7 +1695,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
1659
1695
  cwd: process.cwd(),
1660
1696
  stdio: ['ignore', 'pipe', 'pipe'],
1661
1697
  shell: true,
1662
- env: { ...process.env },
1698
+ env: withExpressMountHook({ ...process.env }),
1663
1699
  detached: false,
1664
1700
  });
1665
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) {
@@ -10,6 +10,16 @@ const axios_1 = __importDefault(require("axios"));
10
10
  const fs_1 = require("fs");
11
11
  const path_1 = require("path");
12
12
  const export_js_1 = require("./export.js");
13
+ /** The backend's fields for a visibility choice — nothing when none was made. */
14
+ function visibilityFields(choice) {
15
+ if (choice === 'ORG_WIDE')
16
+ return { visibility: 'ORG_WIDE', isPrivate: false };
17
+ if (choice === 'TEAM')
18
+ return { visibility: 'TEAM', isPrivate: false };
19
+ if (choice === 'PRIVATE')
20
+ return { visibility: 'TEAM', isPrivate: true };
21
+ return {};
22
+ }
13
23
  /** Status file written to the project root so the CLI can detect completion. */
14
24
  const STATUS_FILE = '.apiforge-status.json';
15
25
  function writeStatusFile(status, data) {
@@ -44,6 +54,7 @@ async function uploadRoutes(routes, options) {
44
54
  framework: enrichedRoutes[0]?.framework || 'unknown',
45
55
  architectureType: options.architectureType || 'monolithic',
46
56
  services: options.services || [],
57
+ ...visibilityFields(options.visibility ?? process.env.APIFORGE_VISIBILITY),
47
58
  };
48
59
  try {
49
60
  const response = await axios_1.default.post(`${backendUrl}/api/cli/submit`, payload, {
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';
@@ -106,6 +106,22 @@ async function main() {
106
106
  cancel('Cancelled.');
107
107
  process.exit(0);
108
108
  }
109
+ // ── Who can see the docs (APIForge spec §6.1 levels, chosen by the creator per §6.3) ──────────────────
110
+ const visibilityChoice = await select({
111
+ message: 'Who can see these docs?',
112
+ initialValue: 'PRIVATE', // "Only me" — the backend's default too
113
+ options: [
114
+ { value: 'ORG_WIDE', label: `${bold('Everyone in the org')} ${dim('— every member of your organization')}` },
115
+ { value: 'TEAM', label: `${bold('Team')} ${dim('— people you add, plus admins and the Superadmin')}` },
116
+ { value: 'PRIVATE', label: `${bold('Only me')} ${dim('— you, people you add, and the Superadmin')}` },
117
+ ],
118
+ });
119
+ if (isCancel(visibilityChoice)) {
120
+ cancel('Cancelled.');
121
+ process.exit(0);
122
+ }
123
+ // Read by uploadRoutes — in this process and in the server this CLI starts (it inherits the environment)
124
+ process.env.APIFORGE_VISIBILITY = visibilityChoice;
109
125
  const isLocal = backendChoice === 'local';
110
126
  const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
111
127
  const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
@@ -666,7 +682,7 @@ async function microservicesFlow(config, opts) {
666
682
  cwd,
667
683
  stdio: ['ignore', 'pipe', 'pipe'],
668
684
  shell: true,
669
- env: { ...process.env },
685
+ env: withExpressMountHook({ ...process.env }),
670
686
  detached: false,
671
687
  });
672
688
  // Capture output so failures are debuggable without re-running the service by hand.
@@ -1647,6 +1663,26 @@ function suppressNodemonSelfRestart(cwd) {
1647
1663
  catch { }
1648
1664
  };
1649
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
+ }
1650
1686
  function startAndWaitForAnalysis(command, timeoutMs) {
1651
1687
  return new Promise(resolve => {
1652
1688
  const statusPath = join(process.cwd(), STATUS_FILE);
@@ -1654,7 +1690,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
1654
1690
  cwd: process.cwd(),
1655
1691
  stdio: ['ignore', 'pipe', 'pipe'],
1656
1692
  shell: true,
1657
- env: { ...process.env },
1693
+ env: withExpressMountHook({ ...process.env }),
1658
1694
  detached: false,
1659
1695
  });
1660
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) {