fix(nestjs): routes carry the app's global prefix and URI version (#2117)

`app.setGlobalPrefix('api')` and `app.enableVersioning({ type:
VersioningType.URI, defaultVersion: '1' })` put every controller under
`/api/v1`, but routes were named by the controller path alone (`GET /user`).
A front end's `this.http.get('/api/v1/user')` then linked to nothing:
Ghostfolio's Angular client had no cross-tier edge into its own NestJS API.

postExtract now reads each `NestFactory.create` bootstrap and renames the
routes of controllers in that app, in NestJS's order:
`/{prefix unless excluded}/{v}{version}/{RouterModule prefix}/{path}`.

- `exclude` paths (plain, `{ path, method }`, `{/*wildcard}`, `(.*)`, `*`)
  skip the prefix and keep their version, as NestJS does.
- A route's version: its own `@Version` (found past decorators with object
  arguments), else the class's `@Version` or `@Controller({ version })`,
  else `defaultVersion`. `VERSION_NEUTRAL` has none; header/media-type
  versioning adds no segment.
- A computed prefix (`config.get(...)`) is not guessed.
- Each app uses its own bootstrap (the longest root above it). A seed script
  that creates the same app without serving it doesn't displace the one that
  configures it, and an app with no config doesn't borrow another app's.
- Only routes whose decorator sits in a `@Controller` are touched, so an
  Express route in the same repo keeps its name.

Ghostfolio: 0 → 103 cross-tier edges (82 of 85 client call sites), no
mismatches. koel, proshop, IceCubes and nest-samples are byte-identical to main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 03:17:35 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c3efbbf4e7
commit e845b1cd10
4 changed files with 481 additions and 40 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading.
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
- In an Angular app, a method a template calls, like `(click)="toggleFavorite()"` or `(ngSubmit)="submitForm()"`, is now linked from its component. These methods used to show no callers at all, so impact and callers questions stopped at them, and dead-code checks could list them as unused. Re-index Angular projects after upgrading.
+1 -1
View File
@@ -322,7 +322,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
| **Flask** | `@app.route('/path', methods=[...])`, blueprint routes |
| **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods |
| **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
| **NestJS** | `@Controller` + `@Get/@Post/...` (with `RouterModule` prefixes, `setGlobalPrefix` and URI versioning), GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
| **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
| **Drupal** | `*.routing.yml` routes (`_controller`, `_form`, entity handlers); `hook_*` implementations in `.module`/`.theme`/`.install`/`.inc` |
| **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
+217
View File
@@ -0,0 +1,217 @@
/**
* A NestJS route is named by the path a request takes to it — the app's
* global prefix and URI version included.
*
* `app.setGlobalPrefix('api')` and `app.enableVersioning({ type:
* VersioningType.URI, defaultVersion: '1' })` in `main.ts` put every
* controller under `/api/v1`, but the routes were named `GET /user`. A front
* end's `this.http.get('/api/v1/user')` then connected to nothing: Ghostfolio's
* Angular client had no link to its own NestJS API.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const projects: string[] = [];
afterAll(() => {
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
});
async function project(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nest-prefix-'));
projects.push(root);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
return CodeGraph.init(root, { index: true });
}
const MAIN = `import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI });
app.setGlobalPrefix('api', { exclude: ['sitemap.xml', 'health{/*wildcard}'] });
await app.listen(3333);
}
bootstrap();
`;
describe('NestJS: the global prefix and URI versioning', () => {
it('names each route under the prefix and its version — or neither, where the app says so', async () => {
const cg = await project({
'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }),
'apps/api/src/main.ts': MAIN,
// A seed script creates the same app without serving it — and is read first.
'apps/api/src/database/run-seed.ts': `import { NestFactory } from '@nestjs/core';
const runSeed = async () => {
const app = await NestFactory.create(SeedModule);
await app.close();
};
void runSeed();
`,
'apps/api/src/app/user.controller.ts': `import { Controller, Get, Post, Version, VERSION_NEUTRAL } from '@nestjs/common';
@Controller('user')
export class UserController {
@Get()
public getUser() {}
@Post()
@UseGuards(AuthGuard('jwt'))
@Version('2')
public createUser() {}
@Get('oidc/callback')
@Version(VERSION_NEUTRAL)
public callback() {}
}
`,
'apps/api/src/app/sitemap.controller.ts': `import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common';
@Controller()
@Version(VERSION_NEUTRAL)
export class SitemapController {
@Get('sitemap.xml')
public sitemap() {}
}
`,
'apps/api/src/app/health.controller.ts': `import { Controller, Get } from '@nestjs/common';
@Controller({ path: 'health', version: '3' })
export class HealthController {
@Get('db')
public db() {}
}
`,
// A second app that sets nothing: its routes stay as written.
'apps/jobs/src/main.ts': `import { NestFactory } from '@nestjs/core';
NestFactory.create(JobsModule).then((app) => app.listen(3335));
`,
'apps/jobs/src/jobs.controller.ts': `import { Controller, Get } from '@nestjs/common';
@Controller('jobs')
export class JobsController {
@Get()
public list() {}
}
`,
});
try {
expect(
cg
.getNodesByKind('route')
.map((r) => r.name)
.sort()
).toEqual([
// VERSION_NEUTRAL: prefixed, not versioned.
'GET /api/user/oidc/callback',
'GET /api/v1/user',
'GET /jobs',
'GET /sitemap.xml',
// Excluded from the prefix, still versioned by its controller.
'GET /v3/health/db',
'POST /api/v2/user',
]);
} finally {
cg.close();
}
});
it('reads a version past decorators with object arguments, and each app its own bootstrap', async () => {
const cg = await project({
'package.json': JSON.stringify({ dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0' } }),
'apps/api/src/main.ts': MAIN,
'apps/api/src/app/stats.controller.ts': `import { Controller, Get, Version } from '@nestjs/common';
@Controller('stats')
export class StatsController {
@Version('2')
@ApiResponse({ status: 200, description: 'The stats' })
@Get('daily')
public daily() {}
@Get('weekly')
public weekly() {}
}
`,
'apps/api/src/app/reports.controller.ts': `import { Controller, Get, Version } from '@nestjs/common';
@Version('4')
@ApiTags({ name: 'reports' })
@Controller('reports')
export class ReportsController {
@Get()
public list() {}
}
`,
// A second app whose prefix is read from config: no prefix is claimed for
// it, and the other app's `/api` is not borrowed.
'apps/admin/src/main.ts': `import { NestFactory } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AdminModule);
app.setGlobalPrefix(config.get('app.prefix'));
app.enableVersioning({ defaultVersion: '1' });
await app.listen(3334);
}
bootstrap();
`,
'apps/admin/src/users.controller.ts': `import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
public list() {}
}
`,
});
try {
expect(
cg
.getNodesByKind('route')
.map((r) => r.name)
.sort()
).toEqual(['GET /api/v1/stats/weekly', 'GET /api/v2/stats/daily', 'GET /api/v4/reports', 'GET /v1/users']);
} finally {
cg.close();
}
});
it("connects a front end's request to the route it reaches, and leaves an Express route in the same repo alone", async () => {
const cg = await project({
'package.json': JSON.stringify({
dependencies: { '@nestjs/core': '^11.0.0', '@nestjs/common': '^11.0.0', '@angular/core': '^19.0.0', express: '^4.0.0' },
}),
'apps/api/src/main.ts': MAIN,
'apps/api/src/app/user.controller.ts': `import { Controller, Get } from '@nestjs/common';
@Controller('user')
export class UserController {
@Get()
public getUser() {}
}
`,
'apps/client/src/app/user.service.ts': `import { HttpClient } from '@angular/common/http';
export class UserService {
constructor(private http: HttpClient) {}
public fetchUser() {
return this.http.get<User>('/api/v1/user');
}
}
`,
'tools/mock-server/src/server.ts': `import express from 'express';
const app = express();
app.get('/status', (req, res) => res.json({ ok: true }));
`,
});
try {
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
expect(names).toContain('GET /api/v1/user');
expect(names).toContain('GET /status');
const fetchUser = cg.getNodesByName('fetchUser')[0]!;
const reached = cg
.getOutgoingEdgesFrom([fetchUser.id], ['calls'])
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.channel === 'http')
.map((e) => cg.getNode(e.target)?.name);
expect(reached).toEqual(['GET /api/v1/user']);
} finally {
cg.close();
}
});
});
+262 -39
View File
@@ -208,14 +208,22 @@ export const nestjsResolver: FrameworkResolver = {
postExtract(context: ResolutionContext): Node[] {
const moduleToPrefix = new Map<string, string>();
const controllerToModule = new Map<string, string>();
const bootstraps: NestBootstrap[] = [];
for (const filePath of context.getAllFiles()) {
if (!/\.module\.(m?[jt]s|cjs)$/.test(filePath)) continue;
if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue;
const isModule = /\.module\.(m?[jt]s|cjs)$/.test(filePath);
if (!isModule && !(context.fileContains?.(filePath, 'NestFactory') ?? true)) continue;
const content = context.readFile(filePath);
if (!content) continue;
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
collectRouterModuleRegistrations(safe, moduleToPrefix);
collectModuleControllers(safe, controllerToModule);
if (isModule) {
collectRouterModuleRegistrations(safe, moduleToPrefix);
collectModuleControllers(safe, controllerToModule);
}
// Every app counts, even one that adds nothing: its routes must not
// borrow another app's prefix.
if (/\bNestFactory\s*\.\s*create/.test(safe)) bootstraps.push({ root: appRootOf(filePath), config: readBootstrap(safe) });
}
const controllerToPrefix = new Map<string, string>();
@@ -228,35 +236,243 @@ export const nestjsResolver: FrameworkResolver = {
}
}
if (controllerToPrefix.size === 0) return [];
const adds = bootstraps.some((b) => b.config.prefix !== '' || b.config.uri);
if (controllerToPrefix.size === 0 && !adds) return [];
const updates: Node[] = [];
// Route → what the app adds to it. A RouterModule prefix binds through the
// controller class's line range; the bootstrap's prefix and versioning
// only to a route its source shows is in a `@Controller` — an Express
// route has the same qualified-name shape.
const plan = new Map<string, { route: Node; modulePrefix: string; config: NestAppConfig | null; version: string | null | undefined }>();
for (const [controllerName, prefix] of controllerToPrefix) {
const classes = context
.getNodesByName(controllerName)
.filter((n) => n.kind === 'class');
for (const cls of classes) {
const routes = context
.getNodesInFile(cls.filePath)
.filter((n) => n.kind === 'route');
for (const cls of context.getNodesByName(controllerName).filter((n) => n.kind === 'class')) {
for (const route of context.getNodesInFile(cls.filePath)) {
if (route.kind !== 'route' || route.startLine < cls.startLine || route.startLine > cls.endLine) continue;
plan.set(route.id, { route, modulePrefix: prefix, config: null, version: undefined });
}
}
}
if (adds) {
for (const filePath of context.getAllFiles()) {
if (!/\.(m?[jt]s|cjs)$/.test(filePath)) continue;
if (!(context.fileContains?.(filePath, '@Controller') ?? true)) continue;
const config = bootstrapFor(bootstraps, filePath);
if (!config || (config.prefix === '' && !config.uri)) continue;
const routes = context.getNodesInFile(filePath).filter((n) => n.kind === 'route' && HTTP_ROUTE_QN.test(n.qualifiedName));
if (routes.length === 0) continue;
const content = context.readFile(filePath);
if (!content) continue;
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
const scopes = buildClassScopes(safe);
const hits = findDecorators(safe, HTTP_METHODS);
for (const route of routes) {
// Multiple controllers can live in one file (covered by the
// existing "attributes methods to the right controller" test);
// each route must be associated with the controller whose line
// range contains it.
if (route.startLine < cls.startLine || route.startLine > cls.endLine) {
continue;
}
const updated = applyModulePrefix(route, prefix);
if (updated && updated.name !== route.name) updates.push(updated);
const hit = hits.find((h) => lineAt(safe, h.index) === route.startLine);
const scope = hit ? scopeFor(scopes, hit.index) : null;
if (!hit || !scope || scope.kind !== 'controller') continue;
const planned = plan.get(route.id);
plan.set(route.id, { route, modulePrefix: planned?.modulePrefix ?? '', config, version: routeVersion(safe, hit, scope) });
}
}
}
const updates: Node[] = [];
for (const { route, modulePrefix, config, version } of plan.values()) {
const updated = applyAppConfig(route, modulePrefix, config, version);
if (updated && updated.name !== route.name) updates.push(updated);
}
return updates;
},
};
// ---------------------------------------------------------------------------
// The app's bootstrap: global prefix and URI versioning
// ---------------------------------------------------------------------------
/** A route's in-file shape in its qualified name: `file::GET:/users`. */
const HTTP_ROUTE_QN = /::(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ALL):/;
/** The version marker for `VERSION_NEUTRAL`: served without a version segment. */
const NEUTRAL = '\u0000neutral';
interface NestAppConfig {
/** `setGlobalPrefix('api')` — '' when none, or not a literal. */
prefix: string;
/** Paths the prefix skips: `exclude: ['health', { path: 'metrics', method }]`, without a leading `/`. */
excludes: string[];
/** URI versioning is on (`enableVersioning({ type: VersioningType.URI })`, the default type). */
uri: boolean;
/** `defaultVersion: '1'` — the version a route without its own is served at; null for none. */
defaultVersion: string | null;
/** The segment's prefix: `v` unless `prefix:` says otherwise (`false` is none). */
versionPrefix: string;
}
interface NestBootstrap {
root: string;
config: NestAppConfig;
}
/** The app a file belongs to: the directory above its `src/` (`apps/api/`), else the repository. */
function appRootOf(filePath: string): string {
const m = /^((?:[^/]+\/)*?)src\//.exec(filePath);
return m ? m[1]! : '';
}
/**
* The config of the app that holds `filePath`: the bootstrap with the longest
* root above it, else — for a file outside every app, like an Nx `libs/`
* controller — the app, when there is only one.
*/
function bootstrapFor(bootstraps: readonly NestBootstrap[], filePath: string): NestAppConfig | null {
let root: string | null = null;
for (const b of bootstraps) {
if (filePath.startsWith(b.root) && (root === null || b.root.length > root.length)) root = b.root;
}
if (root === null) {
if (new Set(bootstraps.map((b) => b.root)).size !== 1) return null;
root = bootstraps[0]!.root;
}
// A seed or migration script creates the same app without serving it: the
// bootstrap that configures the app is the one that serves it.
const here = bootstraps.filter((b) => b.root === root);
return (here.find((b) => b.config.prefix !== '' || b.config.uri) ?? here[0])?.config ?? null;
}
/** One version value: `'2'`, `['1', '2']` (the first), `VERSION_NEUTRAL`. */
function versionValue(text: string): string | null {
const t = text.trim();
if (/^VERSION_NEUTRAL\b/.test(t)) return NEUTRAL;
const lit = /^\[?\s*(['"`])([^'"`]*)\1/.exec(t);
return lit ? lit[2]! : null;
}
/** `setGlobalPrefix` and `enableVersioning` from the file that calls `NestFactory.create`. */
function readBootstrap(safe: string): NestAppConfig {
const config: NestAppConfig = { prefix: '', excludes: [], uri: false, defaultVersion: null, versionPrefix: 'v' };
const prefixCall = /\.setGlobalPrefix\s*\(/.exec(safe);
if (prefixCall) {
const open = prefixCall.index + prefixCall[0].length - 1;
const close = matchingClose(safe, open);
const args = close > open ? safe.slice(open + 1, close) : '';
const lit = /^\s*(['"`])([^'"`$]*)\1/.exec(args);
if (lit) {
config.prefix = lit[2]!.replace(/^\/+|\/+$/g, '');
const ex = /\bexclude\s*:\s*\[/.exec(args);
if (ex) {
const exOpen = ex.index + ex[0].length - 1;
const exClose = matchingClose(args, exOpen);
const list = exClose > exOpen ? args.slice(exOpen + 1, exClose) : '';
for (const item of splitTopLevel(list)) {
const text = item.trim();
const path = /^(['"])([^'"]*)\1$/.exec(text)?.[2] ?? /\bpath\s*:\s*(['"])([^'"]*)\1/.exec(text)?.[2];
if (path !== undefined) config.excludes.push(path.replace(/^\/+/, ''));
}
}
}
}
const versioning = /\.enableVersioning\s*\(/.exec(safe);
if (versioning) {
const open = versioning.index + versioning[0].length - 1;
const close = matchingClose(safe, open);
const args = close > open ? safe.slice(open + 1, close) : '';
const type = /\btype\s*:\s*VersioningType\s*\.\s*(\w+)/.exec(args)?.[1];
config.uri = type === undefined || type === 'URI';
const def = /\bdefaultVersion\s*:\s*/.exec(args);
if (def) config.defaultVersion = versionValue(args.slice(def.index + def[0].length));
const pre = /\bprefix\s*:\s*(false|(['"`])([^'"`]*)\2)/.exec(args);
if (pre) config.versionPrefix = pre[1] === 'false' ? '' : pre[3]!;
}
return config;
}
/** A route excluded from the global prefix: `health`, `health/(.*)`, `health{/*wildcard}`, `health/*`. */
function isExcluded(path: string, excludes: readonly string[]): boolean {
const route = path.replace(/^\/+/, '');
for (const pattern of excludes) {
const wild = /(?:\{\/?\*\w*\}|\/?\(\.\*\)|\/?\*\w*)$/.exec(pattern);
const base = wild ? pattern.slice(0, wild.index).replace(/\/+$/, '') : pattern.replace(/\/+$/, '');
if (route === base || (wild && route.startsWith(`${base}/`))) return true;
}
return false;
}
/**
* The version a route is served at: `@Version(…)` among its own decorators,
* else its controller's (`@Version(…)` on the class, `@Controller({ version })`).
* Undefined when neither says.
*/
function routeVersion(safe: string, hit: DecoratorHit, scope: ClassScope): string | null | undefined {
// The method's decorators: back through the ones stacked above this one…
const from = decoratorChainStart(safe, hit.index, scope.start);
// …and on to the handler itself — past the decorators between (`@UseGuards(…)`, `@Version('2')`).
const handler = methodNameAfter(safe, hit.end);
const at = handler ? new RegExp(String.raw`(?<![@\w$])${handler.replace(/\$/g, '\\$')}\s*\(`).exec(safe.slice(hit.end)) : null;
const to = at ? hit.end + at.index : hit.end;
const own = /@Version\s*\(([^)]*)\)/.exec(safe.slice(from, to));
if (own) return versionValue(own[1]!);
// The class's decorators, on either side of `@Controller`.
const cls = /\bclass\b/g;
cls.lastIndex = scope.start;
const head = safe.slice(decoratorChainStart(safe, scope.start, 0), cls.exec(safe)?.index ?? scope.start + 400);
const onClass = /@Version\s*\(([^)]*)\)/.exec(head);
if (onClass) return versionValue(onClass[1]!);
const inController = /@Controller\s*\(\s*\{[^)]*\bversion\s*:\s*([^,}]+)/.exec(head);
if (inController) return versionValue(inController[1]!);
return undefined;
}
/**
* Where the stack of decorators ending at `at` begins: back over each
* `@Name(…)` / `@Name` above it. A decorator's arguments may hold an object
* (`@ApiResponse({ status: 200 })`), so this steps over balanced parentheses
* rather than stopping at the first brace.
*/
function decoratorChainStart(safe: string, at: number, floor: number): number {
let start = at;
for (;;) {
let i = start;
while (i > floor && /\s/.test(safe[i - 1]!)) i--;
if (safe[i - 1] === ')') {
let depth = 0;
let j = i - 1;
for (; j >= floor; j--) {
if (safe[j] === ')') depth++;
else if (safe[j] === '(' && --depth === 0) break;
}
if (j < floor) return start;
i = j;
}
let name = i;
while (name > floor && /[\w$.]/.test(safe[name - 1]!)) name--;
if (name === i || safe[name - 1] !== '@') return start;
start = name - 1;
}
}
/**
* A route's name with everything the app adds to it, in NestJS's order:
* `/{globalPrefix}/{v}{version}/{RouterModule prefix}/{controller}/{method}`.
* Recomputed from the in-file `method:path` in the qualified name, so the pass
* is idempotent.
*/
function applyAppConfig(route: Node, modulePrefix: string, config: NestAppConfig | null, version: string | null | undefined): Node | null {
const sep = route.qualifiedName.indexOf('::');
if (sep < 0) return null;
const tail = route.qualifiedName.slice(sep + 2);
const colon = tail.indexOf(':');
if (colon < 0) return null;
const method = tail.slice(0, colon);
const inner = joinHttpPath(modulePrefix, tail.slice(colon + 1));
let path = inner;
if (config) {
const v = version === undefined ? config.defaultVersion : version;
const versionSeg = config.uri && v !== null && v !== NEUTRAL ? `${config.versionPrefix}${v}` : '';
const prefix = config.prefix && !isExcluded(inner, config.excludes) ? config.prefix : '';
path = joinHttpPath(joinHttpPath(prefix, versionSeg), inner);
}
return { ...route, name: `${method} ${path}`, updatedAt: Date.now() };
}
// ---------------------------------------------------------------------------
// Provider resolution conventions
// ---------------------------------------------------------------------------
@@ -646,29 +862,36 @@ function classNameAfter(safe: string, start: number): string | null {
return m ? m[1]! : null;
}
/**
* Recompute a route node's `name` by prepending `prefix` to the *original*
* in-file path. The original is recovered from `qualifiedName`, which the
* per-file extract emits as `${filePath}::${method}:${path}` and which this
* pass deliberately never mutates — that's what keeps the update idempotent.
*/
function applyModulePrefix(route: Node, prefix: string): Node | null {
const sep = '::';
const idx = route.qualifiedName.indexOf(sep);
if (idx < 0) return null;
const tail = route.qualifiedName.slice(idx + sep.length);
const colon = tail.indexOf(':');
if (colon < 0) return null;
const method = tail.slice(0, colon);
const original = tail.slice(colon + 1);
const newName = `${method} ${joinHttpPath(prefix, original)}`;
return { ...route, name: newName, updatedAt: Date.now() };
}
// ---------------------------------------------------------------------------
// Small string utilities (object/array literal splitters)
// ---------------------------------------------------------------------------
/** A list's comma-separated items at depth 0, strings and brackets stepped over. */
function splitTopLevel(list: string): string[] {
const out: string[] = [];
let depth = 0;
let inStr: string | null = null;
let start = 0;
for (let i = 0; i < list.length; i++) {
const ch = list[i]!;
if (inStr) {
if (ch === '\\') { i++; continue; }
if (ch === inStr) inStr = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
if (ch === '[' || ch === '{' || ch === '(') depth++;
else if (ch === ']' || ch === '}' || ch === ')') depth--;
else if (ch === ',' && depth === 0) {
out.push(list.slice(start, i));
start = i + 1;
}
}
out.push(list.slice(start));
return out.filter((item) => item.trim().length > 0);
}
/** Return the index of the bracket that closes the one at `open`, or -1. */
function matchingClose(s: string, open: number): number {
const opener = s[open];