diff --git a/src/graph/__tests__/fixtures/nestjs-app.ts b/src/graph/__tests__/fixtures/nestjs-app.ts new file mode 100644 index 00000000..fd2174b6 --- /dev/null +++ b/src/graph/__tests__/fixtures/nestjs-app.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Post, Param, Delete, Version } from '@nestjs/common'; + +@Controller('users') +export class UsersController { + @Get() + async findAll() { + return []; + } + + @Get(':id') + findOne(@Param('id') id: string) { + return { id }; + } + + @Post(':id/posts') + @HttpCode(201) + createPost(@Param('id') id: string) { + return { id, post: true }; + } + + @Version('1') + @Get() + listV1() { + return ['v1']; + } + + @Version('2') + @Get() + listV2() { + return ['v2']; + } +} + +@Controller() +export class RootController { + @Get('health') + healthCheck() { + return 'ok'; + } + + @All() + fallback() { + return 'fallback'; + } +} + +// A commented-out route is not a route. +// @Get('legacy') +// legacyHandler() {} + +@Controller({ path: 'admin', version: '2' }) +export class AdminController { + @Delete(':id') + remove(@Param('id') id: string) { + return { id }; + } +} + +@Controller(ADMIN_PATH_CONSTANT) +export class UnreadableController { + @Get('probe') + probe() { + return null; + } +} diff --git a/src/graph/__tests__/resolver-nestjs.test.ts b/src/graph/__tests__/resolver-nestjs.test.ts new file mode 100644 index 00000000..1719cf25 --- /dev/null +++ b/src/graph/__tests__/resolver-nestjs.test.ts @@ -0,0 +1,208 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { nestjsResolver } from "../resolution/frameworks/nestjs.js"; +import type { GraphNode } from "../types.js"; +import type { ResolutionContext } from "../resolution/types.js"; + +const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "nestjs-app.ts"); +const source = readFileSync(fixturePath, "utf-8"); + +describe("NestJS framework resolver", () => { + it("detects NestJS via @nestjs/core or @nestjs/common dependencies", () => { + let context = fakeContext([], { "package.json": JSON.stringify({ dependencies: { "@nestjs/core": "^9.0.0" } }) }); + expect(nestjsResolver.detect(context)).toBe(true); + + context = fakeContext([], { "package.json": JSON.stringify({ devDependencies: { "@nestjs/common": "^9.0.0" } }) }); + expect(nestjsResolver.detect(context)).toBe(true); + + context = fakeContext([], { "package.json": JSON.stringify({ dependencies: { "express": "^4.17.1" } }) }); + expect(nestjsResolver.detect(context)).toBe(false); + }); + + it("extracts route nodes and function references for controllers", () => { + const result = nestjsResolver.extract!("src/nestjs-app.ts", source); + + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "GET /users" })); + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "GET /users/:id" })); + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "POST /users/:id/posts" })); + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "GET /health" })); + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "ALL /" })); + expect(result.nodes).toContainEqual(expect.objectContaining({ kind: "route", name: "DELETE /admin/:id" })); + + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "findAll", referenceKind: "function_ref" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "findOne", referenceKind: "function_ref" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "createPost", referenceKind: "function_ref" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "healthCheck", referenceKind: "function_ref" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "fallback", referenceKind: "function_ref" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "remove", referenceKind: "function_ref" })); + + // NestJS versioning declares three @Get() routes with the same name; the + // handler in the signature keeps their node ids distinct (#102 review). + const usersRoutes = result.nodes.filter((n) => n.name === "GET /users"); + expect(usersRoutes).toHaveLength(3); + expect(new Set(usersRoutes.map((n) => n.id)).size).toBe(3); + expect(usersRoutes.map((n) => n.signature).sort()).toEqual([ + "GET /users -> findAll", + "GET /users -> listV1", + "GET /users -> listV2", + ]); + }); + + it("keeps commented-out decorators out of the route table", () => { + const result = nestjsResolver.extract!("src/nestjs-app.ts", source); + expect(result.nodes.some((n) => n.name.includes("legacy"))).toBe(false); + expect(result.references.some((r) => r.referenceName === "legacyHandler")).toBe(false); + // The unreadable controller's routes are skipped rather than guessed. + expect(result.nodes.some((n) => n.name.endsWith("/probe"))).toBe(false); + }); + + it("binds the extracted route reference to its same-file handler", () => { + const findAllHandler = node("method:findAll", "findAll"); + const healthCheckHandler = node("method:healthCheck", "healthCheck"); + const context = fakeContext([findAllHandler, healthCheckHandler]); + + const result = nestjsResolver.extract!("src/nestjs-app.ts", source); + const findAllRef = result.references.find(r => r.referenceName === "findAll")!; + const healthCheckRef = result.references.find(r => r.referenceName === "healthCheck")!; + + expect(nestjsResolver.resolve(findAllRef, context)).toMatchObject({ + targetNodeId: findAllHandler.id, + confidence: 0.8, + resolvedBy: "nestjs-route-handler", + }); + + expect(nestjsResolver.resolve(healthCheckRef, context)).toMatchObject({ + targetNodeId: healthCheckHandler.id, + confidence: 0.8, + resolvedBy: "nestjs-route-handler", + }); + }); + + it("resolves two same-named handlers across controllers via the owning class", () => { + const usersFindAll = node("method:users-findAll", "findAll", "UsersController::findAll", "src/two-controllers.ts"); + const adminFindAll = node("method:admin-findAll", "findAll", "AdminController::findAll", "src/two-controllers.ts"); + const context = fakeContext([usersFindAll, adminFindAll]); + + const custom = [ + "@Controller('users')", + "export class UsersController {", + " @Get()", + " findAll() {}", + "}", + "", + "@Controller('admin')", + "export class AdminController {", + " @Get()", + " findAll() {}", + "}", + "", + ].join("\n"); + const result = nestjsResolver.extract!("src/two-controllers.ts", custom); + expect(result.references).toHaveLength(2); + + const usersRef = result.references[0]!; + expect(usersRef.candidates).toEqual(["UsersController::findAll"]); + expect(nestjsResolver.resolve(usersRef, context)).toMatchObject({ + targetNodeId: usersFindAll.id, + resolvedBy: "nestjs-route-handler", + }); + + const adminRef = result.references[1]!; + expect(nestjsResolver.resolve(adminRef, context)).toMatchObject({ + targetNodeId: adminFindAll.id, + }); + }); + + it("reads the object-form controller path and skips unreadable ones", () => { + const custom = [ + "@Controller({ path: 'users', version: '1' })", + "export class UsersController {", + " @Get(':id')", + " findOne() {}", + "}", + "", + "@Controller(ADMIN_PATH)", + "export class UnreadableController {", + " @Get('probe')", + " probe() {}", + "}", + "", + "@Controller('users')", + "export class SecondUsersController {", + " @Get('again')", + " again() {}", + "}", + "", + ].join("\n"); + const result = nestjsResolver.extract!("src/forms.ts", custom); + + // Object form with a `path` property contributes its prefix. + expect(result.nodes).toContainEqual(expect.objectContaining({ name: "GET /users/:id" })); + // A constant argument is not statically readable — its routes are skipped… + expect(result.nodes.some((n) => n.name.endsWith("/probe"))).toBe(false); + // …and a later controller resets the prefix instead of inheriting it. + expect(result.nodes).toContainEqual(expect.objectContaining({ name: "GET /users/again" })); + }); + + it("does not let a string with a parenthesis break decorator scanning", () => { + const custom = [ + "@Controller('users')", + "export class UsersController {", + " @ApiOperation({ summary: 'List users :)' })", + " @Get()", + " findAll() {}", + "}", + "", + ].join("\n"); + const result = nestjsResolver.extract!("src/smiley.ts", custom); + expect(result.nodes).toContainEqual(expect.objectContaining({ name: "GET /users" })); + expect(result.references).toContainEqual(expect.objectContaining({ referenceName: "findAll" })); + }); + + it("leaves ambiguous references unresolved unless the owning class disambiguates", () => { + const handler1 = node("method:1", "duplicateMethod"); + const handler2 = { ...node("method:2", "duplicateMethod"), startLine: 10 }; + const context = fakeContext([handler1, handler2]); + + const result = nestjsResolver.extract!("src/nestjs-app.ts", source); + const fakeRef = { ...result.references[0]!, referenceName: "duplicateMethod" }; + expect(nestjsResolver.resolve(fakeRef, context)).toBeNull(); + + // With the owning class recorded, the same ambiguity resolves. + const owners = [ + { ...node("method:1", "duplicateMethod"), qualifiedName: "UsersController::duplicateMethod" }, + { ...node("method:2", "duplicateMethod"), qualifiedName: "AdminController::duplicateMethod" }, + ]; + const owningContext = fakeContext(owners); + const usersScoped = { ...fakeRef, candidates: ["UsersController::duplicateMethod"] }; + expect(nestjsResolver.resolve(usersScoped, owningContext)).toMatchObject({ targetNodeId: owners[0]!.id }); + }); + + it("leaves missing handlers unresolved", () => { + const context = fakeContext([]); + const result = nestjsResolver.extract!("src/nestjs-app.ts", source); + const fakeRef = { ...result.references[0]!, referenceName: "missingMethod" }; + expect(nestjsResolver.resolve(fakeRef, context)).toBeNull(); + }); +}); + +function node(id: string, name: string, qualifiedName = name, filePath = "src/nestjs-app.ts"): GraphNode { + return { id, kind: "method", name, qualifiedName, filePath, + language: "typescript", startLine: 1, endLine: 2, startColumn: 0, endColumn: 0, updatedAt: 0 }; +} + +function fakeContext(nodes: GraphNode[], files: Record = {}): ResolutionContext { + return { + getNodesInFile: (path) => nodes.filter((entry) => entry.filePath === path), + getNodesByName: (name) => nodes.filter((entry) => entry.name === name), + getNodesByQualifiedName: (name) => nodes.filter((entry) => entry.qualifiedName === name), + getNodesByKind: (kind) => nodes.filter((entry) => entry.kind === kind), + getNodeById: (id) => nodes.find((entry) => entry.id === id) ?? null, + fileExists: (path) => path in files, + readFile: (path) => files[path] ?? null, + getProjectRoot: () => "/repo", + getAllFiles: () => Object.keys(files), + }; +} diff --git a/src/graph/resolution/frameworks/index.ts b/src/graph/resolution/frameworks/index.ts index 2949603e..3d5f5d84 100644 --- a/src/graph/resolution/frameworks/index.ts +++ b/src/graph/resolution/frameworks/index.ts @@ -1,6 +1,8 @@ import { expressResolver } from "./express.js"; +import { nestjsResolver } from "./nestjs.js"; import type { FrameworkResolver } from "../types.js"; /** Reference registry. Community resolvers add one entry here. */ -export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver]; +export const FRAMEWORK_RESOLVERS: readonly FrameworkResolver[] = [expressResolver, nestjsResolver]; export { expressResolver } from "./express.js"; +export { nestjsResolver } from "./nestjs.js"; diff --git a/src/graph/resolution/frameworks/nestjs.ts b/src/graph/resolution/frameworks/nestjs.ts new file mode 100644 index 00000000..bda52543 --- /dev/null +++ b/src/graph/resolution/frameworks/nestjs.ts @@ -0,0 +1,306 @@ +import { canonicalNodeIdentity, generateNodeId } from "../../extraction/node-id.js"; +import type { GraphNode, Language } from "../../types.js"; +import type { FrameworkExtractionResult, FrameworkResolver, ResolvedRef, UnresolvedRef } from "../types.js"; + +const CONTROLLER_DECORATOR = /^@Controller\b/; +const HTTP_DECORATOR = /^@(Get|Post|Put|Patch|Delete|Options|Head|All)\b/; +const CLASS_DECLARATION = /^(?:export\s+)?(?:abstract\s+)?(?:declare\s+)?class\s+([A-Za-z_$][\w$]*)/; +const STRING_LITERAL_ARG = /^(["'`])([^"'\\]*)\1/; +const OBJECT_PATH_ARG = /(?:^|[,{]\s*)path\s*:\s*(["'])([^"'\\]*)\1/; + +export const nestjsResolver: FrameworkResolver = { + name: "nestjs", + languages: ["typescript", "javascript"], + detect(context) { + const pkg = context.readFile("package.json"); + if (!pkg) return false; + try { + const parsed = JSON.parse(pkg) as { dependencies?: Record; devDependencies?: Record }; + return Boolean( + parsed.dependencies?.["@nestjs/core"] ?? + parsed.dependencies?.["@nestjs/common"] ?? + parsed.devDependencies?.["@nestjs/core"] ?? + parsed.devDependencies?.["@nestjs/common"] + ); + } catch { return false; } + }, + claimsReference: (name) => /^[A-Za-z_$][\w$]*$/.test(name), + extract(filePath, content): FrameworkExtractionResult { + const language = languageFor(filePath); + if (!language) return { nodes: [], references: [] }; + + const nodes: GraphNode[] = []; + const references: UnresolvedRef[] = []; + + // Comments are blanked (with spaces, so offsets and line numbers survive) + // before scanning: a commented-out `// @Get('legacy')` or a handler left + // inside a block comment is not a route, and reading raw text invented + // phantom routes pointing at real handlers (#102 review). + const blanked = blankComments(content); + const lines = blanked.split("\n"); + + // Controller state binds to the NEXT class declaration, so two controllers + // in one file each keep their own prefix and neither leaks into the other. + let pendingController: { prefix: string; skip: boolean } | null = null; + let activeController: { prefix: string; skip: boolean } | null = null; + let currentClass: string | null = null; + const occurrences = new Map(); + + let lineStart = 0; + for (const line of lines) { + const trimmed = line.trim(); + + if (CONTROLLER_DECORATOR.test(trimmed)) { + pendingController = parseControllerArgs(trimmed); + lineStart += line.length + 1; + continue; + } + + const classMatch = CLASS_DECLARATION.exec(trimmed); + if (classMatch) { + currentClass = classMatch[1]!; + activeController = pendingController; + pendingController = null; + lineStart += line.length + 1; + continue; + } + + const decoratorMatch = HTTP_DECORATOR.exec(trimmed); + if (decoratorMatch && activeController && !activeController.skip) { + const method = decoratorMatch[1]!.toUpperCase(); + const handlerName = findHandlerName(blanked, lineStart + line.length); + if (handlerName) { + // An empty argument list is a route with no path; an argument that + // is not a string literal (an array, a constant) cannot be read + // statically — no route beats a wrong route. + const args = extractBalancedArgs(trimmed, trimmed.indexOf("(")); + const trimmedArgs = args === null ? null : args.trim(); + const rawPath: string | null = trimmedArgs === null + ? null + : trimmedArgs === "" ? "" : firstStringArgument(args!); + if (rawPath !== null) { + const routeName = `${method} ${normalizeRoutePath(activeController.prefix, rawPath)}`; + const handler = handlerName; + const signature = `${routeName} -> ${handler}`; + // Two routes can share a name in one file (NestJS versioning: + // @Version('1') @Get() findAllV1 / @Version('2') @Get() + // findAllV2). The handler in the signature distinguishes most; + // the ordinal in the role covers the rest, mirroring Express. + const ordinal = occurrences.get(routeName) ?? 0; + occurrences.set(routeName, ordinal + 1); + const role = `nestjs-route:${ordinal}`; + const id = generateNodeId(filePath, "route", routeName, routeName, role, signature); + nodes.push({ + id, + identityKey: canonicalNodeIdentity(filePath, "route", routeName, role, signature), + kind: "route", + name: routeName, + qualifiedName: routeName, + filePath, + language, + startLine: blanked.slice(0, lineStart).split("\n").length, + endLine: blanked.slice(0, lineStart).split("\n").length, + startColumn: 0, + endColumn: line.length, + signature, + isExported: false, + updatedAt: 0, + }); + references.push({ + fromNodeId: id, + referenceName: handler, + referenceKind: "function_ref", + filePath, + language, + line: blanked.slice(0, lineStart).split("\n").length - 1, + column: 0, + // The TypeScript extractor names methods `Class::method`; + // carrying the owning controller lets resolution distinguish + // two same-named handlers across controllers in one file. + ...(currentClass ? { candidates: [`${currentClass}::${handler}`] } : {}), + }); + } + } + } + + lineStart += line.length + 1; + } + + return { nodes, references }; + }, + resolve(ref, context): ResolvedRef | null { + if (ref.referenceKind !== "function_ref") return null; + const sameFile = context.getNodesInFile(ref.filePath) + .filter((node) => (node.kind === "method" || node.kind === "function") && node.name === ref.referenceName); + // NestJS handlers live in the same file as the controller. A unique + // same-file declaration binds; when several share the name (two + // controllers in one file), the owning class recorded at extraction + // picks the one the route was declared on. + let target = sameFile.length === 1 ? sameFile[0] : null; + if (!target && sameFile.length > 1 && ref.candidates?.length) { + const qualified = sameFile.filter((node) => ref.candidates!.includes(node.qualifiedName)); + if (qualified.length === 1) target = qualified[0]!; + } + return target + ? { original: ref, targetNodeId: target.id, confidence: 0.8, resolvedBy: "nestjs-route-handler" } + : null; + }, +}; + +function languageFor(filePath: string): Language | null { + if (/\.(ts|mts|cts)$/.test(filePath)) return "typescript"; + if (/\.tsx$/.test(filePath)) return "tsx"; + if (/\.(js|mjs|cjs)$/.test(filePath)) return "javascript"; + if (/\.jsx$/.test(filePath)) return "jsx"; + return null; +} + +/** + * Blank comments out of the source, replacing every comment character with a + * space and preserving all newlines, so scanning sees comment-free code while + * every offset, line number, and column stays valid against the original. + * String literals are preserved verbatim — decorator arguments live in them. + */ +function blankComments(content: string): string { + const out: string[] = []; + let state: "code" | "line" | "block" | "string" = "code"; + let quote = ""; + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + const next = content[i + 1]; + if (state === "code") { + if (ch === "/" && next === "/") { state = "line"; out.push(" "); i++; continue; } + if (ch === "/" && next === "*") { state = "block"; out.push(" "); i++; continue; } + if (ch === "\"" || ch === "'" || ch === "`") { state = "string"; quote = ch; out.push(ch); continue; } + out.push(ch); + continue; + } + if (state === "line") { + if (ch === "\n") { state = "code"; out.push("\n"); } else out.push(" "); + continue; + } + if (state === "block") { + if (ch === "*" && next === "/") { state = "code"; out.push(" "); i++; continue; } + out.push(ch === "\n" ? "\n" : " "); + continue; + } + // Inside a string: escape sequences cannot close it. + if (ch === quote && content[i - 1] !== "\\") state = "code"; + out.push(ch); + } + return out.join(""); +} + +/** + * Read `@Controller(...)` arguments into a prefix, or a skip when the argument + * cannot be read statically. Every form resets whatever the previous + * controller left behind — a `@Controller` without a readable path must not + * inherit one (#102 review). + */ +function parseControllerArgs(line: string): { prefix: string; skip: boolean } { + const open = line.indexOf("("); + if (open < 0) return { prefix: "", skip: false }; + const args = extractBalancedArgs(line, open); + if (args === null) return { prefix: "", skip: true }; + const trimmedArgs = args.trim(); + if (trimmedArgs === "") return { prefix: "", skip: false }; + + const literal = STRING_LITERAL_ARG.exec(trimmedArgs); + if (literal) return { prefix: literal[2]!, skip: false }; + + if (trimmedArgs.startsWith("{")) { + const objectPath = OBJECT_PATH_ARG.exec(trimmedArgs); + return { prefix: objectPath ? objectPath[2]! : "", skip: false }; + } + + // A constant identifier or an array of paths is not statically readable + // here; skip this controller's routes rather than emit a wrong prefix. + return { prefix: "", skip: true }; +} + +/** The first positional string-literal argument, or null when absent. */ +function firstStringArgument(argsText: string): string | null { + const match = STRING_LITERAL_ARG.exec(argsText.trim()); + return match ? match[2]! : null; +} + +/** `GET /users/:id` from the controller prefix and the method's path. */ +function normalizeRoutePath(prefix: string, methodPath: string): string { + let fullPath = prefix; + if (fullPath && !fullPath.startsWith("/")) fullPath = "/" + fullPath; + let subPath = methodPath; + if (subPath && !subPath.startsWith("/")) subPath = "/" + subPath; + if (subPath === "/") subPath = ""; + fullPath += subPath; + if (!fullPath) fullPath = "/"; + else if (fullPath.length > 1 && fullPath.endsWith("/")) fullPath = fullPath.slice(0, -1); + return fullPath; +} + +/** + * Extract the argument text of the call whose `(` sits at `open`, skipping + * over string literals so a `)` inside `'List users :)'` does not close it. + * Returns null when the call does not close on this line. + */ +function extractBalancedArgs(line: string, open: number): string | null { + let depth = 0; + let quote: string | null = null; + for (let i = open; i < line.length; i++) { + const ch = line[i]!; + if (quote) { + if (ch === quote && line[i - 1] !== "\\") quote = null; + continue; + } + if (ch === "\"" || ch === "'" || ch === "`") { quote = ch; continue; } + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) return line.slice(open + 1, i); + } + } + return null; +} + +/** + * Forward-scan from just past a decorator line for the handler name: skip + * whitespace, further decorators (string-aware, so `@ApiOperation({ + * summary: 'List users :)' })` no longer swallows the route), and modifier + * keywords, then read the identifier that opens a parameter list. + */ +function findHandlerName(content: string, from: number): string | null { + let idx = from; + while (idx < content.length) { + const ch = content[idx]!; + if (/\s/.test(ch)) { idx++; continue; } + if (ch === "@") { + idx++; + while (idx < content.length && /[A-Za-z0-9_$]/.test(content[idx]!)) idx++; + if (content[idx] === "(") { + let depth = 0; + let quote: string | null = null; + while (idx < content.length) { + const c = content[idx]!; + if (quote) { + if (c === quote && content[idx - 1] !== "\\") quote = null; + } else if (c === "\"" || c === "'" || c === "`") { + quote = c; + } else if (c === "(") { + depth++; + } else if (c === ")") { + depth--; + if (depth === 0) { idx++; break; } + } + idx++; + } + } + continue; + } + const rest = content.slice(idx); + const keyword = /^(?:public|private|protected|static|readonly|override|async)\s+/.exec(rest); + if (keyword) { idx += keyword[0].length; continue; } + const method = /^([A-Za-z_$][\w$]*)\s*[<(]/.exec(rest); + if (method) return method[1]!; + return null; + } + return null; +} diff --git a/test/graph-integration.test.ts b/test/graph-integration.test.ts index b8ae3853..c576a42d 100644 --- a/test/graph-integration.test.ts +++ b/test/graph-integration.test.ts @@ -16,6 +16,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { MexConfig } from "../src/types.js"; import { runDriftCheckWithGraphStatus } from "../src/drift/index.js"; import { createGraphEngine } from "../src/graph/engine-impl.js"; +import { rebuildGraph } from "../src/graph/maintenance.js"; import { loadGroundingRuntime, loadReadOnlyGroundingRuntime, @@ -865,4 +866,53 @@ describe("code-graph grounding integration", () => { process.exitCode = previousExitCode; } }, 15_000); + + it("persists NestJS versioned routes through a real build without duplicate-id failures (#102)", async () => { + const root = mkdtempSync(join(tmpdir(), "mex-nestjs-integration-")); + roots.push(root); + const controller = join(root, "src", "users.controller.ts"); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, ".mex"), { recursive: true }); + writeFileSync(join(root, ".mex", "ROUTER.md"), "# Router\n"); + writeFileSync(join(root, "package.json"), JSON.stringify({ + name: "fixture", dependencies: { "@nestjs/common": "^10.0.0" }, + })); + writeFileSync(controller, [ + "import { Controller, Get, Version } from '@nestjs/common';", + "", + "@Controller('users')", + "export class UsersController {", + " @Version('1')", + " @Get()", + " findAllV1() { return ['v1']; }", + "", + " @Version('2')", + " @Get()", + " findAllV2() { return ['v2']; }", + "}", + "", + "// @Get('legacy')", + "// legacyRoute() {}", + "", + ].join("\n")); + + const result = await rebuildGraph(root); + expect(result.status.status).toBe("fresh"); + + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + const routes = db.prepare( + "SELECT name, signature FROM nodes WHERE kind = 'route' ORDER BY name", + ).all() as Array<{ name: string; signature: string }>; + // Both versioned routes persist with distinct ids; the commented-out + // decorator produces no phantom route. + expect(routes.map((route) => route.name)).toEqual(["GET /users", "GET /users"]); + expect(new Set(routes.map((route) => route.signature))).toEqual( + new Set(["GET /users -> findAllV1", "GET /users -> findAllV2"]), + ); + expect(routes.some((route) => route.signature.includes("legacy"))).toBe(false); + } finally { + db.close(); + } + }, 20_000); });