fix(nestjs): propagate RouterModule.register prefixes to controller routes (#459) (#460)

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:
Colby Mchenry
2026-05-26 11:55:18 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 180ba785ce
commit 8876defbc1
6 changed files with 650 additions and 0 deletions
+3
View File
@@ -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.
+265
View File
@@ -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', () => {
+11
View File
@@ -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) {
+328
View File
@@ -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 routehandler 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);
}
+29
View File
@@ -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
+14
View File
@@ -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[];
}
/**