Explorar el Código

feat(frameworks): add NestJS support (#220) (#225)

Detect NestJS projects and emit `route` nodes (each linked by a `references`
edge to its handler method) across all four transport layers:

- HTTP controllers: @Controller prefix joined with
  @Get/@Post/@Put/@Patch/@Delete/@Head/@Options/@All
- GraphQL resolvers: @Query/@Mutation/@Subscription
- Microservices: @MessagePattern/@EventPattern
- WebSocket gateways: @SubscribeMessage (prefixed with gateway namespace)

Detected from any @nestjs/* dependency in package.json (falls back to scanning
*.controller.ts/*.resolver.ts/*.gateway.ts). Handles class+method path joining
with empty @Controller()/@Get(), a string-aware balanced-paren arg reader so
GraphQL type thunks (@Query(() => [User])) aren't truncated, stacked decorators
(@UseGuards) when locating the handler, and disambiguates the @Query() GraphQL
method decorator from the REST @Query() param decorator (GraphQL only counts
inside @Resolver classes). Also resolves injected *Service/*Controller refs to
their classes by Nest file-naming convention.

Adds 18 framework tests; updates the README framework table and CHANGELOG.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby Mchenry hace 1 mes
padre
commit
948b287536
Se han modificado 5 ficheros con 751 adiciones y 0 borrados
  1. 11 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 298 0
      __tests__/frameworks.test.ts
  4. 3 0
      src/resolution/frameworks/index.ts
  5. 438 0
      src/resolution/frameworks/nestjs.ts

+ 11 - 0
CHANGELOG.md

@@ -10,6 +10,17 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 ## [Unreleased]
 
 ### Added
+- **Framework routes (NestJS)**: CodeGraph now recognises NestJS projects and
+  emits `route` nodes — each linked by a `references` edge to its handler
+  method — across all four transport layers: HTTP controllers (the
+  `@Controller` prefix joined with `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/
+  `@Head`/`@Options`/`@All`, including empty `@Controller()`/`@Get()`),
+  GraphQL resolvers (`@Query`/`@Mutation`/`@Subscription`), microservice
+  handlers (`@MessagePattern`/`@EventPattern`), and WebSocket gateways
+  (`@SubscribeMessage`, prefixed with the gateway namespace). Detected
+  automatically from any `@nestjs/*` dependency in `package.json`. Querying a
+  controller method or resolver now surfaces the route that binds it.
+  Resolves [#220](https://github.com/colbymchenry/codegraph/issues/220).
 - **MCP / explore**: `codegraph_explore` source sections now carry line
   numbers (cat -n style `<num>\t<code>`, matching the Read tool). This lets
   the agent cite `file:line` straight from the explore payload instead of

+ 1 - 0
README.md

@@ -123,6 +123,7 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by
 | **Flask** | `@app.route('/path', methods=[...])`, blueprint routes |
 | **FastAPI** | `@app.get(...)`, `@router.post(...)`, all standard methods |
 | **Express** | `app.get(...)`, `router.post(...)` with middleware chains |
+| **NestJS** | `@Controller` + `@Get/@Post/...`, GraphQL `@Resolver` + `@Query/@Mutation`, `@MessagePattern`/`@EventPattern`, `@SubscribeMessage` |
 | **Laravel** | `Route::get()`, `Route::resource()`, `Controller@action`, tuple syntax |
 | **Rails** | `get '/x', to: 'users#index'`, hash-rocket `=>` syntax |
 | **Spring** | `@GetMapping`, `@PostMapping`, `@RequestMapping` on methods |

+ 298 - 0
__tests__/frameworks.test.ts

@@ -175,6 +175,287 @@ describe('expressResolver.extract', () => {
   });
 });
 
+import { nestjsResolver } from '../src/resolution/frameworks/nestjs';
+
+describe('nestjsResolver.extract — HTTP', () => {
+  it('joins @Controller prefix with @Get and links the handler', () => {
+    const src = `
+@Controller('users')
+export class UsersController {
+  @Get()
+  findAll() { return []; }
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
+    expect(nodes).toHaveLength(1);
+    expect(nodes[0].kind).toBe('route');
+    expect(nodes[0].name).toBe('GET /users');
+    expect(references[0].referenceName).toBe('findAll');
+    expect(references[0].referenceKind).toBe('references');
+    expect(references[0].fromNodeId).toBe(nodes[0].id);
+  });
+
+  it('joins controller prefix with a method-level path param', () => {
+    const src = `
+@Controller('cats')
+export class CatsController {
+  @Get(':id')
+  findOne(@Param('id') id: string) { return id; }
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('cats.controller.ts', src);
+    expect(nodes[0].name).toBe('GET /cats/:id');
+    expect(references[0].referenceName).toBe('findOne');
+  });
+
+  it('handles an empty @Controller() and empty @Post()', () => {
+    const src = `
+@Controller()
+export class AppController {
+  @Post()
+  create() {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('app.controller.ts', src);
+    expect(nodes[0].name).toBe('POST /');
+    expect(references[0].referenceName).toBe('create');
+  });
+
+  it('covers HTTP verbs and skips intervening method decorators', () => {
+    const src = `
+@Controller('todos')
+export class TodosController {
+  @Put(':id')
+  @UseGuards(AuthGuard)
+  update(@Param('id') id: string) {}
+
+  @Delete(':id')
+  async remove(@Param('id') id: string) {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('todos.controller.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['PUT /todos/:id', 'DELETE /todos/:id']);
+    expect(references.map((r) => r.referenceName)).toEqual(['update', 'remove']);
+  });
+
+  it('attributes methods to the right controller when a file has two', () => {
+    const src = `
+@Controller('a')
+export class AController {
+  @Get('x')
+  ax() {}
+}
+
+@Controller('b')
+export class BController {
+  @Get('y')
+  by() {}
+}
+`;
+    const { nodes } = nestjsResolver.extract!('multi.controller.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /a/x', 'GET /b/y']);
+  });
+});
+
+describe('nestjsResolver.extract — GraphQL', () => {
+  it('emits QUERY/MUTATION nodes from a resolver, defaulting to the method name', () => {
+    const src = `
+@Resolver(() => User)
+export class UsersResolver {
+  @Query(() => [User])
+  users() { return []; }
+
+  @Mutation(() => User)
+  createUser(@Args('input') input: CreateUserInput) {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('users.resolver.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['QUERY users', 'MUTATION createUser']);
+    expect(references.map((r) => r.referenceName)).toEqual(['users', 'createUser']);
+  });
+
+  it('uses an explicit operation name when given', () => {
+    const src = `
+@Resolver()
+export class CatsResolver {
+  @Query(() => Cat, { name: 'cat' })
+  getCat() {}
+}
+`;
+    const { nodes } = nestjsResolver.extract!('cats.resolver.ts', src);
+    expect(nodes[0].name).toBe('QUERY cat');
+  });
+
+  it('does NOT treat the REST @Query() parameter decorator as a GraphQL op', () => {
+    const src = `
+@Controller('search')
+export class SearchController {
+  @Get()
+  search(@Query() query: SearchDto) { return query; }
+}
+`;
+    const { nodes } = nestjsResolver.extract!('search.controller.ts', src);
+    // Only the HTTP route — the @Query() param decorator must be ignored.
+    expect(nodes.map((n) => n.name)).toEqual(['GET /search']);
+  });
+});
+
+describe('nestjsResolver.extract — microservices & websockets', () => {
+  it('extracts @MessagePattern and @EventPattern handlers', () => {
+    const src = `
+@Controller()
+export class MathController {
+  @MessagePattern({ cmd: 'sum' })
+  accumulate(data: number[]) {}
+
+  @EventPattern('user.created')
+  handleUserCreated(data: any) {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('math.controller.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['MESSAGE sum', 'EVENT user.created']);
+    expect(references.map((r) => r.referenceName)).toEqual(['accumulate', 'handleUserCreated']);
+  });
+
+  it('extracts @SubscribeMessage handlers with the gateway namespace', () => {
+    const src = `
+@WebSocketGateway({ namespace: 'chat' })
+export class ChatGateway {
+  @SubscribeMessage('message')
+  handleMessage(@MessageBody() data: string) {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('chat.gateway.ts', src);
+    expect(nodes[0].name).toBe('WS chat:message');
+    expect(references[0].referenceName).toBe('handleMessage');
+  });
+
+  it('extracts @SubscribeMessage without a namespace', () => {
+    const src = `
+@WebSocketGateway()
+export class EventsGateway {
+  @SubscribeMessage('events')
+  onEvent() {}
+}
+`;
+    const { nodes } = nestjsResolver.extract!('events.gateway.ts', src);
+    expect(nodes[0].name).toBe('WS events');
+  });
+
+  it('returns empty for a non-JS/TS file', () => {
+    const { nodes, references } = nestjsResolver.extract!('thing.py', '@Controller("x")');
+    expect(nodes).toEqual([]);
+    expect(references).toEqual([]);
+  });
+});
+
+describe('nestjsResolver.detect', () => {
+  const baseContext = {
+    getNodesInFile: () => [],
+    getNodesByName: () => [],
+    getNodesByQualifiedName: () => [],
+    getNodesByKind: () => [],
+    fileExists: () => false,
+    getProjectRoot: () => '/test',
+    getAllFiles: () => [],
+    getNodesByLowerName: () => [],
+    getImportMappings: () => [],
+  };
+
+  it('detects @nestjs/* in package.json', () => {
+    const context = {
+      ...baseContext,
+      readFile: (p: string) =>
+        p === 'package.json'
+          ? JSON.stringify({ dependencies: { '@nestjs/common': '^10.0.0' } })
+          : null,
+    };
+    expect(nestjsResolver.detect(context as any)).toBe(true);
+  });
+
+  it('detects @Controller in a *.controller.ts file when package.json is absent', () => {
+    const context = {
+      ...baseContext,
+      getAllFiles: () => ['src/users.controller.ts'],
+      readFile: (p: string) =>
+        p === 'src/users.controller.ts'
+          ? `@Controller('users')\nexport class UsersController {}`
+          : null,
+    };
+    expect(nestjsResolver.detect(context as any)).toBe(true);
+  });
+
+  it('returns false for a non-Nest project', () => {
+    const context = {
+      ...baseContext,
+      readFile: (p: string) =>
+        p === 'package.json' ? JSON.stringify({ dependencies: { express: '^4' } }) : null,
+    };
+    expect(nestjsResolver.detect(context as any)).toBe(false);
+  });
+});
+
+describe('nestjsResolver.resolve', () => {
+  const baseContext = {
+    getNodesInFile: () => [],
+    getNodesByName: () => [],
+    getNodesByQualifiedName: () => [],
+    getNodesByKind: () => [],
+    fileExists: () => false,
+    readFile: () => null,
+    getProjectRoot: () => '/test',
+    getAllFiles: () => [],
+    getNodesByLowerName: () => [],
+    getImportMappings: () => [],
+  };
+
+  it('resolves an injected *Service reference to the class in a *.service.ts file', () => {
+    const svcNode: Node = {
+      id: 'class:src/users/users.service.ts:UsersService:3',
+      kind: 'class',
+      name: 'UsersService',
+      qualifiedName: 'src/users/users.service.ts::UsersService',
+      filePath: 'src/users/users.service.ts',
+      language: 'typescript',
+      startLine: 3,
+      endLine: 3,
+      startColumn: 0,
+      endColumn: 0,
+      updatedAt: Date.now(),
+    };
+    const context = {
+      ...baseContext,
+      getNodesByName: (n: string) => (n === 'UsersService' ? [svcNode] : []),
+    };
+    const ref = {
+      fromNodeId: 'class:src/users/users.controller.ts:UsersController:5',
+      referenceName: 'UsersService',
+      referenceKind: 'references' as const,
+      line: 6,
+      column: 4,
+      filePath: 'src/users/users.controller.ts',
+      language: 'typescript' as const,
+    };
+    const result = nestjsResolver.resolve(ref, context as any);
+    expect(result?.targetNodeId).toBe(svcNode.id);
+    expect(result?.resolvedBy).toBe('framework');
+    expect(result?.confidence).toBeGreaterThanOrEqual(0.85);
+  });
+
+  it('returns null for a name without a provider suffix', () => {
+    const ref = {
+      fromNodeId: 'x',
+      referenceName: 'doThing',
+      referenceKind: 'references' as const,
+      line: 1,
+      column: 1,
+      filePath: 'a.ts',
+      language: 'typescript' as const,
+    };
+    expect(nestjsResolver.resolve(ref, baseContext as any)).toBeNull();
+  });
+});
+
 import { laravelResolver } from '../src/resolution/frameworks/laravel';
 
 describe('laravelResolver.extract', () => {
@@ -768,4 +1049,21 @@ app.get("real", use: listUsers)
     expect(nodes.map((n) => n.name)).toEqual(['GET real']);
     expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
   });
+
+  it('nestjs: skips // and /* */ commented decorators', () => {
+    const src = `
+@Controller('users')
+export class UsersController {
+  // @Get('fake')
+  // fake() {}
+  /* @Post('also-fake')
+     alsoFake() {} */
+  @Get('real')
+  real() {}
+}
+`;
+    const { nodes, references } = nestjsResolver.extract!('users.controller.ts', src);
+    expect(nodes.map((n) => n.name)).toEqual(['GET /users/real']);
+    expect(references.map((r) => r.referenceName)).toEqual(['real']);
+  });
 });

+ 3 - 0
src/resolution/frameworks/index.ts

@@ -8,6 +8,7 @@ import { FrameworkResolver, ResolutionContext } from '../types';
 import type { Language } from '../../types';
 import { laravelResolver } from './laravel';
 import { expressResolver } from './express';
+import { nestjsResolver } from './nestjs';
 import { reactResolver } from './react';
 import { svelteResolver } from './svelte';
 import { vueResolver } from './vue';
@@ -27,6 +28,7 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   laravelResolver,
   // JavaScript/TypeScript
   expressResolver,
+  nestjsResolver,
   reactResolver,
   svelteResolver,
   vueResolver,
@@ -105,6 +107,7 @@ export function registerFrameworkResolver(resolver: FrameworkResolver): void {
 // Re-export framework resolvers
 export { laravelResolver, FACADE_MAPPINGS } from './laravel';
 export { expressResolver } from './express';
+export { nestjsResolver } from './nestjs';
 export { reactResolver } from './react';
 export { svelteResolver } from './svelte';
 export { vueResolver } from './vue';

+ 438 - 0
src/resolution/frameworks/nestjs.ts

@@ -0,0 +1,438 @@
+/**
+ * NestJS Framework Resolver
+ *
+ * Handles NestJS decorator-based routing across its transport layers:
+ *   - HTTP:          @Controller(prefix) + @Get/@Post/@Put/@Patch/@Delete/@Head/@Options/@All
+ *   - GraphQL:       @Resolver + @Query/@Mutation/@Subscription
+ *   - Microservices: @MessagePattern / @EventPattern
+ *   - WebSockets:    @WebSocketGateway(namespace) + @SubscribeMessage(event)
+ *
+ * Like the other framework extractors this is regex-over-source (comment-
+ * stripped), not AST traversal. NestJS differs from Spring/ASP.NET in two ways
+ * that this resolver has to account for:
+ *
+ *   1. An HTTP route's path is split across TWO decorators — the class-level
+ *      `@Controller` prefix and the method-level `@Get`/`@Post` path — and both
+ *      are frequently empty (`@Controller()`, `@Get()`). We pair each method
+ *      decorator with its enclosing class and join the two paths.
+ *
+ *   2. `@Query()` is overloaded: it's a GraphQL *method* decorator (from
+ *      `@nestjs/graphql`) AND a REST *parameter* decorator (from
+ *      `@nestjs/common`). We only treat it as GraphQL when it sits inside an
+ *      `@Resolver` class, which is what disambiguates the two.
+ */
+
+import { Node } from '../../types';
+import {
+  FrameworkResolver,
+  UnresolvedRef,
+  ResolvedRef,
+  ResolutionContext,
+} from '../types';
+import { stripCommentsForRegex } from '../strip-comments';
+
+type JsLang = 'typescript' | 'javascript';
+
+const HTTP_METHODS = ['Get', 'Post', 'Put', 'Patch', 'Delete', 'Head', 'Options', 'All'];
+const GQL_OPS = ['Query', 'Mutation', 'Subscription'];
+
+export const nestjsResolver: FrameworkResolver = {
+  name: 'nestjs',
+  languages: ['typescript', 'javascript'],
+
+  detect(context: ResolutionContext): boolean {
+    // Primary, fast path: any @nestjs/* dependency in package.json.
+    const packageJson = context.readFile('package.json');
+    if (packageJson) {
+      try {
+        const pkg = JSON.parse(packageJson);
+        const deps = { ...pkg.dependencies, ...pkg.devDependencies };
+        if (Object.keys(deps).some((k) => k.startsWith('@nestjs/'))) {
+          return true;
+        }
+      } catch {
+        // Invalid JSON — fall through to the source scan.
+      }
+    }
+
+    // Fallback: NestJS-specific decorators in conventionally named files.
+    const allFiles = context.getAllFiles();
+    for (const file of allFiles) {
+      if (
+        file.endsWith('.controller.ts') ||
+        file.endsWith('.controller.js') ||
+        file.endsWith('.module.ts') ||
+        file.endsWith('.resolver.ts') ||
+        file.endsWith('.gateway.ts')
+      ) {
+        const content = context.readFile(file);
+        if (
+          content &&
+          (content.includes('@nestjs/') ||
+            content.includes('@Controller') ||
+            content.includes('@Module(') ||
+            content.includes('@Resolver(') ||
+            content.includes('@WebSocketGateway('))
+        ) {
+          return true;
+        }
+      }
+    }
+
+    return false;
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // Resolve provider/controller references (e.g. constructor-injected
+    // `UsersService`) to their class, preferring the Nest file-name
+    // convention (`*.service.ts`, `*.controller.ts`, …).
+    for (const [suffix, convention] of PROVIDER_CONVENTIONS) {
+      if (!suffix.test(ref.referenceName)) continue;
+      const candidates = context
+        .getNodesByName(ref.referenceName)
+        .filter((n) => n.kind === 'class');
+      if (candidates.length === 0) return null;
+      const preferred = candidates.find((n) => n.filePath.includes(convention));
+      const target = preferred ?? candidates[0]!;
+      return {
+        original: ref,
+        targetNodeId: target.id,
+        confidence: preferred ? 0.85 : 0.7,
+        resolvedBy: 'framework',
+      };
+    }
+    return null;
+  },
+
+  extract(filePath, content) {
+    if (!/\.(m?js|tsx?|cjs)$/.test(filePath)) return { nodes: [], references: [] };
+    const nodes: Node[] = [];
+    const references: UnresolvedRef[] = [];
+    const now = Date.now();
+    const lang = detectLanguage(filePath);
+    const safe = stripCommentsForRegex(content, lang);
+
+    const addRoute = (
+      index: number,
+      method: string,
+      path: string,
+      length: number,
+      handler: string | null
+    ): void => {
+      const line = lineAt(safe, index);
+      const node: Node = {
+        id: `route:${filePath}:${line}:${method}:${path}`,
+        kind: 'route',
+        name: `${method} ${path}`,
+        qualifiedName: `${filePath}::${method}:${path}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: length,
+        language: lang,
+        updatedAt: now,
+      };
+      nodes.push(node);
+      if (handler) {
+        references.push({
+          fromNodeId: node.id,
+          referenceName: handler,
+          referenceKind: 'references',
+          line,
+          column: 0,
+          filePath,
+          language: lang,
+        });
+      }
+    };
+
+    const scopes = buildClassScopes(safe);
+
+    // HTTP routes: method decorator path joined onto the enclosing controller's prefix.
+    for (const hit of findDecorators(safe, HTTP_METHODS)) {
+      const scope = scopeFor(scopes, hit.index);
+      const prefix = scope && scope.kind === 'controller' ? scope.prefix : '';
+      const path = joinHttpPath(prefix, parseStringArg(hit.args));
+      addRoute(hit.index, hit.name.toUpperCase(), path, hit.length, methodNameAfter(safe, hit.end));
+    }
+
+    // GraphQL operations: only inside an @Resolver class (disambiguates the
+    // REST `@Query()` parameter decorator, which lives inside @Controller classes).
+    for (const hit of findDecorators(safe, GQL_OPS)) {
+      const scope = scopeFor(scopes, hit.index);
+      if (!scope || scope.kind !== 'resolver') continue;
+      const handler = methodNameAfter(safe, hit.end);
+      const name = parseGraphqlName(hit.args, handler);
+      addRoute(hit.index, hit.name.toUpperCase(), name, hit.length, handler);
+    }
+
+    // Microservice message/event handlers.
+    for (const hit of findDecorators(safe, ['MessagePattern', 'EventPattern'])) {
+      const verb = hit.name === 'EventPattern' ? 'EVENT' : 'MESSAGE';
+      const handler = methodNameAfter(safe, hit.end);
+      addRoute(hit.index, verb, parseStringArg(hit.args) || handler || '', hit.length, handler);
+    }
+
+    // WebSocket message handlers, prefixed with the gateway namespace when present.
+    for (const hit of findDecorators(safe, ['SubscribeMessage'])) {
+      const scope = scopeFor(scopes, hit.index);
+      const namespace = scope && scope.kind === 'gateway' ? scope.prefix : '';
+      const handler = methodNameAfter(safe, hit.end);
+      const event = parseStringArg(hit.args) || handler || '';
+      addRoute(hit.index, 'WS', namespace ? `${namespace}:${event}` : event, hit.length, handler);
+    }
+
+    return { nodes, references };
+  },
+};
+
+// ---------------------------------------------------------------------------
+// Provider resolution conventions
+// ---------------------------------------------------------------------------
+
+const PROVIDER_CONVENTIONS: Array<[RegExp, string]> = [
+  [/Service$/, '.service.'],
+  [/Controller$/, '.controller.'],
+  [/Resolver$/, '.resolver.'],
+  [/Gateway$/, '.gateway.'],
+  [/Repository$/, '.repository.'],
+  [/Guard$/, '.guard.'],
+  [/Interceptor$/, '.interceptor.'],
+  [/Pipe$/, '.pipe.'],
+  [/Module$/, '.module.'],
+];
+
+// ---------------------------------------------------------------------------
+// Decorator scanning
+// ---------------------------------------------------------------------------
+
+interface DecoratorHit {
+  /** Decorator name without the leading `@` (e.g. `Get`). */
+  name: string;
+  /** Raw text between the decorator's parentheses. */
+  args: string;
+  /** Index of the leading `@` in the (comment-stripped) source. */
+  index: number;
+  /** Index just past the decorator's closing `)`. */
+  end: number;
+  /** Character length of the whole `@Name(...)` decorator. */
+  length: number;
+}
+
+/**
+ * Find every `@Name(...)` decorator whose name is in `names`. Uses a
+ * string-aware balanced-paren reader for the argument list so type thunks
+ * like `@Query(() => [User])` are captured whole rather than truncated at the
+ * inner `()`.
+ */
+function findDecorators(safe: string, names: string[]): DecoratorHit[] {
+  const hits: DecoratorHit[] = [];
+  const re = new RegExp(`@(${names.join('|')})\\s*\\(`, 'g');
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(safe)) !== null) {
+    const openIndex = m.index + m[0].length - 1; // position of '('
+    const parsed = readArgs(safe, openIndex);
+    if (!parsed) continue;
+    hits.push({
+      name: m[1]!,
+      args: parsed.args,
+      index: m.index,
+      end: parsed.end,
+      length: parsed.end - m.index,
+    });
+    re.lastIndex = parsed.end; // resume past the args so nested text isn't re-scanned
+  }
+  return hits;
+}
+
+/**
+ * Read a balanced `(...)` starting at `openIndex` (which must point at `(`).
+ * String-aware, so parens inside string literals don't unbalance the count.
+ * Returns the inner text and the index just past the closing `)`.
+ */
+function readArgs(s: string, openIndex: number): { args: string; end: number } | null {
+  if (s[openIndex] !== '(') return null;
+  let depth = 0;
+  let inStr: string | null = null;
+  for (let i = openIndex; 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 === '(') depth++;
+    else if (ch === ')') {
+      depth--;
+      if (depth === 0) return { args: s.slice(openIndex + 1, i), end: i + 1 };
+    }
+  }
+  return null;
+}
+
+/**
+ * Starting just after a method decorator's `)`, return the name of the method
+ * it decorates. Skips any further stacked decorators (`@UseGuards(...)`,
+ * `@HttpCode(204)`, …) and access/async modifiers in between.
+ */
+function methodNameAfter(safe: string, start: number): string | null {
+  let i = start;
+  const ws = /\s*/y;
+  const decoName = /@[\w.]+/y;
+  const modifier = /(?:public|private|protected|async|static)\b/y;
+  const ident = /([A-Za-z_$][\w$]*)\s*\(/y;
+
+  const eatWs = (): void => {
+    ws.lastIndex = i;
+    if (ws.exec(safe)) i = ws.lastIndex;
+  };
+
+  // Skip stacked decorators.
+  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;
+    }
+  }
+
+  // Skip access/async/static modifiers.
+  for (;;) {
+    eatWs();
+    modifier.lastIndex = i;
+    if (modifier.exec(safe) && modifier.lastIndex > i) {
+      i = modifier.lastIndex;
+      continue;
+    }
+    break;
+  }
+
+  eatWs();
+  ident.lastIndex = i;
+  const m = ident.exec(safe);
+  return m ? m[1]! : null;
+}
+
+// ---------------------------------------------------------------------------
+// Class scopes (controller / resolver / gateway boundaries)
+// ---------------------------------------------------------------------------
+
+type ClassKind = 'controller' | 'resolver' | 'gateway' | 'other';
+
+interface ClassScope {
+  kind: ClassKind;
+  /** HTTP prefix (controller) or WS namespace (gateway); '' otherwise. */
+  prefix: string;
+  start: number;
+  end: number;
+}
+
+/**
+ * Build the list of class-level decorator scopes, sorted by position. Each
+ * scope runs from its decorator up to the next class decorator (of any kind),
+ * which lets a method decorator find its enclosing class regardless of how
+ * many classes share a file.
+ */
+function buildClassScopes(safe: string): ClassScope[] {
+  const defs: Array<{ kind: ClassKind; name: string; prefixOf: (a: string) => string }> = [
+    { kind: 'controller', name: 'Controller', prefixOf: parseControllerPrefix },
+    { kind: 'resolver', name: 'Resolver', prefixOf: () => '' },
+    { kind: 'gateway', name: 'WebSocketGateway', prefixOf: parseGatewayNamespace },
+    { kind: 'other', name: 'Injectable', prefixOf: () => '' },
+    { kind: 'other', name: 'Module', prefixOf: () => '' },
+    { kind: 'other', name: 'Catch', prefixOf: () => '' },
+  ];
+
+  const raw: Array<{ kind: ClassKind; prefix: string; index: number }> = [];
+  for (const def of defs) {
+    for (const hit of findDecorators(safe, [def.name])) {
+      raw.push({ kind: def.kind, prefix: def.prefixOf(hit.args), index: hit.index });
+    }
+  }
+  raw.sort((a, b) => a.index - b.index);
+
+  return raw.map((r, i) => ({
+    kind: r.kind,
+    prefix: r.prefix,
+    start: r.index,
+    end: i + 1 < raw.length ? raw[i + 1]!.index : safe.length,
+  }));
+}
+
+function scopeFor(scopes: ClassScope[], index: number): ClassScope | null {
+  for (const s of scopes) {
+    if (index >= s.start && index < s.end) return s;
+  }
+  return null;
+}
+
+// ---------------------------------------------------------------------------
+// Argument parsing
+// ---------------------------------------------------------------------------
+
+/** First string literal anywhere in the args, or '' (covers `'x'`, `{ k: 'x' }`). */
+function parseStringArg(args: string): string {
+  const m = args.match(/['"`]([^'"`]*)['"`]/);
+  return m ? m[1]! : '';
+}
+
+/** `@Controller('users')` | `@Controller({ path: 'users', host })` | `@Controller(['a','b'])` | `@Controller()`. */
+function parseControllerPrefix(args: string): string {
+  const obj = args.match(/path\s*:\s*['"`]([^'"`]*)['"`]/);
+  if (obj) return obj[1]!;
+  return parseStringArg(args);
+}
+
+/** `@WebSocketGateway({ namespace: 'chat' })` | `@WebSocketGateway(81, { namespace: '/chat' })` | `@WebSocketGateway()`. */
+function parseGatewayNamespace(args: string): string {
+  const m = args.match(/namespace\s*:\s*['"`]([^'"`]*)['"`]/);
+  return m ? m[1]! : '';
+}
+
+/**
+ * GraphQL operation name. Prefers an explicit `{ name: 'x' }` or a leading
+ * string literal (`@Query('users')`); otherwise the field name defaults to the
+ * handler method name. Avoids mistaking a `description` string for the name.
+ */
+function parseGraphqlName(args: string, handler: string | null): string {
+  const named = args.match(/name\s*:\s*['"`]([^'"`]*)['"`]/);
+  if (named) return named[1]!;
+  const lead = args.match(/^\s*['"`]([^'"`]*)['"`]/);
+  if (lead) return lead[1]!;
+  return handler ?? '';
+}
+
+// ---------------------------------------------------------------------------
+// Path helpers
+// ---------------------------------------------------------------------------
+
+/** Join a controller prefix and method path into a single normalised `/path`. */
+function joinHttpPath(prefix: string, sub: string): string {
+  const parts = [prefix, sub]
+    .map((p) => p.trim().replace(/^\/+|\/+$/g, ''))
+    .filter((p) => p.length > 0);
+  return '/' + parts.join('/');
+}
+
+function lineAt(safe: string, index: number): number {
+  return safe.slice(0, index).split('\n').length;
+}
+
+function detectLanguage(filePath: string): JsLang {
+  if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) return 'typescript';
+  return 'javascript';
+}