Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
175 changes: 161 additions & 14 deletions src/mappers/node-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -813,14 +905,69 @@ 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;
}
}
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(
Expand Down