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]
|
## [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
|
### 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:
|
- **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.
|
- **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';
|
import { laravelResolver } from '../src/resolution/frameworks/laravel';
|
||||||
|
|
||||||
describe('laravelResolver.extract', () => {
|
describe('laravelResolver.extract', () => {
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ export class CodeGraph {
|
|||||||
// chance to see the actual project before resolution runs.
|
// chance to see the actual project before resolution runs.
|
||||||
if (result.success && result.filesIndexed > 0) {
|
if (result.success && result.filesIndexed > 0) {
|
||||||
this.resolver.initialize();
|
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
|
// Resolve references to create call/import/extends edges
|
||||||
@@ -406,6 +409,14 @@ export class CodeGraph {
|
|||||||
try {
|
try {
|
||||||
const result = await this.orchestrator.sync(options.onProgress);
|
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
|
// Resolve references if files were updated
|
||||||
if (result.filesAdded > 0 || result.filesModified > 0) {
|
if (result.filesAdded > 0 || result.filesModified > 0) {
|
||||||
if (result.changedFilePaths) {
|
if (result.changedFilePaths) {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { stripCommentsForRegex } from '../strip-comments';
|
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';
|
type JsLang = 'typescript' | 'javascript';
|
||||||
|
|
||||||
const HTTP_METHODS = ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options', 'All'];
|
const HTTP_METHODS = ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options', 'All'];
|
||||||
@@ -185,6 +191,79 @@ export const nestjsResolver: FrameworkResolver = {
|
|||||||
|
|
||||||
return { nodes, references };
|
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';
|
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'typescript';
|
||||||
return 'javascript';
|
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();
|
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.
|
* Pre-build lightweight caches for resolution.
|
||||||
* Node lookups are now handled by indexed SQLite queries instead of
|
* 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.
|
* pipeline; the framework's own `resolve()` is one of the strategies tried.
|
||||||
*/
|
*/
|
||||||
extract?(filePath: string, content: string): FrameworkExtractionResult;
|
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