Bläddra i källkod

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>
Colby Mchenry 3 veckor sedan
förälder
incheckning
8876defbc1
6 ändrade filer med 650 tillägg och 0 borttagningar
  1. 3 0
      CHANGELOG.md
  2. 265 0
      __tests__/frameworks.test.ts
  3. 11 0
      src/index.ts
  4. 328 0
      src/resolution/frameworks/nestjs.ts
  5. 29 0
      src/resolution/index.ts
  6. 14 0
      src/resolution/types.ts

+ 3 - 0
CHANGELOG.md

@@ -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 - 0
__tests__/frameworks.test.ts

@@ -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 - 0
src/index.ts

@@ -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 - 0
src/resolution/frameworks/nestjs.ts

@@ -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);
+}

+ 29 - 0
src/resolution/index.ts

@@ -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 - 0
src/resolution/types.ts

@@ -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[];
 }
 
 /**