NestJS's RouterModule lets apps compose modular route prefixes across files
(`RouterModule.register([{ path: 'admin', module: AdminModule, children: [...] }])`
in `app.module.ts` sets the prefix for controllers declared in another file's
`@Controller()`). The per-file `extract()` only sees one file at a time, so a
`UsersController` indexed in isolation showed up as `GET /` instead of
`GET /admin/users`.
Add an optional cross-file `postExtract(context)` hook to FrameworkResolver,
called by the orchestrator once after each `indexAll` and after every
incremental `sync` that touched files. The nestjs implementation:
* walks every `*.module.{ts,js}` for `RouterModule.{register,forRoot,forChild}([...])`
and recursively resolves `children` into `Module → /full/prefix`,
* walks `@Module({ controllers: [...] })` for `Controller → Module`,
* matches each route node against its controller's class line range
(multi-controller files keep getting attributed correctly), and
* rewrites `name` while preserving `id` (route→handler edges intact) and
`qualifiedName` (still encodes the *original* in-file path, which keeps
the pass idempotent on a re-sync — `app.module.ts` edits propagate to
controllers in unchanged files without double-prefixing).
End-to-end validated against the exact reproduction in #459 (admin children
users) — all four routes (`GET /admin`, `GET /admin/users`,
`GET /admin/users/:id`, `POST /admin/users`) resolve correctly, edits to the
RouterModule tree re-propagate on the next sync, and route→handler edges in
`codegraph context` are preserved.
Closes #459
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
180ba785ce
commit
8876defbc1
@@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **NestJS: `RouterModule.register([...])` route prefixes now propagate to controller routes.** Previously a controller declared inside a module wired through NestJS's `RouterModule` (a common pattern for modular apps with nested route prefixes) was indexed with its raw `@Controller(...) + @Get(...)` path — so `UsersController` under `RouterModule.register([{ path: 'admin', module: AdminModule, children: [{ path: 'users', module: UsersModule }] }])` showed up as `GET /` instead of `GET /admin/users`. The new cross-file pass walks every `*.module.{ts,js}` for `RouterModule.register/forRoot/forChild([...])` (recursive `children`) and `@Module({ controllers: [...] })`, then prepends the correct prefix to each affected route — including non-empty `@Controller` paths and method-level params (`/admin/users/:id`). The route node's `id` is preserved across the update so existing route→handler edges stay intact, and the pass is idempotent so incremental sync recovers when `app.module.ts` itself is edited. Closes #459.
|
||||
|
||||
### Added
|
||||
- **Installer targets for Gemini CLI and the Antigravity IDE.** `codegraph install` (and the interactive prompt) now detect and configure two more agents out of the box:
|
||||
- **Gemini CLI** (also covers the rebranded Antigravity CLI) — writes `mcpServers.codegraph` to `~/.gemini/settings.json` (global) or `./.gemini/settings.json` (local), and the codegraph usage block into `~/.gemini/GEMINI.md` / `./GEMINI.md`. Existing top-level settings (e.g. `security.auth`) and sibling MCP servers are preserved.
|
||||
|
||||
@@ -528,6 +528,271 @@ describe('nestjsResolver.resolve', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('nestjsResolver.postExtract — RouterModule', () => {
|
||||
function mkClass(name: string, filePath: string, startLine: number, endLine: number): Node {
|
||||
return {
|
||||
id: `class:${filePath}:${startLine}:${name}`,
|
||||
kind: 'class',
|
||||
name,
|
||||
qualifiedName: `${filePath}::${name}`,
|
||||
filePath,
|
||||
language: 'typescript',
|
||||
startLine,
|
||||
endLine,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mkRoute(
|
||||
filePath: string,
|
||||
line: number,
|
||||
method: string,
|
||||
path: string,
|
||||
nameOverride?: string
|
||||
): Node {
|
||||
return {
|
||||
id: `route:${filePath}:${line}:${method}:${path}`,
|
||||
kind: 'route',
|
||||
name: nameOverride ?? `${method} ${path}`,
|
||||
qualifiedName: `${filePath}::${method}:${path}`,
|
||||
filePath,
|
||||
language: 'typescript',
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(opts: {
|
||||
files?: Record<string, string>;
|
||||
nodes?: Node[];
|
||||
}) {
|
||||
const files = opts.files ?? {};
|
||||
const all = opts.nodes ?? [];
|
||||
return {
|
||||
getNodesInFile: (fp: string) => all.filter((n) => n.filePath === fp),
|
||||
getNodesByName: (name: string) => all.filter((n) => n.name === name),
|
||||
getNodesByQualifiedName: () => [],
|
||||
getNodesByKind: (kind: Node['kind']) => all.filter((n) => n.kind === kind),
|
||||
fileExists: (fp: string) => files[fp] !== undefined,
|
||||
readFile: (fp: string) => files[fp] ?? null,
|
||||
getProjectRoot: () => '/test',
|
||||
getAllFiles: () => Object.keys(files),
|
||||
getNodesByLowerName: () => [],
|
||||
getImportMappings: () => [],
|
||||
} as any;
|
||||
}
|
||||
|
||||
it('prepends RouterModule prefix to a controller route (top-level register)', () => {
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
@Module({
|
||||
imports: [
|
||||
RouterModule.register([
|
||||
{ path: 'admin', module: AdminModule },
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@Module({ controllers: [AdminController] })
|
||||
export class AdminModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('AdminController', 'src/admin/admin.controller.ts', 1, 10),
|
||||
mkRoute('src/admin/admin.controller.ts', 3, 'GET', '/'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.name).toBe('GET /admin');
|
||||
// id and qualifiedName must be preserved so existing route→handler edges
|
||||
// stay intact and the pass remains idempotent on a second run.
|
||||
expect(updates[0]!.id).toBe('route:src/admin/admin.controller.ts:3:GET:/');
|
||||
expect(updates[0]!.qualifiedName).toBe('src/admin/admin.controller.ts::GET:/');
|
||||
});
|
||||
|
||||
it('resolves nested children — the issue #459 example', () => {
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
@Module({
|
||||
imports: [
|
||||
AdminModule,
|
||||
UsersModule,
|
||||
RouterModule.register([
|
||||
{
|
||||
path: 'admin',
|
||||
module: AdminModule,
|
||||
children: [
|
||||
{ path: 'users', module: UsersModule },
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
`,
|
||||
'src/users/users.module.ts': `
|
||||
@Module({ controllers: [UsersController] })
|
||||
export class UsersModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('UsersController', 'src/users/users.controller.ts', 1, 10),
|
||||
mkRoute('src/users/users.controller.ts', 3, 'GET', '/'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.name).toBe('GET /admin/users');
|
||||
});
|
||||
|
||||
it('joins module prefix with a non-empty @Controller path and method params', () => {
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
RouterModule.register([{ path: 'admin', module: UsersModule }])
|
||||
|
||||
@Module({ controllers: [UsersController] })
|
||||
export class UsersModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('UsersController', 'src/users.controller.ts', 1, 10),
|
||||
// Existing extract emitted GET /users/:id from @Controller('users') + @Get(':id')
|
||||
mkRoute('src/users.controller.ts', 3, 'GET', '/users/:id'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.name).toBe('GET /admin/users/:id');
|
||||
});
|
||||
|
||||
it('is idempotent — a second run returns no updates', () => {
|
||||
// Simulate the state after one round of postExtract: name is already
|
||||
// 'GET /admin', but qualifiedName still encodes the original 'GET:/'.
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
RouterModule.register([{ path: 'admin', module: UsersModule }])
|
||||
@Module({ controllers: [UsersController] })
|
||||
export class UsersModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('UsersController', 'src/users.controller.ts', 1, 10),
|
||||
mkRoute('src/users.controller.ts', 3, 'GET', '/', 'GET /admin'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op when the project does not use RouterModule', () => {
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
@Module({ controllers: [UsersController] })
|
||||
export class AppModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('UsersController', 'src/users.controller.ts', 1, 10),
|
||||
mkRoute('src/users.controller.ts', 3, 'GET', '/'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('attributes routes to the right controller when one file has two', () => {
|
||||
// Two controllers in one file, declared in two different modules with
|
||||
// two different module prefixes. The route's startLine has to match the
|
||||
// class scope, not just the file path.
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
RouterModule.register([
|
||||
{ path: 'p1', module: AModule },
|
||||
{ path: 'p2', module: BModule },
|
||||
])
|
||||
@Module({ controllers: [AController] }) export class AModule {}
|
||||
@Module({ controllers: [BController] }) export class BModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('AController', 'src/multi.controller.ts', 1, 5),
|
||||
mkClass('BController', 'src/multi.controller.ts', 7, 12),
|
||||
mkRoute('src/multi.controller.ts', 3, 'GET', '/a/x'),
|
||||
mkRoute('src/multi.controller.ts', 9, 'GET', '/b/y'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(2);
|
||||
const byId = new Map(updates.map((u) => [u.id, u.name]));
|
||||
expect(byId.get('route:src/multi.controller.ts:3:GET:/a/x')).toBe('GET /p1/a/x');
|
||||
expect(byId.get('route:src/multi.controller.ts:9:GET:/b/y')).toBe('GET /p2/b/y');
|
||||
});
|
||||
|
||||
it('merges RouterModule registrations spread across multiple module files', () => {
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
RouterModule.register([{ path: 'a', module: AModule }])
|
||||
@Module({ controllers: [AController] }) export class AModule {}
|
||||
`,
|
||||
'src/feature.module.ts': `
|
||||
RouterModule.forChild([{ path: 'b', module: BModule }])
|
||||
@Module({ controllers: [BController] }) export class BModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [
|
||||
mkClass('AController', 'src/a.controller.ts', 1, 5),
|
||||
mkClass('BController', 'src/b.controller.ts', 1, 5),
|
||||
mkRoute('src/a.controller.ts', 3, 'GET', '/'),
|
||||
mkRoute('src/b.controller.ts', 3, 'GET', '/'),
|
||||
],
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(2);
|
||||
const byId = new Map(updates.map((u) => [u.id, u.name]));
|
||||
expect(byId.get('route:src/a.controller.ts:3:GET:/')).toBe('GET /a');
|
||||
expect(byId.get('route:src/b.controller.ts:3:GET:/')).toBe('GET /b');
|
||||
});
|
||||
|
||||
it('silently skips controllers whose class node is not in the graph', () => {
|
||||
// RouterModule declares a prefix for a module, but the @Module that
|
||||
// would link it to a controller is missing — common during partial
|
||||
// re-extraction. Must not throw.
|
||||
const ctx = makeContext({
|
||||
files: {
|
||||
'src/app.module.ts': `
|
||||
RouterModule.register([{ path: 'orphans', module: GhostModule }])
|
||||
@Module({ controllers: [GhostController] }) export class GhostModule {}
|
||||
`,
|
||||
},
|
||||
nodes: [], // no class or route nodes
|
||||
});
|
||||
|
||||
const updates = nestjsResolver.postExtract!(ctx);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
import { laravelResolver } from '../src/resolution/frameworks/laravel';
|
||||
|
||||
describe('laravelResolver.extract', () => {
|
||||
|
||||
@@ -336,6 +336,9 @@ export class CodeGraph {
|
||||
// chance to see the actual project before resolution runs.
|
||||
if (result.success && result.filesIndexed > 0) {
|
||||
this.resolver.initialize();
|
||||
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
|
||||
// before resolution so updated names show up in subsequent reads.
|
||||
this.resolver.runPostExtract();
|
||||
}
|
||||
|
||||
// Resolve references to create call/import/extends edges
|
||||
@@ -406,6 +409,14 @@ export class CodeGraph {
|
||||
try {
|
||||
const result = await this.orchestrator.sync(options.onProgress);
|
||||
|
||||
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on
|
||||
// every sync that touched files so edits to `app.module.ts` propagate
|
||||
// to controllers in unchanged files. The pass is idempotent and cheap
|
||||
// (regex over *.module.ts only).
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
this.resolver.runPostExtract();
|
||||
}
|
||||
|
||||
// Resolve references if files were updated
|
||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||
if (result.changedFilePaths) {
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
} from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface — see comment at top of file. This file owns four NestJS
|
||||
// concerns: HTTP routes, GraphQL ops, microservice handlers, WebSocket
|
||||
// handlers, and (in postExtract below) cross-file RouterModule prefixing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type JsLang = 'typescript' | 'javascript';
|
||||
|
||||
const HTTP_METHODS = ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options', 'All'];
|
||||
@@ -185,6 +191,79 @@ export const nestjsResolver: FrameworkResolver = {
|
||||
|
||||
return { nodes, references };
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-file finalization for `RouterModule.register([...])`. The per-file
|
||||
* extract() above only sees `@Controller(prefix) + @Get(path)` — it can't
|
||||
* learn about the route prefix supplied by a sibling `app.module.ts` like:
|
||||
*
|
||||
* RouterModule.register([
|
||||
* { path: 'admin', module: AdminModule, children: [
|
||||
* { path: 'users', module: UsersModule } ] } ])
|
||||
*
|
||||
* This pass scans every `*.module.{ts,js}` file, walks the registration
|
||||
* tree to build a `Module → /full/prefix` map, walks each `@Module({
|
||||
* controllers: [...] })` to build a `Controller → Module` map, and rewrites
|
||||
* affected route nodes so `GET /` becomes `GET /admin/users` (and
|
||||
* `@Controller('foo') + @Get(':id')` under that same module becomes
|
||||
* `GET /admin/users/foo/:id`).
|
||||
*
|
||||
* The route node's `id` and `qualifiedName` are deliberately preserved
|
||||
* across the update: `id` because existing route→handler edges reference
|
||||
* it, `qualifiedName` because it still encodes the *original* in-file
|
||||
* `method:path` — which keeps this pass idempotent (a second run recovers
|
||||
* the same input regardless of how many times it has already prefixed).
|
||||
*/
|
||||
postExtract(context: ResolutionContext): Node[] {
|
||||
const moduleToPrefix = new Map<string, string>();
|
||||
const controllerToModule = new Map<string, string>();
|
||||
|
||||
for (const filePath of context.getAllFiles()) {
|
||||
if (!/\.module\.(m?[jt]s|cjs)$/.test(filePath)) continue;
|
||||
const content = context.readFile(filePath);
|
||||
if (!content) continue;
|
||||
const safe = stripCommentsForRegex(content, detectLanguage(filePath));
|
||||
collectRouterModuleRegistrations(safe, moduleToPrefix);
|
||||
collectModuleControllers(safe, controllerToModule);
|
||||
}
|
||||
|
||||
const controllerToPrefix = new Map<string, string>();
|
||||
for (const [controller, module] of controllerToModule) {
|
||||
const prefix = moduleToPrefix.get(module);
|
||||
// `''` and `'/'` are no-op prefixes; skip them so we don't run updates
|
||||
// that would set name to the value it already has.
|
||||
if (prefix && prefix !== '' && prefix !== '/') {
|
||||
controllerToPrefix.set(controller, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (controllerToPrefix.size === 0) return [];
|
||||
|
||||
const updates: Node[] = [];
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -436,3 +515,252 @@ function detectLanguage(filePath: string): JsLang {
|
||||
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'typescript';
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RouterModule + @Module walkers (used by postExtract above)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Walk every `RouterModule.register([...])` call (and the equivalent
|
||||
* `RouterModule.forRoot([...])` and `forChild([...])` aliases) and populate
|
||||
* `out` with `Module → /full/prefix`. Recursive `children` arrays inherit
|
||||
* their parent's prefix.
|
||||
*
|
||||
* First-write-wins: if the same module appears in two registrations we keep
|
||||
* the first prefix seen rather than overwriting. NestJS itself does the same.
|
||||
*/
|
||||
function collectRouterModuleRegistrations(safe: string, out: Map<string, string>): void {
|
||||
const re = /\bRouterModule\s*\.\s*(?:register|forRoot|forChild)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const openIndex = m.index + m[0].length - 1;
|
||||
const parsed = readArgs(safe, openIndex);
|
||||
if (!parsed) continue;
|
||||
const items = parseRoutesArray(parsed.args);
|
||||
walkRoutesTree(items, '', out);
|
||||
re.lastIndex = parsed.end;
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteItem {
|
||||
path: string;
|
||||
moduleName: string | null;
|
||||
children: RouteItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `[ {...}, {...} ]` argument list into a list of `RouteItem`s. The
|
||||
* args are expected to be an inline literal — references to a `const routes:
|
||||
* Routes = [...]` declared earlier in the file aren't followed (rare in
|
||||
* practice; the registration is usually inline).
|
||||
*/
|
||||
function parseRoutesArray(args: string): RouteItem[] {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed.startsWith('[')) return [];
|
||||
// Strip outer [ ... ] respecting balanced brackets.
|
||||
const close = matchingClose(trimmed, 0);
|
||||
if (close < 0) return [];
|
||||
return parseRouteObjects(trimmed.slice(1, close));
|
||||
}
|
||||
|
||||
function parseRouteObjects(s: string): RouteItem[] {
|
||||
const items: RouteItem[] = [];
|
||||
for (const obj of splitTopLevelObjects(s)) {
|
||||
const path = parseStringField(obj, 'path');
|
||||
const moduleName = parseIdentField(obj, 'module');
|
||||
const childrenStr = parseArrayField(obj, 'children');
|
||||
const children = childrenStr ? parseRouteObjects(childrenStr) : [];
|
||||
items.push({ path, moduleName, children });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function walkRoutesTree(
|
||||
items: RouteItem[],
|
||||
parentPrefix: string,
|
||||
out: Map<string, string>
|
||||
): void {
|
||||
for (const item of items) {
|
||||
const myPrefix = joinHttpPath(parentPrefix, item.path);
|
||||
if (item.moduleName && !out.has(item.moduleName)) {
|
||||
out.set(item.moduleName, myPrefix);
|
||||
}
|
||||
if (item.children.length > 0) {
|
||||
walkRoutesTree(item.children, myPrefix, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every `@Module(...)` decorator and populate `out` with
|
||||
* `Controller → enclosingModuleClassName`, based on the decorator's
|
||||
* `controllers: [...]` field and the class declaration that follows the
|
||||
* decorator (skipping stacked decorators and export/default/abstract
|
||||
* modifiers).
|
||||
*/
|
||||
function collectModuleControllers(safe: string, out: Map<string, string>): void {
|
||||
for (const hit of findDecorators(safe, ['Module'])) {
|
||||
const className = classNameAfter(safe, hit.end);
|
||||
if (!className) continue;
|
||||
for (const controller of parseControllersField(hit.args)) {
|
||||
// First-write-wins, same as RouterModule, so a controller listed in two
|
||||
// modules picks up the one declared earliest in source.
|
||||
if (!out.has(controller)) out.set(controller, className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseControllersField(args: string): string[] {
|
||||
const inner = parseArrayField(args, 'controllers');
|
||||
if (inner === null) return [];
|
||||
return inner
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /^[A-Za-z_$][\w$]*$/.test(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting just after a class decorator's `)`, return the name of the class
|
||||
* it decorates. Mirrors `methodNameAfter` for methods: skips stacked
|
||||
* decorators and `export`/`default`/`abstract` modifiers.
|
||||
*/
|
||||
function classNameAfter(safe: string, start: number): string | null {
|
||||
let i = start;
|
||||
const ws = /\s*/y;
|
||||
const decoName = /@[\w.]+/y;
|
||||
const classDecl = /(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/y;
|
||||
|
||||
const eatWs = (): void => {
|
||||
ws.lastIndex = i;
|
||||
if (ws.exec(safe)) i = ws.lastIndex;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
eatWs();
|
||||
if (safe[i] !== '@') break;
|
||||
decoName.lastIndex = i;
|
||||
if (!decoName.exec(safe)) break;
|
||||
i = decoName.lastIndex;
|
||||
eatWs();
|
||||
if (safe[i] === '(') {
|
||||
const parsed = readArgs(safe, i);
|
||||
if (!parsed) return null;
|
||||
i = parsed.end;
|
||||
}
|
||||
}
|
||||
|
||||
eatWs();
|
||||
classDecl.lastIndex = i;
|
||||
const m = classDecl.exec(safe);
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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];
|
||||
if (opener !== '[' && opener !== '{' && opener !== '(') return -1;
|
||||
let depth = 0;
|
||||
let inStr: string | null = null;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[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--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `s` into the contents of each top-level object literal. Brackets and
|
||||
* string literals are balanced so nested arrays/objects/strings inside an
|
||||
* object don't cause an early split.
|
||||
*/
|
||||
function splitTopLevelObjects(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let objStart = -1;
|
||||
let inStr: string | null = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i]!;
|
||||
if (inStr) {
|
||||
if (ch === '\\') { i++; continue; }
|
||||
if (ch === inStr) inStr = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
||||
if (depth === 0 && ch === '{') {
|
||||
depth = 1;
|
||||
objStart = i;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
||||
else if (ch === '}' || ch === ']' || ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0 && objStart >= 0 && ch === '}') {
|
||||
out.push(s.slice(objStart + 1, i));
|
||||
objStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a string-valued field — `key: 'value'` — out of one object literal's
|
||||
* body. Returns `''` if not present. The leading character class guards
|
||||
* against matching a field whose name *contains* the target as a suffix.
|
||||
*/
|
||||
function parseStringField(obj: string, name: string): string {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*['"\`]([^'"\`]*)['"\`]`);
|
||||
const m = obj.match(re);
|
||||
return m ? m[1]! : '';
|
||||
}
|
||||
|
||||
/** Read an identifier-valued field — `key: SomeIdent` — out of one object body. */
|
||||
function parseIdentField(obj: string, name: string): string | null {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*([A-Za-z_$][\\w$]*)`);
|
||||
const m = obj.match(re);
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
/** Read an array-valued field — `key: [ ... ]` — as the raw inner text. */
|
||||
function parseArrayField(obj: string, name: string): string | null {
|
||||
const re = new RegExp(`(?:^|[,{\\s])${name}\\s*:\\s*\\[`);
|
||||
const m = re.exec(obj);
|
||||
if (!m) return null;
|
||||
const open = m.index + m[0].length - 1;
|
||||
const close = matchingClose(obj, open);
|
||||
if (close < 0) return null;
|
||||
return obj.slice(open + 1, close);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,35 @@ export class ReferenceResolver {
|
||||
this.clearCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each framework resolver's cross-file finalization pass and persist
|
||||
* the returned node updates. Idempotent — safe to call after every indexAll
|
||||
* and every incremental sync. Returns the number of nodes updated.
|
||||
*
|
||||
* Caches are cleared before/after so the post-extract pass sees fresh DB
|
||||
* state and downstream queries see the updated names.
|
||||
*/
|
||||
runPostExtract(): number {
|
||||
let updated = 0;
|
||||
this.clearCaches();
|
||||
for (const fw of this.frameworks) {
|
||||
if (!fw.postExtract) continue;
|
||||
try {
|
||||
const nodes = fw.postExtract(this.context);
|
||||
for (const node of nodes) {
|
||||
this.queries.updateNode(node);
|
||||
updated++;
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`Framework '${fw.name}' postExtract failed`, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updated > 0) this.clearCaches();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-build lightweight caches for resolution.
|
||||
* Node lookups are now handled by indexed SQLite queries instead of
|
||||
|
||||
@@ -148,6 +148,20 @@ export interface FrameworkResolver {
|
||||
* pipeline; the framework's own `resolve()` is one of the strategies tried.
|
||||
*/
|
||||
extract?(filePath: string, content: string): FrameworkExtractionResult;
|
||||
/**
|
||||
* Cross-file finalization pass, called once after all per-file extraction
|
||||
* completes (and again on every incremental sync). Used by frameworks where
|
||||
* a symbol's final representation depends on a sibling file the per-file
|
||||
* `extract()` never saw — e.g. NestJS's `RouterModule.register([...])`
|
||||
* sets route prefixes for controllers declared elsewhere.
|
||||
*
|
||||
* Implementations return route/etc. nodes with mutated fields (typically
|
||||
* `name`); the orchestrator persists each via `updateNode`. The node `id`
|
||||
* MUST be preserved so existing edges (route → handler, etc.) stay intact;
|
||||
* `qualifiedName` SHOULD be preserved so the pass stays idempotent — a
|
||||
* second run can recover the original in-file form from `qualifiedName`.
|
||||
*/
|
||||
postExtract?(context: ResolutionContext): Node[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user