From d86b5fd15da8c805231c34d8a2d552f4329bfbe5 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Sun, 17 May 2026 14:33:11 +0530 Subject: [PATCH 1/3] feat(mapper): add Node server route mapping --- docs/feature-mapping.md | 6 +- src/mapper.test.ts | 285 ++++++++++++++++++++ src/mapper.ts | 2 + src/mappers/node-routes.ts | 540 +++++++++++++++++++++++++++++++++++++ 4 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 src/mappers/node-routes.ts diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index f470f0b..914ba4e 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -37,6 +37,8 @@ Supported deterministic mappers today: - React Router `` declarations and React components in root or nested frontend packages such as `frontend/`, `client/`, `web/`, workspaces, and packages under `apps/` or `packages/` +- Express, Fastify, and Hono string-literal route declarations in root or + workspace Node packages - Next.js `app/` and `pages/` routes at the repo root or inside discovered monorepo projects - Go `cmd/*/main.go` - Go `internal/*` packages @@ -141,7 +143,9 @@ as reviewable config because they can contain provider-sensitive secrets. Known gaps: -- no Express/Fastify/Hono route mapper yet +- Express/Fastify/Hono route mapping is conservative and does not infer + prefixes from cross-file router mounts such as `app.use("/api", router)`, + `fastify.register(..., { prefix })`, or `app.route("/api", subApp)` - no Django route mapper yet - Laravel route parsing is convention-based, does not execute Laravel route discovery, and may omit prefixes applied by `Route::group(...)` wrappers diff --git a/src/mapper.test.ts b/src/mapper.test.ts index ef1b06b..589cdeb 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1498,6 +1498,291 @@ describe("mapFeatures", () => { ).toEqual([{ path: "frontend/src/index.test.ts", command: "pnpm --dir frontend test" }]); }); + it("maps Express, Fastify, and Hono string-literal routes", async () => { + const root = await fixtureRoot("clawpatch-node-server-routes-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "server-app", + scripts: { test: "vitest run" }, + dependencies: { express: "1.0.0", fastify: "1.0.0", hono: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "src/server.ts", + [ + "import express, { Router } from 'express';", + "", + "const app = express();", + "const router = Router();", + "app.get('/health', health);", + "app.all('/proxy', proxy);", + "router.post('/admin/jobs', createJob);", + "router.route('/users').get(listUsers).delete(deleteUsers);", + "router.route('/reports').get(listReports);", + "db.delete('/not-a-route');", + "// app.get('/commented', ignored);", + "const text = \"router.post('/string', ignored)\";", + "function health() {}", + "function proxy() {}", + "function createJob() {}", + "function listUsers() {}", + "function deleteUsers() {}", + "function listReports() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/fastify.ts", + [ + "import Fastify from 'fastify';", + "", + "const fastify = Fastify();", + "fastify.get('/status', status);", + "fastify.post('/webhook/github', handleWebhook);", + "function status() {}", + "function handleWebhook() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/hono.ts", + [ + "import { Hono } from 'hono';", + "", + "const app = new Hono();", + "app.get('/api/items', listItems);", + "app.delete('/sessions/:id', deleteSession);", + "function listItems() {}", + "function deleteSession() {}", + "", + ].join("\n"), + ); + await writeFixture(root, "src/server.test.ts", "test('server', () => {});\n"); + await writeFixture(root, "src/fastify.test.ts", "test('fastify', () => {});\n"); + await writeFixture(root, "src/hono.test.ts", "test('hono', () => {});\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const admin = result.features.find( + (feature) => feature.title === "Express route POST /admin/jobs", + ); + const webhook = result.features.find( + (feature) => feature.title === "Fastify route POST /webhook/github", + ); + const session = result.features.find( + (feature) => feature.title === "Hono route DELETE /sessions/:id", + ); + + expect(project.detected.frameworks).toEqual( + expect.arrayContaining(["express", "fastify", "hono"]), + ); + expect(titles).toEqual( + expect.arrayContaining([ + "Express route GET /health", + "Express route ALL /proxy", + "Express route POST /admin/jobs", + "Express route GET /users", + "Express route DELETE /users", + "Express route GET /reports", + "Fastify route GET /status", + "Fastify route POST /webhook/github", + "Hono route GET /api/items", + "Hono route DELETE /sessions/:id", + ]), + ); + expect(titles).not.toContain("Express route GET /commented"); + expect(titles).not.toContain("Express route POST /string"); + expect(titles).not.toContain("Express route DELETE /reports"); + expect(admin?.source).toBe("express-route"); + expect(admin?.entrypoints[0]).toMatchObject({ + path: "src/server.ts", + symbol: "createJob", + route: "POST /admin/jobs", + }); + expect(admin?.tests).toEqual([{ path: "src/server.test.ts", command: "npm run test" }]); + expect(admin?.trustBoundaries).toContain("auth"); + expect(webhook?.trustBoundaries).toEqual(expect.arrayContaining(["auth", "external-api"])); + expect(session?.trustBoundaries).toContain("auth"); + }); + + it("maps workspace Express routes with package-scoped validation", async () => { + const root = await fixtureRoot("clawpatch-workspace-express-routes-"); + await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); + await writeFixture( + root, + "packages/api/package.json", + JSON.stringify( + { + name: "@scope/api", + scripts: { test: "vitest run" }, + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "packages/api/src/routes/users.ts", + [ + "import { Router } from 'express';", + "", + "const usersRouter = Router();", + "usersRouter.get('/users/:id', showUser);", + "function showUser() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "packages/api/src/routes/users.test.ts", + "test('users route', () => {});\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find( + (feature) => feature.title === "Express route GET /users/:id", + ); + + expect(route?.tags).toEqual(expect.arrayContaining(["express", "route", "project:@scope/api"])); + expect(route?.tests).toEqual([ + { + path: "packages/api/src/routes/users.test.ts", + command: "pnpm --dir packages/api test", + }, + ]); + }); + + it("does not map route-like calls without a server framework dependency", async () => { + const root = await fixtureRoot("clawpatch-node-route-false-positive-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ name: "client", dependencies: { axios: "1.0.0" } }, null, 2), + ); + await writeFixture( + root, + "src/client.ts", + "const app = client();\napp.get('/not-a-server-route');\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.some((feature) => feature.source.endsWith("-route"))).toBe(false); + }); + + it("does not map client calls inside server packages as routes", async () => { + const root = await fixtureRoot("clawpatch-node-client-call-routes-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "mixed-server-client", + dependencies: { express: "1.0.0", axios: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "src/client.ts", + [ + "import axios from 'axios';", + "", + "const api = axios.create();", + "api.get('/users');", + "const app = createClient();", + "app.post('/client-submit');", + "const server = express();", + "client.server.get('/nested-client');", + "this.server.post('/nested-submit');", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + + expect(titles).not.toContain("Express route GET /users"); + expect(titles).not.toContain("Express route POST /client-submit"); + expect(titles).not.toContain("Express route GET /nested-client"); + expect(titles).not.toContain("Express route POST /nested-submit"); + }); + + it("maps Nx Express routes without a project-local package manifest", async () => { + const root = await fixtureRoot("clawpatch-nx-express-routes-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "nx-root", + packageManager: "pnpm@10.0.0", + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture(root, "pnpm-lock.yaml", ""); + await writeFixture( + root, + "apps/api/project.json", + JSON.stringify( + { + name: "api", + sourceRoot: "apps/api/src", + projectType: "application", + targets: { test: {} }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "apps/api/src/server.mjs", + [ + "import express from 'express';", + "", + "const app = express();", + "app.get('/health', health);", + "function health() {}", + "", + ].join("\n"), + ); + await writeFixture(root, "apps/api/src/server.test.mjs", "test('server', () => {});\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "Express route GET /health"); + + expect(route?.entrypoints[0]).toMatchObject({ + path: "apps/api/src/server.mjs", + symbol: "health", + route: "GET /health", + }); + expect(route?.tags).toEqual(expect.arrayContaining(["project:api", "project-root:apps/api"])); + expect(route?.tests).toEqual([ + { path: "apps/api/src/server.test.mjs", command: "pnpm nx test api" }, + ]); + }); + it("maps React Router routes and components in a nested frontend app", async () => { const root = await fixtureRoot("clawpatch-react-router-map-"); await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - frontend\n"); diff --git a/src/mapper.ts b/src/mapper.ts index a1c0222..30b6d2a 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -7,6 +7,7 @@ import { appleSeeds } from "./mappers/apple.js"; import { gradleSeeds } from "./mappers/gradle.js"; import { laravelSeeds } from "./mappers/laravel.js"; import { nextSeeds } from "./mappers/next.js"; +import { nodeRouteSeeds } from "./mappers/node-routes.js"; import { nodeSeeds } from "./mappers/node.js"; import { pythonSeeds } from "./mappers/python.js"; import { reactSeeds } from "./mappers/react.js"; @@ -41,6 +42,7 @@ const featureMappers: FeatureMapper[] = [ { name: "node", map: nodeSeeds }, { name: "next", map: nextSeeds }, { name: "react", map: reactSeeds }, + { name: "node-routes", map: nodeRouteSeeds }, { name: "go", map: goSeeds }, { name: "python", map: pythonSeeds }, { name: "ruby", map: rubySeeds }, diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts new file mode 100644 index 0000000..a851ab1 --- /dev/null +++ b/src/mappers/node-routes.ts @@ -0,0 +1,540 @@ +import { readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { + dependencyFieldHas, + packageRelativePath, + projectContextFiles, + projectTags, + projectTargetCommand, +} from "./projects.js"; +import { pathMatchesPrefix, walk } from "./shared.js"; +import { + FeatureSeed, + MapperContext, + SeedFileRef, + SeedTestRef, + suppressedTestCommandTag, +} from "./types.js"; +import type { NodeProjectInfo } from "./projects.js"; + +type ServerFramework = "express" | "fastify" | "hono"; + +type ServerRoute = { + framework: ServerFramework; + filePath: string; + method: string; + routePath: string; + symbol: string | null; +}; + +const sourceRoots = ["src", "lib", "app", "server", "routes", "api"] as const; +const sourceExtensions = ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"] as const; +const rootEntryFiles = ["server", "app", "index", "main", "api"].flatMap((name) => + sourceExtensions.map((extension) => `${name}.${extension}`), +); +const testRoots = ["src", "lib", "app", "server", "routes", "api", "test", "tests", "__tests__"]; +const routeMethods = ["get", "post", "put", "patch", "delete", "options", "head", "all"] as const; +const routeMethodPattern = new RegExp( + `(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*)\\s*\\.\\s*(${routeMethods.join("|")})\\s*\\(`, + "gu", +); +const routeChainPattern = + /(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\s*\.\s*route\s*\(/gu; + +export async function nodeRouteSeeds(root: string, context: MapperContext): Promise { + const seeds: FeatureSeed[] = []; + const rootFrameworks = serverFrameworks( + context.projects.find((project) => project.root === ".") ?? null, + ); + for (const project of context.projects) { + const frameworks = serverFrameworks(project); + const effectiveFrameworks = + frameworks.length > 0 || project.packageJson !== null ? frameworks : rootFrameworks; + if (frameworks.length === 0) { + if (effectiveFrameworks.length === 0) { + continue; + } + } + seeds.push(...(await projectRouteSeeds(root, project, context, effectiveFrameworks))); + } + return seeds; +} + +function serverFrameworks(project: NodeProjectInfo | null): ServerFramework[] { + if (project === null) { + return []; + } + return (["express", "fastify", "hono"] as const).filter((framework) => + packageHasDependency(project, framework), + ); +} + +function packageHasDependency(project: NodeProjectInfo, dependency: string): boolean { + const pkg = project.packageJson as Record | null; + if (pkg === null) { + return false; + } + return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some( + (field) => dependencyFieldHas(pkg[field], dependency), + ); +} + +async function projectRouteSeeds( + root: string, + project: NodeProjectInfo, + context: MapperContext, + frameworks: ServerFramework[], +): Promise { + const files = await packageSourceFiles(root, project); + const tests = await packageTestFiles(root, project); + const testCommand = projectTargetCommand(project, "test", context.taskGraph); + const projectContext = await projectContextFiles(root, project); + const seeds: FeatureSeed[] = []; + + for (const file of files) { + const source = await readFile(join(root, file), "utf8"); + for (const route of parseServerRoutes(source, file, frameworks)) { + const routeTests = associatedTests([route.filePath], tests, testCommand ?? null); + const frameworkLabel = frameworkTitle(route.framework); + seeds.push({ + title: `${frameworkLabel} route ${route.method} ${route.routePath}`, + summary: `${frameworkLabel} route ${route.method} ${route.routePath} declared in ${route.filePath}.`, + kind: "route", + source: `${route.framework}-route`, + confidence: "medium", + entryPath: route.filePath, + symbol: route.symbol, + route: `${route.method} ${route.routePath}`, + command: null, + ownedFiles: [{ path: route.filePath, reason: `${frameworkLabel} route declaration` }], + contextFiles: uniqueFileRefs([ + ...projectContext, + ...routeTests.map((test) => ({ path: test.path, reason: "associated test" })), + ]), + tests: routeTests, + tags: [ + "node", + route.framework, + "route", + "api", + ...projectTags(project), + ...(testCommand === null ? [suppressedTestCommandTag] : []), + ], + trustBoundaries: routeTrustBoundaries(route), + ...(testCommand === undefined ? {} : { testCommand }), + skipNearbyTests: true, + }); + } + } + + return seeds; +} + +function parseServerRoutes( + source: string, + filePath: string, + projectFrameworks: ServerFramework[], +): ServerRoute[] { + const routes: ServerRoute[] = []; + for (const framework of projectFrameworks) { + const targets = routeTargetNames(source, framework); + if (targets.size === 0) { + continue; + } + routes.push(...directMethodRoutes(source, filePath, framework, targets)); + if (framework === "express") { + routes.push(...expressRouteChains(source, filePath, targets)); + } + } + return uniqueRoutes(routes); +} + +function directMethodRoutes( + source: string, + filePath: string, + framework: ServerFramework, + targets: ReadonlySet, +): ServerRoute[] { + const routes: ServerRoute[] = []; + routeMethodPattern.lastIndex = 0; + for (const match of source.matchAll(routeMethodPattern)) { + const matchIndex = match.index ?? 0; + const targetIndex = matchIndex + (match[1]?.length ?? 0); + if (isInsideCommentOrString(source, targetIndex)) { + continue; + } + const target = match[2]; + const method = match[3]; + if (target === undefined || method === undefined || !isRouteTarget(targets, target)) { + continue; + } + const openParenIndex = matchIndex + match[0].lastIndexOf("("); + const routePath = readStringLiteralArgument(source, openParenIndex + 1); + if (routePath === null || !isRoutePath(routePath.value)) { + continue; + } + routes.push({ + framework, + filePath, + method: method.toUpperCase(), + routePath: routePath.value, + symbol: readHandlerSymbol(source, routePath.end), + }); + } + return routes; +} + +function expressRouteChains( + source: string, + filePath: string, + targets: ReadonlySet, +): ServerRoute[] { + const routes: ServerRoute[] = []; + routeChainPattern.lastIndex = 0; + for (const match of source.matchAll(routeChainPattern)) { + const matchIndex = match.index ?? 0; + const targetIndex = matchIndex + (match[1]?.length ?? 0); + if (isInsideCommentOrString(source, targetIndex)) { + continue; + } + const target = match[2]; + if (target === undefined || !isRouteTarget(targets, target)) { + continue; + } + const openParenIndex = matchIndex + match[0].lastIndexOf("("); + const routePath = readStringLiteralArgument(source, openParenIndex + 1); + if (routePath === null || !isRoutePath(routePath.value)) { + continue; + } + for (const method of expressChainMethods(source, routePath.end)) { + routes.push({ + framework: "express", + filePath, + method, + routePath: routePath.value, + symbol: null, + }); + } + } + return routes; +} + +function routeTargetNames(source: string, framework: ServerFramework): Set { + if (framework === "express") { + return declaredTargetNames(source, [ + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:express\s*\(\s*\)|express\s*\.\s*Router\s*\(\s*\)|Router\s*\(\s*\))/gu, + ]); + } + if (framework === "fastify") { + return declaredTargetNames(source, [ + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:Fastify|fastify)\s*\(/gu, + ]); + } + return declaredTargetNames(source, [ + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:new\s+)?Hono\s*\(/gu, + ]); +} + +function declaredTargetNames(source: string, patterns: RegExp[]): Set { + const names = new Set(); + for (const pattern of patterns) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const matchIndex = match.index ?? 0; + if (isInsideCommentOrString(source, matchIndex)) { + continue; + } + const name = match[1]; + if (name !== undefined) { + names.add(name); + } + } + } + return names; +} + +function isRouteTarget(targets: ReadonlySet, target: string): boolean { + return !target.includes(".") && targets.has(target); +} + +function expressChainMethods(source: string, start: number): string[] { + const methods: string[] = []; + let cursor = endOfCall(source, start); + while (cursor !== null) { + cursor = skipWhitespace(source, cursor); + if (source[cursor] !== ".") { + return methods; + } + const rest = source.slice(cursor + 1); + const methodMatch = /^(get|post|put|patch|delete|options|head|all)\s*\(/u.exec(rest); + if (methodMatch === null) { + return methods; + } + const method = methodMatch[1]; + if (method === undefined) { + return methods; + } + methods.push(method.toUpperCase()); + cursor = endOfCall(source, cursor + 1 + methodMatch[0].length); + } + return methods; +} + +function skipWhitespace(source: string, start: number): number { + let cursor = start; + while (/\s/u.test(source[cursor] ?? "")) { + cursor += 1; + } + return cursor; +} + +function endOfCall(source: string, start: number): number | null { + let depth = 1; + let quote: string | null = null; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + return null; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote === "`" && char === "$" && source[index + 1] === "{") { + return null; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index + 1; + } + } + } + return null; +} + +function readStringLiteralArgument( + source: string, + start: number, +): { value: string; end: number } | null { + let cursor = start; + while (/\s/u.test(source[cursor] ?? "")) { + cursor += 1; + } + const quote = source[cursor]; + if (quote !== "'" && quote !== '"' && quote !== "`") { + return null; + } + let value = ""; + let escaped = false; + for (let index = cursor + 1; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + return null; + } + if (escaped) { + value += char; + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (quote === "`" && char === "$" && source[index + 1] === "{") { + return null; + } + if (char === quote) { + return { value, end: index + 1 }; + } + value += char; + } + return null; +} + +function isRoutePath(path: string): boolean { + return path === "*" || path.startsWith("/"); +} + +function readHandlerSymbol(source: string, start: number): string | null { + let cursor = start; + while (/\s/u.test(source[cursor] ?? "")) { + cursor += 1; + } + if (source[cursor] !== ",") { + return null; + } + cursor += 1; + while (/\s/u.test(source[cursor] ?? "")) { + cursor += 1; + } + const match = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?/u.exec( + source.slice(cursor), + ); + const symbol = match?.[0] ?? null; + if ( + symbol === null || + ["async", "function", "req", "request", "res", "response"].includes(symbol) + ) { + return null; + } + return symbol; +} + +function isInsideCommentOrString(source: string, index: number): boolean { + let state: "code" | "line-comment" | "block-comment" | "single" | "double" | "template" = "code"; + let escaped = false; + for (let cursor = 0; cursor < index; cursor += 1) { + const char = source[cursor]; + const next = source[cursor + 1]; + if (char === undefined) { + break; + } + if (state === "line-comment") { + if (char === "\n") { + state = "code"; + } + continue; + } + if (state === "block-comment") { + if (char === "*" && next === "/") { + cursor += 1; + state = "code"; + } + continue; + } + if (state === "single" || state === "double" || state === "template") { + const quote = state === "single" ? "'" : state === "double" ? '"' : "`"; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + state = "code"; + } + continue; + } + if (char === "/" && next === "/") { + cursor += 1; + state = "line-comment"; + } else if (char === "/" && next === "*") { + cursor += 1; + state = "block-comment"; + } else if (char === "'") { + state = "single"; + } else if (char === '"') { + state = "double"; + } else if (char === "`") { + state = "template"; + } + } + return state !== "code"; +} + +async function packageSourceFiles(root: string, project: NodeProjectInfo): Promise { + const prefixes = [ + ...sourceRoots.map((prefix) => packageRelativePath(project.root, prefix)), + ...(project.sourceRoot === null ? [] : [project.sourceRoot]), + ...rootEntryFiles.map((file) => packageRelativePath(project.root, file)), + ]; + return (await walk(root, prefixes)) + .filter((file) => pathMatchesPrefix(file, project.root === "." ? "" : project.root)) + .filter(isReviewableServerSourceFile); +} + +async function packageTestFiles(root: string, project: NodeProjectInfo): Promise { + const prefixes = [ + ...testRoots.map((prefix) => packageRelativePath(project.root, prefix)), + ...(project.sourceRoot === null ? [] : [project.sourceRoot]), + ]; + return (await walk(root, prefixes)).filter(isNodeTestPath).slice(0, 200); +} + +function associatedTests(files: string[], tests: string[], command: string | null): SeedTestRef[] { + const fileStems = new Set(files.map((file) => basename(file).replace(/\.[^.]+$/u, ""))); + const dirs = new Set(files.map((file) => dirname(file))); + const exact = tests.filter((test) => { + const testStem = basename(test).replace(/\.(test|spec)\.[^.]+$/u, ""); + return fileStems.has(testStem); + }); + const candidates = + exact.length > 0 + ? exact + : tests.filter((test) => [...dirs].some((dir) => pathMatchesPrefix(test, dir))); + return candidates.slice(0, 8).map((path) => ({ path, command })); +} + +function isReviewableServerSourceFile(path: string): boolean { + return ( + /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u.test(path) && + !isNodeTestPath(path) && + !/\.d\.[cm]?ts$/u.test(path) && + !/(^|\/)(__fixtures__|fixtures|testdata)(\/|$)/u.test(path) && + !/(^|\/)[^/]*(?:generated|\.gen)\.[^.]+$/iu.test(path) + ); +} + +function isNodeTestPath(path: string): boolean { + return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u.test(path); +} + +function routeTrustBoundaries(route: ServerRoute): FeatureSeed["trustBoundaries"] { + const boundaries: FeatureSeed["trustBoundaries"] = ["user-input", "network", "serialization"]; + if ( + route.method !== "GET" || + /(^|\/)(admin|auth|login|logout|oauth|session|token)(\/|$)/iu.test(route.routePath) + ) { + boundaries.push("auth"); + } + if (/(^|\/)(webhook|callback|integration)(\/|$)/iu.test(route.routePath)) { + boundaries.push("external-api"); + } + return [...new Set(boundaries)]; +} + +function frameworkTitle(framework: ServerFramework): string { + if (framework === "fastify") { + return "Fastify"; + } + if (framework === "hono") { + return "Hono"; + } + return "Express"; +} + +function uniqueRoutes(routes: ServerRoute[]): ServerRoute[] { + const seen = new Set(); + const output: ServerRoute[] = []; + for (const route of routes) { + const key = `${route.framework}:${route.filePath}:${route.method}:${route.routePath}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(route); + } + return output; +} + +function uniqueFileRefs(refs: SeedFileRef[]): SeedFileRef[] { + const seen = new Set(); + const output: SeedFileRef[] = []; + for (const ref of refs) { + if (seen.has(ref.path)) { + continue; + } + seen.add(ref.path); + output.push(ref); + } + return output; +} From 7ff0004af68613596dcec3ceab712c3d671de732 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Sun, 17 May 2026 15:17:12 +0530 Subject: [PATCH 2/3] fix(mapper): handle Node route edge cases --- src/mapper.test.ts | 25 ++++++++++++++++++ src/mappers/node-routes.ts | 52 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 589cdeb..1b07ac1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1521,20 +1521,28 @@ describe("mapFeatures", () => { "", "const app = express();", "const router = Router();", + "const projectRouter = Router({ mergeParams: true });", + "let hitCount = 0;", + "const normalized = hitCount++ / 100;", "app.get('/health', health);", + "app.get('/after-postfix-division', afterPostfixDivision);", "app.all('/proxy', proxy);", "router.post('/admin/jobs', createJob);", "router.route('/users').get(listUsers).delete(deleteUsers);", "router.route('/reports').get(listReports);", + "projectRouter.get('/projects/:projectId/items', listProjectItems);", + "const routePattern = /app.get('\\/regex-health')/;", "db.delete('/not-a-route');", "// app.get('/commented', ignored);", "const text = \"router.post('/string', ignored)\";", "function health() {}", + "function afterPostfixDivision() {}", "function proxy() {}", "function createJob() {}", "function listUsers() {}", "function deleteUsers() {}", "function listReports() {}", + "function listProjectItems() {}", "", ].join("\n"), ); @@ -1569,6 +1577,19 @@ describe("mapFeatures", () => { await writeFixture(root, "src/server.test.ts", "test('server', () => {});\n"); await writeFixture(root, "src/fastify.test.ts", "test('fastify', () => {});\n"); await writeFixture(root, "src/hono.test.ts", "test('hono', () => {});\n"); + await writeFixture( + root, + "src/mixed.tsx", + [ + "import express from 'express';", + "", + "const app = express();", + "const view =
;", + "app.get('/after-jsx-close', afterJsxClose);", + "function afterJsxClose() {}", + "", + ].join("\n"), + ); const project = await detectProject(root); const result = await mapFeatures(root, project, []); @@ -1589,11 +1610,14 @@ describe("mapFeatures", () => { expect(titles).toEqual( expect.arrayContaining([ "Express route GET /health", + "Express route GET /after-postfix-division", "Express route ALL /proxy", "Express route POST /admin/jobs", "Express route GET /users", "Express route DELETE /users", "Express route GET /reports", + "Express route GET /projects/:projectId/items", + "Express route GET /after-jsx-close", "Fastify route GET /status", "Fastify route POST /webhook/github", "Hono route GET /api/items", @@ -1602,6 +1626,7 @@ describe("mapFeatures", () => { ); expect(titles).not.toContain("Express route GET /commented"); expect(titles).not.toContain("Express route POST /string"); + expect(titles).not.toContain("Express route GET /regex-health"); expect(titles).not.toContain("Express route DELETE /reports"); expect(admin?.source).toBe("express-route"); expect(admin?.entrypoints[0]).toMatchObject({ diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index a851ab1..9e49c5f 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -222,7 +222,7 @@ function expressRouteChains( function routeTargetNames(source: string, framework: ServerFramework): Set { if (framework === "express") { return declaredTargetNames(source, [ - /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:express\s*\(\s*\)|express\s*\.\s*Router\s*\(\s*\)|Router\s*\(\s*\))/gu, + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:express\s*\(|express\s*\.\s*Router\s*\(|Router\s*\()/gu, ]); } if (framework === "fastify") { @@ -392,8 +392,16 @@ function readHandlerSymbol(source: string, start: number): string | null { } function isInsideCommentOrString(source: string, index: number): boolean { - let state: "code" | "line-comment" | "block-comment" | "single" | "double" | "template" = "code"; + let state: + | "code" + | "line-comment" + | "block-comment" + | "single" + | "double" + | "template" + | "regex" = "code"; let escaped = false; + let regexCharClass = false; for (let cursor = 0; cursor < index; cursor += 1) { const char = source[cursor]; const next = source[cursor + 1]; @@ -424,12 +432,29 @@ function isInsideCommentOrString(source: string, index: number): boolean { } continue; } + if (state === "regex") { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "[") { + regexCharClass = true; + } else if (char === "]") { + regexCharClass = false; + } else if (char === "/" && !regexCharClass) { + state = "code"; + } + continue; + } if (char === "/" && next === "/") { cursor += 1; state = "line-comment"; } else if (char === "/" && next === "*") { cursor += 1; state = "block-comment"; + } else if (startsRegexLiteral(source, cursor)) { + state = "regex"; + regexCharClass = false; } else if (char === "'") { state = "single"; } else if (char === '"') { @@ -441,6 +466,29 @@ function isInsideCommentOrString(source: string, index: number): boolean { return state !== "code"; } +function startsRegexLiteral(source: string, index: number): boolean { + const char = source[index]; + const next = source[index + 1]; + if (char !== "/" || next === "/" || next === "*" || next === undefined) { + return false; + } + const previous = previousSignificantChar(source, index); + return previous === null || /[([{=,:;!&|?*~^]/u.test(previous); +} + +function previousSignificantChar(source: string, index: number): string | null { + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + const char = source[cursor]; + if (char === undefined) { + return null; + } + if (!/\s/u.test(char)) { + return char; + } + } + return null; +} + async function packageSourceFiles(root: string, project: NodeProjectInfo): Promise { const prefixes = [ ...sourceRoots.map((prefix) => packageRelativePath(project.root, prefix)), From cd53244584ced7ef69bd8cd44fe702783f14dbe0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 13:48:10 +0100 Subject: [PATCH 3/3] fix(mapper): harden node route mapping --- src/mapper.test.ts | 249 +++++++++++++++++++++- src/mappers/node-routes.ts | 410 ++++++++++++++++++++++++++++++++----- 2 files changed, 605 insertions(+), 54 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 1b07ac1..ef3ac80 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1521,24 +1521,37 @@ describe("mapFeatures", () => { "", "const app = express();", "const router = Router();", + "const typedRouter: Router = Router();", "const projectRouter = Router({ mergeParams: true });", "let hitCount = 0;", "const normalized = hitCount++ / 100;", "app.get('/health', health);", "app.get('/after-postfix-division', afterPostfixDivision);", + "app.get('/admin', requireAuth, showAdmin);", + "app.get('/anonymous', requireAuth, (_req, res) => res.send('ok'));", + "app.get('/dynamic/' + version, dynamicRoute);", "app.all('/proxy', proxy);", "router.post('/admin/jobs', createJob);", + "router.post<{ Body: CreateJob }>('/typed-jobs', createTypedJob);", + "typedRouter.patch('/typed/:id', updateTyped);", "router.route('/users').get(listUsers).delete(deleteUsers);", "router.route('/reports').get(listReports);", "projectRouter.get('/projects/:projectId/items', listProjectItems);", "const routePattern = /app.get('\\/regex-health')/;", + "const returnedPattern = () => /app.get('\\/arrow-regex')/;", "db.delete('/not-a-route');", "// app.get('/commented', ignored);", "const text = \"router.post('/string', ignored)\";", + "function routePatternFn() { return /app.get('\\/returned-regex')/; }", "function health() {}", "function afterPostfixDivision() {}", + "function requireAuth() {}", + "function showAdmin() {}", + "function dynamicRoute() {}", "function proxy() {}", "function createJob() {}", + "function createTypedJob() {}", + "function updateTyped() {}", "function listUsers() {}", "function deleteUsers() {}", "function listReports() {}", @@ -1552,21 +1565,41 @@ describe("mapFeatures", () => { [ "import Fastify from 'fastify';", "", - "const fastify = Fastify();", + "const fastify = Fastify<{ logger: true }>();", "fastify.get('/status', status);", + "fastify.get<{ Params: { id: string } }>('/typed-users/:id', showTypedUser);", + "fastify.route({ method: 'GET', url: '/route-status', handler: routeStatus });", + "fastify.route({ method: 'GET', url: `/dynamic/${id}`, handler: dynamicRoute });", + "fastify.route({ method: 'GET', url: '/concat-' + suffix, handler: dynamicRoute });", "fastify.post('/webhook/github', handleWebhook);", "function status() {}", + "function showTypedUser() {}", + "function routeStatus() {}", + "function dynamicRoute() {}", "function handleWebhook() {}", "", ].join("\n"), ); + await writeFixture( + root, + "src/fastify-plugin.ts", + [ + "import { FastifyInstance } from 'fastify';", + "", + "export async function routes(fastify: FastifyInstance) {", + " fastify.get('/plugin-users', listPluginUsers);", + "}", + "function listPluginUsers() {}", + "", + ].join("\n"), + ); await writeFixture( root, "src/hono.ts", [ "import { Hono } from 'hono';", "", - "const app = new Hono();", + "const app = new Hono<{ Bindings: Env }>();", "app.get('/api/items', listItems);", "app.delete('/sessions/:id', deleteSession);", "function listItems() {}", @@ -1576,6 +1609,7 @@ describe("mapFeatures", () => { ); await writeFixture(root, "src/server.test.ts", "test('server', () => {});\n"); await writeFixture(root, "src/fastify.test.ts", "test('fastify', () => {});\n"); + await writeFixture(root, "src/fastify-plugin.test.ts", "test('fastify plugin', () => {});\n"); await writeFixture(root, "src/hono.test.ts", "test('hono', () => {});\n"); await writeFixture( root, @@ -1590,6 +1624,21 @@ describe("mapFeatures", () => { "", ].join("\n"), ); + await writeFixture( + root, + "src/custom-router.ts", + [ + "// import { Router } from 'express';", + "import { type Router } from 'express';", + "", + "declare function Router(): { get(path: string, handler: unknown): void };", + "", + "const router = Router();", + "router.get('/custom-router', handler);", + "function handler() {}", + "", + ].join("\n"), + ); const project = await detectProject(root); const result = await mapFeatures(root, project, []); @@ -1600,6 +1649,15 @@ describe("mapFeatures", () => { const webhook = result.features.find( (feature) => feature.title === "Fastify route POST /webhook/github", ); + const adminMiddleware = result.features.find( + (feature) => feature.title === "Express route GET /admin", + ); + const anonymousHandler = result.features.find( + (feature) => feature.title === "Express route GET /anonymous", + ); + const fastifyRouteObject = result.features.find( + (feature) => feature.title === "Fastify route GET /route-status", + ); const session = result.features.find( (feature) => feature.title === "Hono route DELETE /sessions/:id", ); @@ -1611,15 +1669,22 @@ describe("mapFeatures", () => { expect.arrayContaining([ "Express route GET /health", "Express route GET /after-postfix-division", + "Express route GET /admin", + "Express route GET /anonymous", "Express route ALL /proxy", "Express route POST /admin/jobs", + "Express route POST /typed-jobs", + "Express route PATCH /typed/:id", "Express route GET /users", "Express route DELETE /users", "Express route GET /reports", "Express route GET /projects/:projectId/items", "Express route GET /after-jsx-close", "Fastify route GET /status", + "Fastify route GET /typed-users/:id", + "Fastify route GET /route-status", "Fastify route POST /webhook/github", + "Fastify route GET /plugin-users", "Hono route GET /api/items", "Hono route DELETE /sessions/:id", ]), @@ -1627,6 +1692,12 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Express route GET /commented"); expect(titles).not.toContain("Express route POST /string"); expect(titles).not.toContain("Express route GET /regex-health"); + expect(titles).not.toContain("Express route GET /arrow-regex"); + expect(titles).not.toContain("Express route GET /returned-regex"); + expect(titles).not.toContain("Express route GET /custom-router"); + expect(titles).not.toContain("Express route GET /dynamic/"); + expect(titles).not.toContain("Fastify route GET /dynamic/"); + expect(titles).not.toContain("Fastify route GET /concat-"); expect(titles).not.toContain("Express route DELETE /reports"); expect(admin?.source).toBe("express-route"); expect(admin?.entrypoints[0]).toMatchObject({ @@ -1637,9 +1708,183 @@ describe("mapFeatures", () => { expect(admin?.tests).toEqual([{ path: "src/server.test.ts", command: "npm run test" }]); expect(admin?.trustBoundaries).toContain("auth"); expect(webhook?.trustBoundaries).toEqual(expect.arrayContaining(["auth", "external-api"])); + expect(adminMiddleware?.entrypoints[0]?.symbol).toBe("showAdmin"); + expect(anonymousHandler?.entrypoints[0]?.symbol).toBeNull(); + expect(fastifyRouteObject?.entrypoints[0]?.symbol).toBe("routeStatus"); expect(session?.trustBoundaries).toContain("auth"); }); + it("keeps index route tests scoped to their route directory", async () => { + const root = await fixtureRoot("clawpatch-node-server-index-route-tests-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "index-route-server", + scripts: { test: "vitest run" }, + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "src/routes/users/index.ts", + [ + "import { Router } from 'express';", + "", + "const router = Router();", + "router.get('/users', listUsers);", + "function listUsers() {}", + "", + ].join("\n"), + ); + await writeFixture(root, "src/routes/users/index.test.ts", "test('users', () => {});\n"); + await writeFixture(root, "src/routes/admin/index.test.ts", "test('admin', () => {});\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "Express route GET /users"); + + expect(route?.tests).toEqual([ + { path: "src/routes/users/index.test.ts", command: "npm run test" }, + ]); + }); + + it("keeps nested top-level Express routes scoped to their package", async () => { + const root = await fixtureRoot("clawpatch-top-level-workspace-express-routes-"); + await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - api\n"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "root-server", + scripts: { test: "vitest run root.test.ts" }, + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "api/package.json", + JSON.stringify( + { + name: "@scope/api", + scripts: { test: "vitest run" }, + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "api/src/server.ts", + [ + "import express from 'express';", + "", + "const app = express();", + "app.get('/health', health);", + "function health() {}", + "", + ].join("\n"), + ); + await writeFixture(root, "api/src/server.test.ts", "test('api route', () => {});\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find((feature) => feature.title === "Express route GET /health"); + + expect(route?.tags).toEqual(expect.arrayContaining(["project:@scope/api", "project-root:api"])); + expect(route?.tags).not.toContain("project:root-server"); + expect(route?.tests).toEqual([ + { path: "api/src/server.test.ts", command: "pnpm --dir api test" }, + ]); + }); + + it("does not scan nested packages without server route dependencies", async () => { + const root = await fixtureRoot("clawpatch-node-server-nested-no-framework-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { name: "root", workspaces: ["packages/*"], dependencies: { express: "1.0.0" } }, + null, + 2, + ), + ); + await writeFixture( + root, + "packages/worker/package.json", + JSON.stringify({ name: "worker", scripts: { test: "vitest run" } }, null, 2), + ); + await writeFixture( + root, + "packages/worker/src/looks-like-server.ts", + [ + "const app = { get(_path: string, _handler: unknown) {} };", + "", + "app.get('/worker-health', handler);", + "function handler() {}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(result.features.map((feature) => feature.title)).not.toContain( + "Express route GET /worker-health", + ); + }); + + it("keeps root entry route tests with root entry route features", async () => { + const root = await fixtureRoot("clawpatch-node-root-entry-route-tests-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "root-entry-server", + scripts: { test: "vitest run" }, + dependencies: { express: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "server.ts", + [ + "import express from 'express';", + "", + "const app = express();", + "app.get('/root-health', health);", + "function health() {}", + "", + ].join("\n"), + ); + await writeFixture(root, "server.test.ts", "test('root server', () => {});\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const route = result.features.find( + (feature) => feature.title === "Express route GET /root-health", + ); + + expect(route?.tests).toEqual([{ path: "server.test.ts", command: "npm run test" }]); + expect(route?.contextFiles).toContainEqual({ + path: "server.test.ts", + reason: "associated test", + }); + }); + it("maps workspace Express routes with package-scoped validation", async () => { const root = await fixtureRoot("clawpatch-workspace-express-routes-"); await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 9e49c5f..00b828d 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -29,13 +29,35 @@ type ServerRoute = { const sourceRoots = ["src", "lib", "app", "server", "routes", "api"] as const; const sourceExtensions = ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"] as const; -const rootEntryFiles = ["server", "app", "index", "main", "api"].flatMap((name) => +const rootEntryNames = ["server", "app", "index", "main", "api"] as const; +const rootEntryFiles = rootEntryNames.flatMap((name) => sourceExtensions.map((extension) => `${name}.${extension}`), ); +const rootEntryTestFiles = rootEntryNames.flatMap((name) => + sourceExtensions.flatMap((extension) => [ + `${name}.test.${extension}`, + `${name}.spec.${extension}`, + ]), +); const testRoots = ["src", "lib", "app", "server", "routes", "api", "test", "tests", "__tests__"]; const routeMethods = ["get", "post", "put", "patch", "delete", "options", "head", "all"] as const; +const declarationPrefix = String.raw`\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*:\s*[^=;]+)?\s*=\s*`; +const genericArguments = String.raw`(?:\s*<[^;=()]*>)?`; +const regexPrefixKeywords = new Set([ + "await", + "case", + "delete", + "else", + "in", + "instanceof", + "return", + "throw", + "typeof", + "void", + "yield", +]); const routeMethodPattern = new RegExp( - `(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*)\\s*\\.\\s*(${routeMethods.join("|")})\\s*\\(`, + `(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*)\\s*\\.\\s*(${routeMethods.join("|")})${genericArguments}\\s*\\(`, "gu", ); const routeChainPattern = @@ -49,11 +71,9 @@ export async function nodeRouteSeeds(root: string, context: MapperContext): Prom for (const project of context.projects) { const frameworks = serverFrameworks(project); const effectiveFrameworks = - frameworks.length > 0 || project.packageJson !== null ? frameworks : rootFrameworks; - if (frameworks.length === 0) { - if (effectiveFrameworks.length === 0) { - continue; - } + frameworks.length > 0 ? frameworks : project.packageJson === null ? rootFrameworks : []; + if (effectiveFrameworks.length === 0) { + continue; } seeds.push(...(await projectRouteSeeds(root, project, context, effectiveFrameworks))); } @@ -85,8 +105,8 @@ async function projectRouteSeeds( context: MapperContext, frameworks: ServerFramework[], ): Promise { - const files = await packageSourceFiles(root, project); - const tests = await packageTestFiles(root, project); + const files = await packageSourceFiles(root, project, context.projects); + const tests = await packageTestFiles(root, project, context.projects); const testCommand = projectTargetCommand(project, "test", context.taskGraph); const projectContext = await projectContextFiles(root, project); const seeds: FeatureSeed[] = []; @@ -144,6 +164,8 @@ function parseServerRoutes( routes.push(...directMethodRoutes(source, filePath, framework, targets)); if (framework === "express") { routes.push(...expressRouteChains(source, filePath, targets)); + } else if (framework === "fastify") { + routes.push(...fastifyRouteObjects(source, filePath, targets)); } } return uniqueRoutes(routes); @@ -173,12 +195,61 @@ function directMethodRoutes( if (routePath === null || !isRoutePath(routePath.value)) { continue; } + const delimiter = nextRouteValueDelimiter(source, routePath.end); + if (delimiter !== "," && delimiter !== ")") { + continue; + } + const callEnd = endOfCall(source, openParenIndex + 1); routes.push({ framework, filePath, method: method.toUpperCase(), routePath: routePath.value, - symbol: readHandlerSymbol(source, routePath.end), + symbol: callEnd === null ? null : readHandlerSymbol(source, routePath.end, callEnd - 1), + }); + } + return routes; +} + +function fastifyRouteObjects( + source: string, + filePath: string, + targets: ReadonlySet, +): ServerRoute[] { + const routes: ServerRoute[] = []; + routeChainPattern.lastIndex = 0; + for (const match of source.matchAll(routeChainPattern)) { + const matchIndex = match.index ?? 0; + const targetIndex = matchIndex + (match[1]?.length ?? 0); + if (isInsideCommentOrString(source, targetIndex)) { + continue; + } + const target = match[2]; + if (target === undefined || !isRouteTarget(targets, target)) { + continue; + } + const openParenIndex = matchIndex + match[0].lastIndexOf("("); + const objectStart = skipWhitespace(source, openParenIndex + 1); + if (source[objectStart] !== "{") { + continue; + } + const objectEnd = endOfObject(source, objectStart + 1); + if (objectEnd === null) { + continue; + } + const routeObject = source.slice(objectStart, objectEnd); + const method = readStringProperty(routeObject, "method"); + const routePath = + readStringProperty(routeObject, "url") ?? readStringProperty(routeObject, "path"); + if (method === null || routePath === null || !isRoutePath(routePath)) { + continue; + } + routes.push({ + framework: "fastify", + filePath, + method: method.toUpperCase(), + routePath, + symbol: readIdentifierProperty(routeObject, "handler"), }); } return routes; @@ -206,6 +277,10 @@ function expressRouteChains( if (routePath === null || !isRoutePath(routePath.value)) { continue; } + const delimiter = nextRouteValueDelimiter(source, routePath.end); + if (delimiter !== "," && delimiter !== ")") { + continue; + } for (const method of expressChainMethods(source, routePath.end)) { routes.push({ framework: "express", @@ -221,20 +296,79 @@ function expressRouteChains( function routeTargetNames(source: string, framework: ServerFramework): Set { if (framework === "express") { - return declaredTargetNames(source, [ - /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:express\s*\(|express\s*\.\s*Router\s*\(|Router\s*\()/gu, - ]); + const patterns = [ + new RegExp( + `${declarationPrefix}(?:express${genericArguments}\\s*\\(|express\\s*\\.\\s*Router${genericArguments}\\s*\\()`, + "gu", + ), + ]; + if (hasExpressRouterBinding(source)) { + patterns.push(new RegExp(`${declarationPrefix}Router${genericArguments}\\s*\\(`, "gu")); + } + return declaredTargetNames(source, patterns); } if (framework === "fastify") { - return declaredTargetNames(source, [ - /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:Fastify|fastify)\s*\(/gu, + return new Set([ + ...declaredTargetNames(source, [ + new RegExp(`${declarationPrefix}(?:Fastify|fastify)${genericArguments}\\s*\\(`, "gu"), + ]), + ...functionParameterTargets(source, "fastify"), ]); } return declaredTargetNames(source, [ - /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:new\s+)?Hono\s*\(/gu, + new RegExp(`${declarationPrefix}(?:new\\s+)?Hono${genericArguments}\\s*\\(`, "gu"), ]); } +function hasExpressRouterBinding(source: string): boolean { + return ( + hasExpressRouterImportBinding(source) || + codePatternMatches( + source, + /\b(?:const|let|var)\s*\{\s*[^}]*\bRouter\b[^}]*\}\s*=\s*require\s*\(\s*["']express["']\s*\)/gu, + ) || + codePatternMatches( + source, + /\b(?:const|let|var)\s+Router\s*=\s*(?:express\s*\.\s*Router|require\s*\(\s*["']express["']\s*\)\s*\.\s*Router)\b/gu, + ) + ); +} + +function hasExpressRouterImportBinding(source: string): boolean { + const pattern = /\bimport\s+(?!type\b)([\s\S]{0,400}?)\bfrom\s*["']express["']/gu; + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + if (isInsideCommentOrString(source, match.index ?? 0)) { + continue; + } + if (importClauseHasBareValueRouter(match[1] ?? "")) { + return true; + } + } + return false; +} + +function importClauseHasBareValueRouter(clause: string): boolean { + const named = /\{([^}]*)\}/u.exec(clause)?.[1]; + if (named === undefined) { + return false; + } + return named.split(",").some((part) => { + const binding = part.trim(); + return !binding.startsWith("type ") && /^Router(?:\s+as\s+Router)?$/u.test(binding); + }); +} + +function codePatternMatches(source: string, pattern: RegExp): boolean { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + if (!isInsideCommentOrString(source, match.index ?? 0)) { + return true; + } + } + return false; +} + function declaredTargetNames(source: string, patterns: RegExp[]): Set { const names = new Set(); for (const pattern of patterns) { @@ -253,6 +387,35 @@ function declaredTargetNames(source: string, patterns: RegExp[]): Set { return names; } +function functionParameterTargets(source: string, preferredName: string): Set { + const names = new Set(); + for (const pattern of [ + /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(([^)]*)\)/gu, + /(?:async\s*)?\(([^)]*)\)\s*=>/gu, + ]) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const matchIndex = match.index ?? 0; + if (isInsideCommentOrString(source, matchIndex)) { + continue; + } + for (const parameter of parameterNames(match[1] ?? "")) { + if (parameter === preferredName) { + names.add(parameter); + } + } + } + } + return names; +} + +function parameterNames(parameters: string): string[] { + return parameters + .split(",") + .map((parameter) => /^\.{0,3}\s*([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(parameter.trim())?.[1]) + .filter((parameter): parameter is string => parameter !== undefined); +} + function isRouteTarget(targets: ReadonlySet, target: string): boolean { return !target.includes(".") && targets.has(target); } @@ -288,6 +451,31 @@ function skipWhitespace(source: string, start: number): number { return cursor; } +function nextRouteValueDelimiter(source: string, start: number): string | null { + let cursor = start; + while (cursor < source.length) { + cursor = skipWhitespace(source, cursor); + if (source[cursor] === "/" && source[cursor + 1] === "/") { + const newline = source.indexOf("\n", cursor + 2); + if (newline < 0) { + return null; + } + cursor = newline + 1; + continue; + } + if (source[cursor] === "/" && source[cursor + 1] === "*") { + const close = source.indexOf("*/", cursor + 2); + if (close < 0) { + return null; + } + cursor = close + 2; + continue; + } + return source[cursor] ?? null; + } + return null; +} + function endOfCall(source: string, start: number): number | null { let depth = 1; let quote: string | null = null; @@ -323,6 +511,41 @@ function endOfCall(source: string, start: number): number | null { return null; } +function endOfObject(source: string, start: number): number | null { + let depth = 1; + let quote: string | null = null; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + return null; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote === "`" && char === "$" && source[index + 1] === "{") { + return null; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return index + 1; + } + } + } + return null; +} + function readStringLiteralArgument( source: string, start: number, @@ -366,22 +589,78 @@ function isRoutePath(path: string): boolean { return path === "*" || path.startsWith("/"); } -function readHandlerSymbol(source: string, start: number): string | null { - let cursor = start; - while (/\s/u.test(source[cursor] ?? "")) { - cursor += 1; - } - if (source[cursor] !== ",") { - return null; +function readStringProperty(source: string, property: string): string | null { + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp(String.raw`(?:^|[,{]\s*)${escapedProperty}\s*:`, "gu"); + for (const match of source.matchAll(pattern)) { + const literal = readStringLiteralArgument(source, (match.index ?? 0) + match[0].length); + if (literal === null) { + continue; + } + const delimiter = nextRouteValueDelimiter(source, literal.end); + if (delimiter === "," || delimiter === "}") { + return literal.value; + } } - cursor += 1; - while (/\s/u.test(source[cursor] ?? "")) { - cursor += 1; + return null; +} + +function readIdentifierProperty(source: string, property: string): string | null { + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp( + String.raw`(?:^|[,{]\s*)${escapedProperty}\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?)`, + "u", + ).exec(source); + return normalizeHandlerSymbol(match?.[1] ?? null); +} + +function readHandlerSymbol(source: string, start: number, end: number): string | null { + const args = splitTopLevelArguments(source.slice(start, end)); + const lastArg = args.at(-1); + const match = + lastArg === undefined + ? null + : /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?$/u.exec(lastArg.trim()); + return normalizeHandlerSymbol(match?.[0] ?? null); +} + +function splitTopLevelArguments(source: string): string[] { + const args: string[] = []; + let start = 0; + let depth = 0; + let quote: string | null = null; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + break; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(" || char === "[" || char === "{") { + depth += 1; + } else if (char === ")" || char === "]" || char === "}") { + depth = Math.max(0, depth - 1); + } else if (char === "," && depth === 0) { + args.push(source.slice(start, index)); + start = index + 1; + } } - const match = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?/u.exec( - source.slice(cursor), - ); - const symbol = match?.[0] ?? null; + args.push(source.slice(start)); + return args.map((arg) => arg.trim()).filter((arg) => arg.length > 0); +} + +function normalizeHandlerSymbol(symbol: string | null): string | null { if ( symbol === null || ["async", "function", "req", "request", "res", "response"].includes(symbol) @@ -472,49 +751,67 @@ function startsRegexLiteral(source: string, index: number): boolean { if (char !== "/" || next === "/" || next === "*" || next === undefined) { return false; } - const previous = previousSignificantChar(source, index); - return previous === null || /[([{=,:;!&|?*~^]/u.test(previous); -} - -function previousSignificantChar(source: string, index: number): string | null { - for (let cursor = index - 1; cursor >= 0; cursor -= 1) { - const char = source[cursor]; - if (char === undefined) { - return null; - } - if (!/\s/u.test(char)) { - return char; - } + const previousSegment = source.slice(0, index).trimEnd(); + if (previousSegment.endsWith("=>")) { + return true; } - return null; + const previousWord = /([A-Za-z_$][A-Za-z0-9_$]*)$/u.exec(previousSegment)?.[1] ?? null; + if (previousWord !== null && regexPrefixKeywords.has(previousWord)) { + return true; + } + const previous = previousSegment.at(-1) ?? null; + return previous === null || /[([{=,:;!&|?*~^]/u.test(previous); } -async function packageSourceFiles(root: string, project: NodeProjectInfo): Promise { +async function packageSourceFiles( + root: string, + project: NodeProjectInfo, + projects: NodeProjectInfo[], +): Promise { const prefixes = [ ...sourceRoots.map((prefix) => packageRelativePath(project.root, prefix)), ...(project.sourceRoot === null ? [] : [project.sourceRoot]), ...rootEntryFiles.map((file) => packageRelativePath(project.root, file)), ]; + const nestedRoots = nestedProjectRoots(project, projects); return (await walk(root, prefixes)) .filter((file) => pathMatchesPrefix(file, project.root === "." ? "" : project.root)) + .filter((file) => !nestedRoots.some((nestedRoot) => pathMatchesPrefix(file, nestedRoot))) .filter(isReviewableServerSourceFile); } -async function packageTestFiles(root: string, project: NodeProjectInfo): Promise { +async function packageTestFiles( + root: string, + project: NodeProjectInfo, + projects: NodeProjectInfo[], +): Promise { const prefixes = [ ...testRoots.map((prefix) => packageRelativePath(project.root, prefix)), ...(project.sourceRoot === null ? [] : [project.sourceRoot]), + ...rootEntryTestFiles.map((file) => packageRelativePath(project.root, file)), ]; - return (await walk(root, prefixes)).filter(isNodeTestPath).slice(0, 200); + const nestedRoots = nestedProjectRoots(project, projects); + return (await walk(root, prefixes)) + .filter((file) => !nestedRoots.some((nestedRoot) => pathMatchesPrefix(file, nestedRoot))) + .filter(isNodeTestPath) + .slice(0, 200); +} + +function nestedProjectRoots(project: NodeProjectInfo, projects: NodeProjectInfo[]): string[] { + return projects + .map((candidate) => candidate.root) + .filter( + (candidateRoot) => + candidateRoot !== "." && + candidateRoot !== project.root && + pathMatchesPrefix(candidateRoot, project.root === "." ? "" : project.root), + ) + .toSorted((left, right) => right.length - left.length); } function associatedTests(files: string[], tests: string[], command: string | null): SeedTestRef[] { - const fileStems = new Set(files.map((file) => basename(file).replace(/\.[^.]+$/u, ""))); const dirs = new Set(files.map((file) => dirname(file))); - const exact = tests.filter((test) => { - const testStem = basename(test).replace(/\.(test|spec)\.[^.]+$/u, ""); - return fileStems.has(testStem); - }); + const exact = tests.filter((test) => files.some((file) => isExactTestForFile(file, test))); const candidates = exact.length > 0 ? exact @@ -522,6 +819,15 @@ function associatedTests(files: string[], tests: string[], command: string | nul return candidates.slice(0, 8).map((path) => ({ path, command })); } +function isExactTestForFile(file: string, test: string): boolean { + const fileStem = basename(file).replace(/\.[^.]+$/u, ""); + const testStem = basename(test).replace(/\.(test|spec)\.[^.]+$/u, ""); + if (fileStem !== testStem) { + return false; + } + return fileStem !== "index" || dirname(file) === dirname(test); +} + function isReviewableServerSourceFile(path: string): boolean { return ( /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/u.test(path) &&