From 0722ba9d1a7bd4fdd7ad863557ae85f766286d37 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 20:33:47 +0530 Subject: [PATCH 1/5] fix(mapper): support Fastify method arrays --- src/mapper.test.ts | 57 ++++++++++++++++++++ src/mappers/node-routes.ts | 107 +++++++++++++++++++++++++++++++++---- 2 files changed, 155 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5265b83..3e18104 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2128,6 +2128,63 @@ describe("mapFeatures", () => { expect(session?.trustBoundaries).toContain("auth"); }); + it("maps Fastify route-object static method arrays conservatively", async () => { + const root = await fixtureRoot("clawpatch-fastify-method-array-routes-"); + await writeFixture( + root, + "package.json", + JSON.stringify( + { + name: "fastify-array-routes", + dependencies: { fastify: "1.0.0" }, + }, + null, + 2, + ), + ); + await writeFixture( + root, + "src/fastify.ts", + [ + "import Fastify from 'fastify';", + "", + "const fastify = Fastify();", + "fastify.route({ method: ['GET', 'POST'], url: '/items', handler: items });", + "fastify.route({ method: ['DELETE', configuredMethod], url: '/mixed', handler: mixed });", + "fastify.route({ method: [configuredMethod], url: '/dynamic-only', handler: dynamicOnly });", + "fastify.route({ method: [200], url: '/numeric-only', handler: numericOnly });", + "fastify.route({ method: [`PATCH`], url: '/template-static', handler: templateStatic });", + "fastify.route({ method: [`PATCH-${suffix}`], url: '/template-dynamic', handler: templateDynamic });", + "function items() {}", + "function mixed() {}", + "function dynamicOnly() {}", + "function numericOnly() {}", + "function templateStatic() {}", + "function templateDynamic() {}", + "", + ].join("\n"), + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const routes = result.features + .map((feature) => feature.entrypoints[0]?.route) + .filter((route): route is string => route !== undefined && route !== null); + + expect(titles).toEqual( + expect.arrayContaining([ + "Fastify route GET /items", + "Fastify route POST /items", + "Fastify route DELETE /mixed", + "Fastify route PATCH /template-static", + ]), + ); + expect(routes.some((route) => route.endsWith(" /dynamic-only"))).toBe(false); + expect(routes.some((route) => route.endsWith(" /numeric-only"))).toBe(false); + expect(routes.some((route) => route.endsWith(" /template-dynamic"))).toBe(false); + }); + it("keeps index route tests scoped to their route directory", async () => { const root = await fixtureRoot("clawpatch-node-server-index-route-tests-"); await writeFixture( diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 9a003bf..aac27a2 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -238,19 +238,22 @@ function fastifyRouteObjects( continue; } const routeObject = source.slice(objectStart, objectEnd); - const method = readStringProperty(routeObject, "method"); + const methods = readStringPropertyValues(routeObject, "method"); const routePath = readStringProperty(routeObject, "url") ?? readStringProperty(routeObject, "path"); - if (method === null || routePath === null || !isRoutePath(routePath)) { + if (methods.length === 0 || routePath === null || !isRoutePath(routePath)) { continue; } - routes.push({ - framework: "fastify", - filePath, - method: method.toUpperCase(), - routePath, - symbol: readIdentifierProperty(routeObject, "handler"), - }); + const symbol = readIdentifierProperty(routeObject, "handler"); + for (const method of methods) { + routes.push({ + framework: "fastify", + filePath, + method: method.toUpperCase(), + routePath, + symbol, + }); + } } return routes; } @@ -762,6 +765,41 @@ function endOfObject(source: string, start: number): number | null { return null; } +function endOfArray(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, @@ -805,6 +843,57 @@ function isRoutePath(path: string): boolean { return path === "*" || path.startsWith("/"); } +function readStringPropertyValues(source: string, property: string): string[] { + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp(String.raw`(?:^|[,{}]\s*)${escapedProperty}\s*:`, "gu"); + for (const match of source.matchAll(pattern)) { + const valueStart = (match.index ?? 0) + match[0].length; + const literal = readStringLiteralArgument(source, valueStart); + if (literal !== null) { + const delimiter = nextRouteValueDelimiter(source, literal.end); + if (delimiter === "," || delimiter === "}") { + return [literal.value]; + } + continue; + } + const array = readStringArrayLiteral(source, valueStart); + if (array === null) { + continue; + } + const delimiter = nextRouteValueDelimiter(source, array.end); + if (delimiter === "," || delimiter === "}") { + return array.values; + } + } + return []; +} + +function readStringArrayLiteral( + source: string, + start: number, +): { values: string[]; end: number } | null { + const arrayStart = skipWhitespace(source, start); + if (source[arrayStart] !== "[") { + return null; + } + const arrayEnd = endOfArray(source, arrayStart + 1); + if (arrayEnd === null) { + return null; + } + const values: string[] = []; + for (const element of splitTopLevelArguments(source.slice(arrayStart + 1, arrayEnd - 1))) { + const literal = readStringLiteralArgument(element, 0); + if (literal === null) { + continue; + } + const delimiter = nextRouteValueDelimiter(element, literal.end); + if (delimiter === null) { + values.push(literal.value); + } + } + return { values, end: arrayEnd }; +} + function readStringProperty(source: string, property: string): string | null { const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const pattern = new RegExp(String.raw`(?:^|[,{]\s*)${escapedProperty}\s*:`, "gu"); From 01d27fde087431cd77263eedaaf0f466f40b7771 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 20:52:32 +0530 Subject: [PATCH 2/5] fix(mapper): preserve static methods in mixed arrays --- src/mapper.test.ts | 12 ++++++++++++ src/mappers/node-routes.ts | 6 ------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3e18104..669780b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2154,12 +2154,16 @@ describe("mapFeatures", () => { "fastify.route({ method: [configuredMethod], url: '/dynamic-only', handler: dynamicOnly });", "fastify.route({ method: [200], url: '/numeric-only', handler: numericOnly });", "fastify.route({ method: [`PATCH`], url: '/template-static', handler: templateStatic });", + "fastify.route({ method: ['GET', `POST-${suffix}`], url: '/template-mixed', handler: templateMixed });", + "fastify.route({ method: [`PUT-${suffix}`, 'HEAD'], url: '/template-mixed-tail', handler: templateMixedTail });", "fastify.route({ method: [`PATCH-${suffix}`], url: '/template-dynamic', handler: templateDynamic });", "function items() {}", "function mixed() {}", "function dynamicOnly() {}", "function numericOnly() {}", "function templateStatic() {}", + "function templateMixed() {}", + "function templateMixedTail() {}", "function templateDynamic() {}", "", ].join("\n"), @@ -2178,10 +2182,18 @@ describe("mapFeatures", () => { "Fastify route POST /items", "Fastify route DELETE /mixed", "Fastify route PATCH /template-static", + "Fastify route GET /template-mixed", + "Fastify route HEAD /template-mixed-tail", ]), ); expect(routes.some((route) => route.endsWith(" /dynamic-only"))).toBe(false); expect(routes.some((route) => route.endsWith(" /numeric-only"))).toBe(false); + expect(routes.filter((route) => route.endsWith(" /template-mixed"))).toEqual([ + "GET /template-mixed", + ]); + expect(routes.filter((route) => route.endsWith(" /template-mixed-tail"))).toEqual([ + "HEAD /template-mixed-tail", + ]); expect(routes.some((route) => route.endsWith(" /template-dynamic"))).toBe(false); }); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index aac27a2..ccc296a 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -709,8 +709,6 @@ function endOfCall(source: string, start: number): number | null { escaped = false; } else if (char === "\\") { escaped = true; - } else if (quote === "`" && char === "$" && source[index + 1] === "{") { - return null; } else if (char === quote) { quote = null; } @@ -744,8 +742,6 @@ function endOfObject(source: string, start: number): number | null { escaped = false; } else if (char === "\\") { escaped = true; - } else if (quote === "`" && char === "$" && source[index + 1] === "{") { - return null; } else if (char === quote) { quote = null; } @@ -779,8 +775,6 @@ function endOfArray(source: string, start: number): number | null { escaped = false; } else if (char === "\\") { escaped = true; - } else if (quote === "`" && char === "$" && source[index + 1] === "{") { - return null; } else if (char === quote) { quote = null; } From 766623bc8f5f3cb2a67e7ef47e4294ae84a09159 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:16:14 +0100 Subject: [PATCH 3/5] docs(changelog): note Fastify method arrays --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d47f681..d916910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi. - 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 Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. ## 0.3.0 - 2026-05-18 From 64cc5a1826ee839c3c80e64df2f08588db9455b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:22:00 +0100 Subject: [PATCH 4/5] fix(mapper): accept const Fastify method arrays --- src/mapper.test.ts | 7 +++++ src/mappers/node-routes.ts | 61 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c23acfe..b09ae22 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2647,6 +2647,8 @@ describe("mapFeatures", () => { "const fastify = Fastify();", "fastify.route({ method: ['GET', 'POST'], url: '/items', handler: items });", "fastify.route({ method: ['DELETE', configuredMethod], url: '/mixed', handler: mixed });", + "fastify.route({ method: ['PUT', 'PATCH'] as const, url: '/const-items', handler: constItems });", + "fastify.route({ method: ['OPTIONS'] satisfies readonly string[], url: '/satisfies-items', handler: satisfiesItems });", "fastify.route({ method: [configuredMethod], url: '/dynamic-only', handler: dynamicOnly });", "fastify.route({ method: [200], url: '/numeric-only', handler: numericOnly });", "fastify.route({ method: [`PATCH`], url: '/template-static', handler: templateStatic });", @@ -2655,6 +2657,8 @@ describe("mapFeatures", () => { "fastify.route({ method: [`PATCH-${suffix}`], url: '/template-dynamic', handler: templateDynamic });", "function items() {}", "function mixed() {}", + "function constItems() {}", + "function satisfiesItems() {}", "function dynamicOnly() {}", "function numericOnly() {}", "function templateStatic() {}", @@ -2677,6 +2681,9 @@ describe("mapFeatures", () => { "Fastify route GET /items", "Fastify route POST /items", "Fastify route DELETE /mixed", + "Fastify route PUT /const-items", + "Fastify route PATCH /const-items", + "Fastify route OPTIONS /satisfies-items", "Fastify route PATCH /template-static", "Fastify route GET /template-mixed", "Fastify route HEAD /template-mixed-tail", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index ccc296a..a6096c0 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -844,7 +844,7 @@ function readStringPropertyValues(source: string, property: string): string[] { const valueStart = (match.index ?? 0) + match[0].length; const literal = readStringLiteralArgument(source, valueStart); if (literal !== null) { - const delimiter = nextRouteValueDelimiter(source, literal.end); + const delimiter = nextRoutePropertyDelimiter(source, literal.end); if (delimiter === "," || delimiter === "}") { return [literal.value]; } @@ -854,7 +854,7 @@ function readStringPropertyValues(source: string, property: string): string[] { if (array === null) { continue; } - const delimiter = nextRouteValueDelimiter(source, array.end); + const delimiter = nextRoutePropertyDelimiter(source, array.end); if (delimiter === "," || delimiter === "}") { return array.values; } @@ -896,7 +896,7 @@ function readStringProperty(source: string, property: string): string | null { if (literal === null) { continue; } - const delimiter = nextRouteValueDelimiter(source, literal.end); + const delimiter = nextRoutePropertyDelimiter(source, literal.end); if (delimiter === "," || delimiter === "}") { return literal.value; } @@ -904,6 +904,61 @@ function readStringProperty(source: string, property: string): string | null { return null; } +function nextRoutePropertyDelimiter(source: string, start: number): string | null { + const suffixEnd = skipTypeScriptValueSuffix(source, start); + return nextRouteValueDelimiter(source, suffixEnd); +} + +function skipTypeScriptValueSuffix(source: string, start: number): number { + let cursor = skipWhitespaceAndComments(source, start); + if (isKeywordAt(source, cursor, "as")) { + cursor = skipWhitespaceAndComments(source, cursor + "as".length); + if (isKeywordAt(source, cursor, "const")) { + return skipWhitespaceAndComments(source, cursor + "const".length); + } + return start; + } + if (isKeywordAt(source, cursor, "satisfies")) { + return skipTypeSuffix(source, cursor + "satisfies".length); + } + return start; +} + +function skipTypeSuffix(source: string, start: number): number { + let quote: string | null = null; + let escaped = false; + let depth = 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 === "(" || char === "[" || char === "{") { + depth += 1; + } else if (char === ")" || char === "]" || char === "}") { + if (depth === 0) { + return index; + } + depth -= 1; + } else if (char === "," && depth === 0) { + return index; + } + } + return source.length; +} + function readIdentifierProperty(source: string, property: string): string | null { const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const match = new RegExp( From 94c67d7af4f8417817fbba32650193400ed79d00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:25:37 +0100 Subject: [PATCH 5/5] fix(mapper): keep static Fastify methods with indexed entries --- src/mapper.test.ts | 3 +++ src/mappers/node-routes.ts | 23 ++++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index b09ae22..75399bb 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2647,6 +2647,7 @@ describe("mapFeatures", () => { "const fastify = Fastify();", "fastify.route({ method: ['GET', 'POST'], url: '/items', handler: items });", "fastify.route({ method: ['DELETE', configuredMethod], url: '/mixed', handler: mixed });", + "fastify.route({ method: ['GET', configuredMethods[0]], url: '/indexed-mixed', handler: indexedMixed });", "fastify.route({ method: ['PUT', 'PATCH'] as const, url: '/const-items', handler: constItems });", "fastify.route({ method: ['OPTIONS'] satisfies readonly string[], url: '/satisfies-items', handler: satisfiesItems });", "fastify.route({ method: [configuredMethod], url: '/dynamic-only', handler: dynamicOnly });", @@ -2657,6 +2658,7 @@ describe("mapFeatures", () => { "fastify.route({ method: [`PATCH-${suffix}`], url: '/template-dynamic', handler: templateDynamic });", "function items() {}", "function mixed() {}", + "function indexedMixed() {}", "function constItems() {}", "function satisfiesItems() {}", "function dynamicOnly() {}", @@ -2681,6 +2683,7 @@ describe("mapFeatures", () => { "Fastify route GET /items", "Fastify route POST /items", "Fastify route DELETE /mixed", + "Fastify route GET /indexed-mixed", "Fastify route PUT /const-items", "Fastify route PATCH /const-items", "Fastify route OPTIONS /satisfies-items", diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index a6096c0..fafbfd4 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -762,7 +762,7 @@ function endOfObject(source: string, start: number): number | null { } function endOfArray(source: string, start: number): number | null { - let depth = 1; + const stack = ["["]; let quote: string | null = null; let escaped = false; for (let index = start; index < source.length; index += 1) { @@ -782,12 +782,21 @@ function endOfArray(source: string, start: number): number | null { } if (char === "'" || char === '"' || char === "`") { quote = char; - } else if (char === "[") { - depth += 1; - } else if (char === "]") { - depth -= 1; - if (depth === 0) { - return index + 1; + } else if (char === "[" || char === "(" || char === "{") { + stack.push(char); + } else if (char === "]" || char === ")" || char === "}") { + const opener = stack.at(-1); + if ( + (opener === "[" && char === "]") || + (opener === "(" && char === ")") || + (opener === "{" && char === "}") + ) { + stack.pop(); + if (stack.length === 0) { + return index + 1; + } + } else { + return null; } } }