From d73f716ae15993224ae796cc3cc9300c7c170b27 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 20 May 2026 17:09:36 -0500 Subject: [PATCH] feat(frameworks): add NestJS support (#220) 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) --- CHANGELOG.md | 11 + README.md | 1 + __tests__/frameworks.test.ts | 298 +++++++++++++++++++ src/resolution/frameworks/index.ts | 3 + src/resolution/frameworks/nestjs.ts | 438 ++++++++++++++++++++++++++++ 5 files changed, 751 insertions(+) create mode 100644 src/resolution/frameworks/nestjs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a36bdb8..b661dfd5 100644 --- a/CHANGELOG.md +++ b/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 `\t`, matching the Read tool). This lets the agent cite `file:line` straight from the explore payload instead of diff --git a/README.md b/README.md index 27632d1d..e36fcf7f 100644 --- a/README.md +++ b/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 | diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 8eb33e2e..a5e5c56b 100644 --- a/__tests__/frameworks.test.ts +++ b/__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']); + }); }); diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index f50ea84a..188b5e48 100644 --- a/src/resolution/frameworks/index.ts +++ b/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'; diff --git a/src/resolution/frameworks/nestjs.ts b/src/resolution/frameworks/nestjs.ts new file mode 100644 index 00000000..3a8c1e9a --- /dev/null +++ b/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'; +}