From 29e3458328f0673e468469ea1d745fe9b5d1e825 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 17:51:09 +0530 Subject: [PATCH 1/8] fix(mapper): map Fastify plugin callback routes --- src/mapper.test.ts | 74 ++++++++++ src/mappers/node-routes.ts | 280 ++++++++++++++++++++++++++++++++++--- 2 files changed, 335 insertions(+), 19 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5265b83..81d4399 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1912,12 +1912,75 @@ describe("mapFeatures", () => { root, "src/fastify-plugin.ts", [ + "import fastifyPlugin from 'fastify-plugin';", "import { FastifyInstance } from 'fastify';", "", "export async function routes(fastify: FastifyInstance) {", " fastify.get('/plugin-users', listPluginUsers);", "}", + "export async function appRoutes(app: FastifyInstance) {", + " app.get('/plugin-app-users', listPluginAppUsers);", + "}", + "const app = createHttpServer();", + "app.get('/not-plugin-app-typed', ignoredApp);", + "export const serverRoutes = fastifyPlugin(async function routes(server) {", + " server.get('/plugin-server-users', listPluginServerUsers);", + "});", + "export const arrowRoutes = fastifyPlugin(async (app) => {", + " app.get('/plugin-arrow-users', listPluginArrowUsers);", + "});", + "export const typedArrowRoutes = async (server: FastifyInstance): Promise => {", + " server.get('/plugin-typed-arrow-users', listPluginTypedArrowUsers);", + "};", + "const server = createHttpServer();", + "server.get('/not-plugin-server', ignoredServer);", + 'export async function inlineRoutes(inlineApp: import("fastify").FastifyInstance) {', + ' inlineApp.get("/plugin-inline-users", listPluginInlineUsers);', + "}", + 'export const inlineArrowRoutes = async (inlineServer: import("fastify").FastifyInstance): Promise => {', + ' inlineServer.get("/plugin-inline-arrow-users", listPluginInlineArrowUsers);', + "};", "function listPluginUsers() {}", + "function listPluginAppUsers() {}", + "function listPluginServerUsers() {}", + "function listPluginArrowUsers() {}", + "function listPluginTypedArrowUsers() {}", + "function listPluginInlineUsers() {}", + "function listPluginInlineArrowUsers() {}", + "function createHttpServer() { return { get() {} }; }", + "function ignoredApp() {}", + "function ignoredServer() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/fastify-multiline-import.ts", + [ + "import {", + " FastifyInstance,", + "} from 'fastify';", + "", + "export async function multilineRoutes(app: FastifyInstance) {", + " app.get('/plugin-multiline-users', listPluginMultilineUsers);", + "}", + "function listPluginMultilineUsers() {}", + "", + ].join("\n"), + ); + await writeFixture( + root, + "src/not-fastify-plugin.ts", + [ + 'import { FastifyInstance } from "./types"', + 'import Fastify from "fastify"', + "export async function genericAppRoutes(app) {", + ' app.get("/not-plugin-app", ignored);', + "}", + "export async function shadowInstanceRoutes(instance: FastifyInstance) {", + ' instance.get("/shadow-fastify-instance", ignored);', + "}", + "function ignored() {}", "", ].join("\n"), ); @@ -2089,6 +2152,13 @@ describe("mapFeatures", () => { "Fastify route GET /route-status", "Fastify route POST /webhook/github", "Fastify route GET /plugin-users", + "Fastify route GET /plugin-app-users", + "Fastify route GET /plugin-server-users", + "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-typed-arrow-users", + "Fastify route GET /plugin-inline-users", + "Fastify route GET /plugin-inline-arrow-users", + "Fastify route GET /plugin-multiline-users", "Hono route GET /api/items", "Hono route DELETE /sessions/:id", ]), @@ -2111,6 +2181,10 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Express route GET /assigned-not-router"); expect(titles).not.toContain("Express route GET /dynamic/"); expect(titles).not.toContain("Fastify route GET /dynamic/"); + expect(titles).not.toContain("Fastify route GET /not-plugin-app"); + expect(titles).not.toContain("Fastify route GET /not-plugin-app-typed"); + expect(titles).not.toContain("Fastify route GET /not-plugin-server"); + expect(titles).not.toContain("Fastify route GET /shadow-fastify-instance"); expect(titles).not.toContain("Fastify route GET /concat-"); expect(titles).not.toContain("Express route DELETE /reports"); expect(admin?.source).toBe("express-route"); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 9a003bf..f60176e 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -158,7 +158,9 @@ function parseServerRoutes( const routes: ServerRoute[] = []; for (const framework of projectFrameworks) { const targets = routeTargetNames(source, framework); - if (targets.size === 0) { + const scopedFastifyRoutes = + framework === "fastify" ? fastifyScopedCallbackRoutes(source, filePath) : []; + if (targets.size === 0 && scopedFastifyRoutes.length === 0) { continue; } routes.push(...directMethodRoutes(source, filePath, framework, targets)); @@ -166,6 +168,7 @@ function parseServerRoutes( routes.push(...expressRouteChains(source, filePath, targets)); } else if (framework === "fastify") { routes.push(...fastifyRouteObjects(source, filePath, targets)); + routes.push(...scopedFastifyRoutes); } } return uniqueRoutes(routes); @@ -317,7 +320,7 @@ function routeTargetNames(source: string, framework: ServerFramework): Set { return names; } -function functionParameterTargets(source: string, preferredName: string): Set { +function fastifyParameterTargets(source: 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)) { + for (const callback of functionParameterCallbacks(source)) { + for (const parameter of callback.parameters) { + if (parameter.name === "fastify") { + names.add(parameter.name); + } + } + } + return names; +} + +function hasFastifyPluginImport(source: string): boolean { + const pattern = + /\b(?:from\s*["']fastify-plugin["']|require\s*\(\s*["']fastify-plugin["']\s*\))/gu; + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + if (!isInsideCommentOrString(source, match.index ?? 0)) { + return true; + } + } + return false; +} + +function hasFastifyInstanceImport(source: string): boolean { + const pattern = /\bimport\b/gu; + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + const importIndex = match.index ?? 0; + if ( + isInsideCommentOrString(source, importIndex) || + !isImportDeclarationStart(source, importIndex) + ) { + continue; + } + const clause = readFastifyStaticImportClause(source, importIndex); + if (clause !== null && /\bFastifyInstance\b/u.test(clause)) { + return true; + } + } + return false; +} + +function readFastifyStaticImportClause(source: string, importIndex: number): string | null { + let cursor = importIndex + "import".length; + cursor = skipWhitespaceAndComments(source, cursor); + if ( + source[cursor] === "(" || + source[cursor] === "." || + source[cursor] === "'" || + source[cursor] === '"' + ) { + return null; + } + if (!isImportClauseStart(source[cursor])) { + return null; + } + const clauseStart = cursor; + const limit = Math.min(source.length, importIndex + 500); + while (cursor < limit) { + const char = source[cursor]; + const next = source[cursor + 1]; + if (char === undefined) { + break; + } + if (char === ";") { + return null; + } + if (char === "/" && next === "/") { + cursor = skipLineComment(source, cursor + 2); + continue; + } + if (char === "/" && next === "*") { + cursor = skipBlockComment(source, cursor + 2); + continue; + } + if (char === "'" || char === '"' || char === "`") { + cursor = skipQuoted(source, cursor, char); + continue; + } + if (isKeywordAt(source, cursor, "from")) { + const specifier = readImportSpecifier(source, cursor + "from".length); + if (specifier === null) { + cursor += "from".length; continue; } - for (const parameter of parameterNames(match[1] ?? "")) { - if (parameter === preferredName) { - names.add(parameter); - } + return specifier.value === "fastify" ? source.slice(clauseStart, cursor) : null; + } + cursor += 1; + } + return null; +} + +function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRoute[] { + const hasPluginImport = hasFastifyPluginImport(source); + const hasInstanceImport = hasFastifyInstanceImport(source); + const routes: ServerRoute[] = []; + for (const callback of [ + ...functionParameterCallbacks(source), + ...inlineFastifyInstanceCallbacks(source), + ]) { + const targets = new Set(); + for (const parameter of callback.parameters) { + if ( + isFastifyInstanceParameter(parameter.source, hasInstanceImport) || + (hasPluginImport && + isCommonFastifyPluginParameterName(parameter.name) && + isInsideFastifyPluginCall(source, callback.index)) + ) { + targets.add(parameter.name); } } + if (targets.size === 0) { + continue; + } + const body = functionBodySource(source, callback.bodySearchStart); + if (body === null) { + continue; + } + routes.push(...directMethodRoutes(body, filePath, "fastify", targets)); + routes.push(...fastifyRouteObjects(body, filePath, targets)); } - return names; + return routes; +} + +function inlineFastifyInstanceCallbacks(source: string): Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; +}> { + const callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }> = []; + const functionPattern = + /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b[^)]*\)/gu; + functionPattern.lastIndex = 0; + for (const match of source.matchAll(functionPattern)) { + const matchIndex = match.index ?? 0; + addInlineFastifyInstanceCallback(callbacks, source, matchIndex, match[0].length, match[1]); + } + const arrowPattern = + /(^|[^A-Za-z0-9_$])((?:async\s*)?\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b[^)]*\)\s*(?::\s*[^=]+?)?=>)/gu; + arrowPattern.lastIndex = 0; + for (const match of source.matchAll(arrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addInlineFastifyInstanceCallback( + callbacks, + source, + callbackIndex, + match[2]?.length ?? 0, + match[3], + ); + } + return callbacks; +} + +function addInlineFastifyInstanceCallback( + callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }>, + source: string, + callbackIndex: number, + matchLength: number, + name: string | undefined, +): void { + if (name === undefined || isInsideCommentOrString(source, callbackIndex)) { + return; + } + callbacks.push({ + index: callbackIndex, + bodySearchStart: callbackIndex + matchLength, + parameters: [ + { + name, + source: `${name}: import("fastify").FastifyInstance`, + }, + ], + }); +} + +function functionParameterCallbacks(source: string): Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; +}> { + const callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }> = []; + const functionPattern = /(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\(([^)]*)\)/gu; + functionPattern.lastIndex = 0; + for (const match of source.matchAll(functionPattern)) { + const matchIndex = match.index ?? 0; + addFunctionParameterCallback(callbacks, source, matchIndex, match[0].length, match[1]); + } + const arrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s*)?\(([^)]*)\)\s*(?::\s*[^=]+?)?=>)/gu; + arrowPattern.lastIndex = 0; + for (const match of source.matchAll(arrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addFunctionParameterCallback(callbacks, source, callbackIndex, match[2]?.length ?? 0, match[3]); + } + return callbacks; +} + +function addFunctionParameterCallback( + callbacks: Array<{ + index: number; + bodySearchStart: number; + parameters: Array<{ name: string; source: string }>; + }>, + source: string, + callbackIndex: number, + matchLength: number, + parameters: string | undefined, +): void { + if (isInsideCommentOrString(source, callbackIndex)) { + return; + } + callbacks.push({ + index: callbackIndex, + bodySearchStart: callbackIndex + matchLength, + parameters: functionParameters(parameters ?? ""), + }); +} + +function functionBodySource(source: string, bodySearchStart: number): string | null { + const bodyStart = skipWhitespace(source, bodySearchStart); + if (source[bodyStart] !== "{") { + return null; + } + const bodyEnd = endOfObject(source, bodyStart + 1); + return bodyEnd === null ? null : source.slice(bodyStart + 1, bodyEnd - 1); } -function parameterNames(parameters: string): string[] { +function functionParameters(parameters: string): Array<{ name: string; source: 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); + .map((parameter) => { + const source = parameter.trim(); + const name = /^\.{0,3}\s*([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(source)?.[1]; + return name === undefined ? null : { name, source }; + }) + .filter((parameter): parameter is { name: string; source: string } => parameter !== null); +} + +function isFastifyInstanceParameter(parameter: string, hasInstanceImport: boolean): boolean { + return ( + /:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b/u.test(parameter) || + (hasInstanceImport && /:\s*FastifyInstance\b/u.test(parameter)) + ); +} + +function isInsideFastifyPluginCall(source: string, functionIndex: number): boolean { + const prefix = source.slice(Math.max(0, functionIndex - 120), functionIndex); + return /\bfastifyPlugin\s*\(\s*$/u.test(prefix); +} + +function isCommonFastifyPluginParameterName(name: string): boolean { + return name === "app" || name === "server"; } function isRouteTarget(targets: ReadonlySet, target: string): boolean { From 56b98f2615dd993607bb29ec9f37404e17512596 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 18:53:50 +0530 Subject: [PATCH 2/8] fix(mapper): handle fastify plugin aliases --- src/mapper.test.ts | 24 +++++++++ src/mappers/node-routes.ts | 100 ++++++++++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 81d4399..2e7df70 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1913,6 +1913,8 @@ describe("mapFeatures", () => { "src/fastify-plugin.ts", [ "import fastifyPlugin from 'fastify-plugin';", + "import fp from 'fastify-plugin';", + "import cjsPlugin = require('fastify-plugin');", "import { FastifyInstance } from 'fastify';", "", "export async function routes(fastify: FastifyInstance) {", @@ -1929,6 +1931,20 @@ describe("mapFeatures", () => { "export const arrowRoutes = fastifyPlugin(async (app) => {", " app.get('/plugin-arrow-users', listPluginArrowUsers);", "});", + "export const aliasedRoutes = fp(async (app) => {", + " app.get('/plugin-aliased-users', listPluginAliasedUsers);", + "});", + "type PluginOptions = { prefix: string };", + "export const genericRoutes = fastifyPlugin(async (server) => {", + " server.get('/plugin-generic-users', listPluginGenericUsers);", + "});", + "export const importEqualsRoutes = cjsPlugin(async (server) => {", + " server.get('/plugin-import-equals-users', listPluginImportEqualsUsers);", + "});", + "const defaultPlugin = require('fastify-plugin').default;", + "export const defaultRequireRoutes = defaultPlugin(async (app) => {", + " app.get('/plugin-default-require-users', listPluginDefaultRequireUsers);", + "});", "export const typedArrowRoutes = async (server: FastifyInstance): Promise => {", " server.get('/plugin-typed-arrow-users', listPluginTypedArrowUsers);", "};", @@ -1944,6 +1960,10 @@ describe("mapFeatures", () => { "function listPluginAppUsers() {}", "function listPluginServerUsers() {}", "function listPluginArrowUsers() {}", + "function listPluginAliasedUsers() {}", + "function listPluginGenericUsers() {}", + "function listPluginImportEqualsUsers() {}", + "function listPluginDefaultRequireUsers() {}", "function listPluginTypedArrowUsers() {}", "function listPluginInlineUsers() {}", "function listPluginInlineArrowUsers() {}", @@ -2155,6 +2175,10 @@ describe("mapFeatures", () => { "Fastify route GET /plugin-app-users", "Fastify route GET /plugin-server-users", "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-aliased-users", + "Fastify route GET /plugin-generic-users", + "Fastify route GET /plugin-import-equals-users", + "Fastify route GET /plugin-default-require-users", "Fastify route GET /plugin-typed-arrow-users", "Fastify route GET /plugin-inline-users", "Fastify route GET /plugin-inline-arrow-users", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index f60176e..c3bc4cf 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -499,16 +499,77 @@ function fastifyParameterTargets(source: string): Set { return names; } -function hasFastifyPluginImport(source: string): boolean { - const pattern = - /\b(?:from\s*["']fastify-plugin["']|require\s*\(\s*["']fastify-plugin["']\s*\))/gu; +function fastifyPluginCallTargetNames(source: string): Set { + const names = new Set(); + for (const clause of fastifyPluginImportClauses(source)) { + addFastifyPluginImportNames(names, clause); + } + for (const pattern of [ + /\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*:\s*[^=;]+)?\s*=\s*require\s*\(\s*["']fastify-plugin["']\s*\)(?:\s*\.\s*default)?/gu, + /\bimport\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*require\s*\(\s*["']fastify-plugin["']\s*\)/gu, + ]) { + pattern.lastIndex = 0; + for (const match of source.matchAll(pattern)) { + if (isInsideCommentOrString(source, match.index ?? 0)) { + continue; + } + const name = match[1]; + if (name !== undefined) { + names.add(name); + } + } + } + return names; +} + +function fastifyPluginImportClauses(source: string): string[] { + const clauses: string[] = []; + const pattern = /\bimport\b/gu; pattern.lastIndex = 0; for (const match of source.matchAll(pattern)) { - if (!isInsideCommentOrString(source, match.index ?? 0)) { - return true; + const importIndex = match.index ?? 0; + if ( + isInsideCommentOrString(source, importIndex) || + !isImportDeclarationStart(source, importIndex) + ) { + continue; + } + const clause = readStaticImportClause(source, importIndex, "fastify-plugin"); + if (clause !== null) { + clauses.push(clause); + } + } + return clauses; +} + +function addFastifyPluginImportNames(names: Set, clause: string): void { + const namespace = /^\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/u.exec(clause.trim())?.[1]; + if (namespace !== undefined) { + names.add(namespace); + return; + } + + const defaultName = /^([A-Za-z_$][A-Za-z0-9_$]*)\b/u.exec(clause.trim())?.[1]; + if (defaultName !== undefined && defaultName !== "type") { + names.add(defaultName); + } + + const named = /\{([^}]*)\}/u.exec(clause)?.[1]; + if (named === undefined) { + return; + } + for (const part of named.split(",")) { + const binding = part.trim(); + if (binding.startsWith("type ")) { + continue; + } + const match = /^(?:default|fastifyPlugin)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/u.exec( + binding, + ); + if (match !== null) { + names.add(match[1] ?? binding); } } - return false; } function hasFastifyInstanceImport(source: string): boolean { @@ -531,6 +592,14 @@ function hasFastifyInstanceImport(source: string): boolean { } function readFastifyStaticImportClause(source: string, importIndex: number): string | null { + return readStaticImportClause(source, importIndex, "fastify"); +} + +function readStaticImportClause( + source: string, + importIndex: number, + moduleName: string, +): string | null { let cursor = importIndex + "import".length; cursor = skipWhitespaceAndComments(source, cursor); if ( @@ -573,7 +642,7 @@ function readFastifyStaticImportClause(source: string, importIndex: number): str cursor += "from".length; continue; } - return specifier.value === "fastify" ? source.slice(clauseStart, cursor) : null; + return specifier.value === moduleName ? source.slice(clauseStart, cursor) : null; } cursor += 1; } @@ -581,7 +650,7 @@ function readFastifyStaticImportClause(source: string, importIndex: number): str } function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRoute[] { - const hasPluginImport = hasFastifyPluginImport(source); + const pluginCallTargets = fastifyPluginCallTargetNames(source); const hasInstanceImport = hasFastifyInstanceImport(source); const routes: ServerRoute[] = []; for (const callback of [ @@ -592,9 +661,9 @@ function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRo for (const parameter of callback.parameters) { if ( isFastifyInstanceParameter(parameter.source, hasInstanceImport) || - (hasPluginImport && + (pluginCallTargets.size > 0 && isCommonFastifyPluginParameterName(parameter.name) && - isInsideFastifyPluginCall(source, callback.index)) + isInsideFastifyPluginCall(source, callback.index, pluginCallTargets)) ) { targets.add(parameter.name); } @@ -688,7 +757,7 @@ function functionParameterCallbacks(source: string): Array<{ const matchIndex = match.index ?? 0; addFunctionParameterCallback(callbacks, source, matchIndex, match[0].length, match[1]); } - const arrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s*)?\(([^)]*)\)\s*(?::\s*[^=]+?)?=>)/gu; + const arrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s*)?\(([^()]*)\)\s*(?::\s*[^=]+?)?=>)/gu; arrowPattern.lastIndex = 0; for (const match of source.matchAll(arrowPattern)) { const prefixLength = match[1]?.length ?? 0; @@ -746,9 +815,14 @@ function isFastifyInstanceParameter(parameter: string, hasInstanceImport: boolea ); } -function isInsideFastifyPluginCall(source: string, functionIndex: number): boolean { +function isInsideFastifyPluginCall( + source: string, + functionIndex: number, + pluginCallTargets: ReadonlySet, +): boolean { const prefix = source.slice(Math.max(0, functionIndex - 120), functionIndex); - return /\bfastifyPlugin\s*\(\s*$/u.test(prefix); + const targetPattern = [...pluginCallTargets].map(escapeRegExp).join("|"); + return new RegExp(`\\b(?:${targetPattern})${genericArguments}\\s*\\(\\s*$`, "u").test(prefix); } function isCommonFastifyPluginParameterName(name: string): boolean { From 5d35e2ba6ef0aaa758d105429afab4f37869a5e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:35:02 +0100 Subject: [PATCH 3/8] docs(changelog): note Fastify plugin callback routes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d916910..9c1f101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi. - Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi. +- Fixed Fastify plugin callback route mapping for typed parameters and plugin aliases, thanks @rohitjavvadi. - Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. ## 0.3.0 - 2026-05-18 From f294d9840ed957551452e2fc1dbc1eaf220290e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:41:06 +0100 Subject: [PATCH 4/8] fix(mapper): handle Fastify function return types --- src/mapper.test.ts | 15 +++++++++ src/mappers/node-routes.ts | 67 +++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 66563c7..2f8cb29 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2419,11 +2419,20 @@ describe("mapFeatures", () => { "export async function appRoutes(app: FastifyInstance) {", " app.get('/plugin-app-users', listPluginAppUsers);", "}", + "export async function typedReturnRoutes(app: FastifyInstance): Promise {", + " app.get('/plugin-typed-return-users', listPluginTypedReturnUsers);", + "}", + "export async function typedObjectReturnRoutes(app: FastifyInstance): Promise<{ ok: true }> {", + " app.get('/plugin-typed-object-return-users', listPluginTypedObjectReturnUsers);", + "}", "const app = createHttpServer();", "app.get('/not-plugin-app-typed', ignoredApp);", "export const serverRoutes = fastifyPlugin(async function routes(server) {", " server.get('/plugin-server-users', listPluginServerUsers);", "});", + "export const serverReturnRoutes = fastifyPlugin(function routes(server): Promise {", + " server.get('/plugin-server-return-users', listPluginServerReturnUsers);", + "});", "export const arrowRoutes = fastifyPlugin(async (app) => {", " app.get('/plugin-arrow-users', listPluginArrowUsers);", "});", @@ -2454,7 +2463,10 @@ describe("mapFeatures", () => { "};", "function listPluginUsers() {}", "function listPluginAppUsers() {}", + "function listPluginTypedReturnUsers() {}", + "function listPluginTypedObjectReturnUsers() {}", "function listPluginServerUsers() {}", + "function listPluginServerReturnUsers() {}", "function listPluginArrowUsers() {}", "function listPluginAliasedUsers() {}", "function listPluginGenericUsers() {}", @@ -2669,7 +2681,10 @@ describe("mapFeatures", () => { "Fastify route POST /webhook/github", "Fastify route GET /plugin-users", "Fastify route GET /plugin-app-users", + "Fastify route GET /plugin-typed-return-users", + "Fastify route GET /plugin-typed-object-return-users", "Fastify route GET /plugin-server-users", + "Fastify route GET /plugin-server-return-users", "Fastify route GET /plugin-arrow-users", "Fastify route GET /plugin-aliased-users", "Fastify route GET /plugin-generic-users", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index fd1f77c..8cf3fda 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -792,7 +792,10 @@ function addFunctionParameterCallback( } function functionBodySource(source: string, bodySearchStart: number): string | null { - const bodyStart = skipWhitespace(source, bodySearchStart); + let bodyStart = skipWhitespaceAndComments(source, bodySearchStart); + if (source[bodyStart] === ":") { + bodyStart = skipWhitespaceAndComments(source, skipFunctionReturnType(source, bodyStart + 1)); + } if (source[bodyStart] !== "{") { return null; } @@ -800,6 +803,68 @@ function functionBodySource(source: string, bodySearchStart: number): string | n return bodyEnd === null ? null : source.slice(bodyStart + 1, bodyEnd - 1); } +function skipFunctionReturnType(source: string, start: number): number { + let quote: string | null = null; + let escaped = false; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === undefined) { + return index; + } + 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 === "(") { + parenDepth += 1; + } else if (char === ")") { + parenDepth = Math.max(0, parenDepth - 1); + } else if (char === "[") { + bracketDepth += 1; + } else if (char === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + } else if (char === "{") { + if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + const previous = previousSignificantChar(source, index - 1, start); + if ( + previous !== ":" && + previous !== "|" && + previous !== "&" && + previous !== "," && + previous !== "<" + ) { + return index; + } + } + braceDepth += 1; + } else if (char === "}") { + braceDepth = Math.max(0, braceDepth - 1); + } + } + return source.length; +} + +function previousSignificantChar(source: string, start: number, lowerBound: number): string | null { + for (let index = start; index >= lowerBound; index -= 1) { + const char = source[index]; + if (char !== undefined && !/\s/u.test(char)) { + return char; + } + } + return ":"; +} + function functionParameters(parameters: string): Array<{ name: string; source: string }> { return parameters .split(",") From 283c5875803dfe94151b698ef514cd5d12959e5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:44:35 +0100 Subject: [PATCH 5/8] fix(mapper): accept Fastify plugin instance aliases --- src/mapper.test.ts | 8 ++++++++ src/mappers/node-routes.ts | 8 ++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 2f8cb29..65c2856 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2436,6 +2436,10 @@ describe("mapFeatures", () => { "export const arrowRoutes = fastifyPlugin(async (app) => {", " app.get('/plugin-arrow-users', listPluginArrowUsers);", "});", + "export const instanceRoutes = fp(async (instance, options) => {", + " instance.get('/plugin-instance-users', listPluginInstanceUsers);", + " options.get('/not-plugin-options', ignoredOptions);", + "});", "export const aliasedRoutes = fp(async (app) => {", " app.get('/plugin-aliased-users', listPluginAliasedUsers);", "});", @@ -2468,6 +2472,7 @@ describe("mapFeatures", () => { "function listPluginServerUsers() {}", "function listPluginServerReturnUsers() {}", "function listPluginArrowUsers() {}", + "function listPluginInstanceUsers() {}", "function listPluginAliasedUsers() {}", "function listPluginGenericUsers() {}", "function listPluginImportEqualsUsers() {}", @@ -2478,6 +2483,7 @@ describe("mapFeatures", () => { "function createHttpServer() { return { get() {} }; }", "function ignoredApp() {}", "function ignoredServer() {}", + "function ignoredOptions() {}", "", ].join("\n"), ); @@ -2686,6 +2692,7 @@ describe("mapFeatures", () => { "Fastify route GET /plugin-server-users", "Fastify route GET /plugin-server-return-users", "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-instance-users", "Fastify route GET /plugin-aliased-users", "Fastify route GET /plugin-generic-users", "Fastify route GET /plugin-import-equals-users", @@ -2719,6 +2726,7 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Fastify route GET /not-plugin-app"); expect(titles).not.toContain("Fastify route GET /not-plugin-app-typed"); expect(titles).not.toContain("Fastify route GET /not-plugin-server"); + expect(titles).not.toContain("Fastify route GET /not-plugin-options"); expect(titles).not.toContain("Fastify route GET /shadow-fastify-instance"); expect(titles).not.toContain("Fastify route GET /concat-"); expect(titles).not.toContain("Express route DELETE /reports"); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 8cf3fda..2929167 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -661,11 +661,11 @@ function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRo ...inlineFastifyInstanceCallbacks(source), ]) { const targets = new Set(); - for (const parameter of callback.parameters) { + for (const [index, parameter] of callback.parameters.entries()) { if ( isFastifyInstanceParameter(parameter.source, hasInstanceImport) || (pluginCallTargets.size > 0 && - isCommonFastifyPluginParameterName(parameter.name) && + index === 0 && isInsideFastifyPluginCall(source, callback.index, pluginCallTargets)) ) { targets.add(parameter.name); @@ -893,10 +893,6 @@ function isInsideFastifyPluginCall( return new RegExp(`\\b(?:${targetPattern})${genericArguments}\\s*\\(\\s*$`, "u").test(prefix); } -function isCommonFastifyPluginParameterName(name: string): boolean { - return name === "app" || name === "server"; -} - function isRouteTarget(targets: ReadonlySet, target: string): boolean { return !target.includes(".") && targets.has(target); } From a83895b090a60bbd9e888d9bd4ad2e8902835c2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:49:44 +0100 Subject: [PATCH 6/8] fix(mapper): ignore comments in object scans --- src/mapper.test.ts | 6 ++++++ src/mappers/node-routes.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 65c2856..17986a0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2440,6 +2440,10 @@ describe("mapFeatures", () => { " instance.get('/plugin-instance-users', listPluginInstanceUsers);", " options.get('/not-plugin-options', ignoredOptions);", "});", + "export const commentRoutes = fp(async (instance) => {", + " // }", + " instance.get('/plugin-comment-users', listPluginCommentUsers);", + "});", "export const aliasedRoutes = fp(async (app) => {", " app.get('/plugin-aliased-users', listPluginAliasedUsers);", "});", @@ -2473,6 +2477,7 @@ describe("mapFeatures", () => { "function listPluginServerReturnUsers() {}", "function listPluginArrowUsers() {}", "function listPluginInstanceUsers() {}", + "function listPluginCommentUsers() {}", "function listPluginAliasedUsers() {}", "function listPluginGenericUsers() {}", "function listPluginImportEqualsUsers() {}", @@ -2693,6 +2698,7 @@ describe("mapFeatures", () => { "Fastify route GET /plugin-server-return-users", "Fastify route GET /plugin-arrow-users", "Fastify route GET /plugin-instance-users", + "Fastify route GET /plugin-comment-users", "Fastify route GET /plugin-aliased-users", "Fastify route GET /plugin-generic-users", "Fastify route GET /plugin-import-equals-users", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 2929167..87a9408 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -1124,7 +1124,13 @@ function endOfObject(source: string, start: number): number | null { } continue; } - if (char === "'" || char === '"' || char === "`") { + if (char === "/" && source[index + 1] === "/") { + const newline = source.indexOf("\n", index + 2); + index = newline < 0 ? source.length : newline; + } else if (char === "/" && source[index + 1] === "*") { + const close = source.indexOf("*/", index + 2); + index = close < 0 ? source.length : close + 1; + } else if (char === "'" || char === '"' || char === "`") { quote = char; } else if (char === "{") { depth += 1; From 46cdf32e96fa4b1706e0c9084f314b300f996fea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:54:01 +0100 Subject: [PATCH 7/8] fix(mapper): handle bare Fastify plugin arrows --- src/mapper.test.ts | 5 +++++ src/mappers/node-routes.ts | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 17986a0..1807a27 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2436,6 +2436,9 @@ describe("mapFeatures", () => { "export const arrowRoutes = fastifyPlugin(async (app) => {", " app.get('/plugin-arrow-users', listPluginArrowUsers);", "});", + "export const bareArrowRoutes = fastifyPlugin(async bareApp => {", + " bareApp.get('/plugin-bare-arrow-users', listPluginBareArrowUsers);", + "});", "export const instanceRoutes = fp(async (instance, options) => {", " instance.get('/plugin-instance-users', listPluginInstanceUsers);", " options.get('/not-plugin-options', ignoredOptions);", @@ -2476,6 +2479,7 @@ describe("mapFeatures", () => { "function listPluginServerUsers() {}", "function listPluginServerReturnUsers() {}", "function listPluginArrowUsers() {}", + "function listPluginBareArrowUsers() {}", "function listPluginInstanceUsers() {}", "function listPluginCommentUsers() {}", "function listPluginAliasedUsers() {}", @@ -2697,6 +2701,7 @@ describe("mapFeatures", () => { "Fastify route GET /plugin-server-users", "Fastify route GET /plugin-server-return-users", "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-bare-arrow-users", "Fastify route GET /plugin-instance-users", "Fastify route GET /plugin-comment-users", "Fastify route GET /plugin-aliased-users", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 87a9408..83289c0 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -767,6 +767,13 @@ function functionParameterCallbacks(source: string): Array<{ const callbackIndex = (match.index ?? 0) + prefixLength; addFunctionParameterCallback(callbacks, source, callbackIndex, match[2]?.length ?? 0, match[3]); } + const bareArrowPattern = /(^|[^A-Za-z0-9_$])((?:async\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=>)/gu; + bareArrowPattern.lastIndex = 0; + for (const match of source.matchAll(bareArrowPattern)) { + const prefixLength = match[1]?.length ?? 0; + const callbackIndex = (match.index ?? 0) + prefixLength; + addFunctionParameterCallback(callbacks, source, callbackIndex, match[2]?.length ?? 0, match[3]); + } return callbacks; } From cbb3f7dfa1e256ed9f9b430c8c5dc6cf9b41a5fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 03:01:11 +0100 Subject: [PATCH 8/8] fix(mapper): handle Fastify plugin callback edge cases --- src/mapper.test.ts | 11 +++++++++ src/mappers/node-routes.ts | 50 +++++++++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 1807a27..f3d8285 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2412,6 +2412,7 @@ describe("mapFeatures", () => { "import fp from 'fastify-plugin';", "import cjsPlugin = require('fastify-plugin');", "import { FastifyInstance } from 'fastify';", + "import type { FastifyInstance as FastifyApp } from 'fastify';", "", "export async function routes(fastify: FastifyInstance) {", " fastify.get('/plugin-users', listPluginUsers);", @@ -2425,6 +2426,9 @@ describe("mapFeatures", () => { "export async function typedObjectReturnRoutes(app: FastifyInstance): Promise<{ ok: true }> {", " app.get('/plugin-typed-object-return-users', listPluginTypedObjectReturnUsers);", "}", + "export async function aliasedTypeRoutes(app: FastifyApp) {", + " app.get('/plugin-aliased-type-users', listPluginAliasedTypeUsers);", + "}", "const app = createHttpServer();", "app.get('/not-plugin-app-typed', ignoredApp);", "export const serverRoutes = fastifyPlugin(async function routes(server) {", @@ -2447,6 +2451,9 @@ describe("mapFeatures", () => { " // }", " instance.get('/plugin-comment-users', listPluginCommentUsers);", "});", + "export const commentedArgumentRoutes = fp( /* routes */ async (commentedApp) => {", + " commentedApp.get('/plugin-commented-argument-users', listPluginCommentedArgumentUsers);", + "});", "export const aliasedRoutes = fp(async (app) => {", " app.get('/plugin-aliased-users', listPluginAliasedUsers);", "});", @@ -2476,12 +2483,14 @@ describe("mapFeatures", () => { "function listPluginAppUsers() {}", "function listPluginTypedReturnUsers() {}", "function listPluginTypedObjectReturnUsers() {}", + "function listPluginAliasedTypeUsers() {}", "function listPluginServerUsers() {}", "function listPluginServerReturnUsers() {}", "function listPluginArrowUsers() {}", "function listPluginBareArrowUsers() {}", "function listPluginInstanceUsers() {}", "function listPluginCommentUsers() {}", + "function listPluginCommentedArgumentUsers() {}", "function listPluginAliasedUsers() {}", "function listPluginGenericUsers() {}", "function listPluginImportEqualsUsers() {}", @@ -2698,12 +2707,14 @@ describe("mapFeatures", () => { "Fastify route GET /plugin-app-users", "Fastify route GET /plugin-typed-return-users", "Fastify route GET /plugin-typed-object-return-users", + "Fastify route GET /plugin-aliased-type-users", "Fastify route GET /plugin-server-users", "Fastify route GET /plugin-server-return-users", "Fastify route GET /plugin-arrow-users", "Fastify route GET /plugin-bare-arrow-users", "Fastify route GET /plugin-instance-users", "Fastify route GET /plugin-comment-users", + "Fastify route GET /plugin-commented-argument-users", "Fastify route GET /plugin-aliased-users", "Fastify route GET /plugin-generic-users", "Fastify route GET /plugin-import-equals-users", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 83289c0..2a6ab09 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -575,7 +575,8 @@ function addFastifyPluginImportNames(names: Set, clause: string): void { } } -function hasFastifyInstanceImport(source: string): boolean { +function fastifyInstanceTypeNames(source: string): Set { + const names = new Set(); const pattern = /\bimport\b/gu; pattern.lastIndex = 0; for (const match of source.matchAll(pattern)) { @@ -587,11 +588,25 @@ function hasFastifyInstanceImport(source: string): boolean { continue; } const clause = readFastifyStaticImportClause(source, importIndex); - if (clause !== null && /\bFastifyInstance\b/u.test(clause)) { - return true; + if (clause !== null) { + addFastifyInstanceTypeNames(names, clause); + } + } + return names; +} + +function addFastifyInstanceTypeNames(names: Set, clause: string): void { + const named = /\{([^}]*)\}/u.exec(clause)?.[1]; + if (named === undefined) { + return; + } + for (const part of named.split(",")) { + const binding = part.trim().replace(/^type\s+/u, ""); + const match = /^FastifyInstance(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/u.exec(binding); + if (match !== null) { + names.add(match[1] ?? "FastifyInstance"); } } - return false; } function readFastifyStaticImportClause(source: string, importIndex: number): string | null { @@ -654,7 +669,7 @@ function readStaticImportClause( function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRoute[] { const pluginCallTargets = fastifyPluginCallTargetNames(source); - const hasInstanceImport = hasFastifyInstanceImport(source); + const instanceTypeNames = fastifyInstanceTypeNames(source); const routes: ServerRoute[] = []; for (const callback of [ ...functionParameterCallbacks(source), @@ -663,7 +678,7 @@ function fastifyScopedCallbackRoutes(source: string, filePath: string): ServerRo const targets = new Set(); for (const [index, parameter] of callback.parameters.entries()) { if ( - isFastifyInstanceParameter(parameter.source, hasInstanceImport) || + isFastifyInstanceParameter(parameter.source, instanceTypeNames) || (pluginCallTargets.size > 0 && index === 0 && isInsideFastifyPluginCall(source, callback.index, pluginCallTargets)) @@ -883,11 +898,19 @@ function functionParameters(parameters: string): Array<{ name: string; source: s .filter((parameter): parameter is { name: string; source: string } => parameter !== null); } -function isFastifyInstanceParameter(parameter: string, hasInstanceImport: boolean): boolean { - return ( - /:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b/u.test(parameter) || - (hasInstanceImport && /:\s*FastifyInstance\b/u.test(parameter)) - ); +function isFastifyInstanceParameter( + parameter: string, + instanceTypeNames: ReadonlySet, +): boolean { + if (/:\s*import\s*\(\s*["']fastify["']\s*\)\s*\.\s*FastifyInstance\b/u.test(parameter)) { + return true; + } + for (const name of instanceTypeNames) { + if (new RegExp(String.raw`:\s*${escapeRegExp(name)}\b`, "u").test(parameter)) { + return true; + } + } + return false; } function isInsideFastifyPluginCall( @@ -895,7 +918,10 @@ function isInsideFastifyPluginCall( functionIndex: number, pluginCallTargets: ReadonlySet, ): boolean { - const prefix = source.slice(Math.max(0, functionIndex - 120), functionIndex); + const prefix = source + .slice(Math.max(0, functionIndex - 500), functionIndex) + .replace(/\/\*[\s\S]*?\*\//gu, " ") + .replace(/\/\/[^\n\r]*/gu, " "); const targetPattern = [...pluginCallTargets].map(escapeRegExp).join("|"); return new RegExp(`\\b(?:${targetPattern})${genericArguments}\\s*\\(\\s*$`, "u").test(prefix); }