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 diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 54291e1..75399bb 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2624,6 +2624,85 @@ 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: ['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 });", + "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 indexedMixed() {}", + "function constItems() {}", + "function satisfiesItems() {}", + "function dynamicOnly() {}", + "function numericOnly() {}", + "function templateStatic() {}", + "function templateMixed() {}", + "function templateMixedTail() {}", + "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 GET /indexed-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", + ]), + ); + 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); + }); + 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..fafbfd4 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; } @@ -706,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; } @@ -741,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; } @@ -762,6 +761,48 @@ function endOfObject(source: string, start: number): number | null { return null; } +function endOfArray(source: string, start: number): number | null { + const stack = ["["]; + 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 (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + } 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; + } + } + } + return null; +} + function readStringLiteralArgument( source: string, start: number, @@ -805,6 +846,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 = nextRoutePropertyDelimiter(source, literal.end); + if (delimiter === "," || delimiter === "}") { + return [literal.value]; + } + continue; + } + const array = readStringArrayLiteral(source, valueStart); + if (array === null) { + continue; + } + const delimiter = nextRoutePropertyDelimiter(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"); @@ -813,7 +905,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; } @@ -821,6 +913,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(