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 @@ -13,6 +13,7 @@
- Fixed `clawpatch ci --since` empty-review output so it reports `reviewed: 0`.
- 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 Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911.

## 0.3.0 - 2026-05-18
Expand Down
134 changes: 134 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10952,6 +10952,140 @@ add_executable(headerapp include/headers.hpp)
expect(dashboard?.entrypoints[0]?.route).toBe("/{tenant}/dashboard");
});

it("maps Laravel array-style route group prefixes", async () => {
const root = await fixtureRoot("clawpatch-laravel-array-group-prefix-");
await writeFixture(
root,
"composer.json",
JSON.stringify(
{
name: "acme/array-group-prefix",
require: {
php: "^8.3",
"laravel/framework": "^13.0",
},
},
null,
2,
),
);
await writeFixture(
root,
"routes/web.php",
"<?php\n" +
"use App\\Http\\Controllers\\UserController;\n" +
'Route::group(["prefix" => "admin"], function () {\n' +
' Route::get("/users", UserController::class);\n' +
"});\n",
);
await writeFixture(
root,
"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\nfinal class UserController {}\n",
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const userController = result.features.find(
(feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/UserController.php",
);

expect(userController?.entrypoints[0]?.route).toBe("/admin/users");
expect(userController?.summary).toContain("GET /admin/users");
expect(userController?.contextFiles).toContainEqual({
path: "routes/web.php",
reason: "route definition",
});
});

it("maps nested Laravel route groups inside array-style prefixes", async () => {
const root = await fixtureRoot("clawpatch-laravel-nested-array-group-prefix-");
await writeFixture(
root,
"composer.json",
JSON.stringify(
{
name: "acme/nested-array-group-prefix",
require: {
php: "^8.3",
"laravel/framework": "^13.0",
},
},
null,
2,
),
);
await writeFixture(
root,
"routes/web.php",
"<?php\n" +
"use App\\Http\\Controllers\\UserController;\n" +
"Route::group(['prefix' => 'admin'], function () {\n" +
" Route::controller(UserController::class)->group(function () {\n" +
" Route::get('/users', 'index');\n" +
" });\n" +
"});\n",
);
await writeFixture(
root,
"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\nfinal class UserController {}\n",
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const userController = result.features.find(
(feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/UserController.php",
);

expect(userController?.entrypoints[0]?.route).toBe("/admin/users");
expect(userController?.summary).toContain("GET /admin/users#index");
});

it("maps Laravel prefixes nested inside non-prefix array groups", async () => {
const root = await fixtureRoot("clawpatch-laravel-non-prefix-array-group-");
await writeFixture(
root,
"composer.json",
JSON.stringify(
{
name: "acme/non-prefix-array-group",
require: {
php: "^8.3",
"laravel/framework": "^13.0",
},
},
null,
2,
),
);
await writeFixture(
root,
"routes/web.php",
"<?php\n" +
"use App\\Http\\Controllers\\UserController;\n" +
"Route::group(['middleware' => 'auth'], function () {\n" +
" Route::group(['prefix' => 'admin'], function () {\n" +
" Route::get('/users', UserController::class);\n" +
" });\n" +
"});\n",
);
await writeFixture(
root,
"app/Http/Controllers/UserController.php",
"<?php\nnamespace App\\Http\\Controllers;\nfinal class UserController {}\n",
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const userController = result.features.find(
(feature) => feature.entrypoints[0]?.path === "app/Http/Controllers/UserController.php",
);

expect(userController?.entrypoints[0]?.route).toBe("/admin/users");
expect(userController?.summary).toContain("GET /admin/users");
});

it("maps Laravel controller route groups", async () => {
const root = await fixtureRoot("clawpatch-laravel-controller-groups-");
await writeFixture(
Expand Down
121 changes: 115 additions & 6 deletions src/mappers/laravel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,22 +405,59 @@ async function laravelRoutes(root: string): Promise<RouteRef[]> {
for (const file of routeFiles) {
const source = stripPhpComments(await readFile(join(root, file), "utf8"));
const imports = phpUseMap(source);
const filePrefixes = fileDefaultRoutePrefixes(file);
for (const statement of routeStatements(source)) {
const calls = parseRouteCalls(statement);
const route = routeFromCalls(file, imports, calls, fileDefaultRoutePrefixes(file));
const route = routeFromCalls(file, imports, calls, filePrefixes);
if (route !== null) {
routes.push(route);
}
routes.push(...controllerGroupRoutes(file, imports, calls));
routes.push(...routeGroupRoutes(file, imports, calls, filePrefixes));
routes.push(...controllerGroupRoutes(file, imports, calls, filePrefixes));
}
}
return routes;
}

function routeGroupRoutes(
file: string,
imports: Map<string, string>,
calls: RouteCall[],
basePrefixes: string[],
): RouteRef[] {
const routes: RouteRef[] = [];
const groupIndex = calls.findIndex((call) => call.name === "group");
const groupCall = groupIndex < 0 ? undefined : calls[groupIndex];
if (groupCall === undefined) {
return routes;
}
const groupAttributePrefixes = routeGroupAttributePrefixes(groupCall);
const body = closureBody(groupCall.args[1] ?? groupCall.args[0] ?? "");
if (body === null) {
return routes;
}
const groupPrefixes = [
...basePrefixes,
...routePrefixesFromCalls(calls.slice(0, groupIndex)),
...groupAttributePrefixes,
];
for (const statement of routeStatements(body)) {
const nestedCalls = parseRouteCalls(statement);
const route = routeFromCalls(file, imports, nestedCalls, groupPrefixes);
if (route !== null) {
routes.push(route);
}
routes.push(...routeGroupRoutes(file, imports, nestedCalls, groupPrefixes));
routes.push(...controllerGroupRoutes(file, imports, nestedCalls, groupPrefixes));
}
return routes;
}

function controllerGroupRoutes(
file: string,
imports: Map<string, string>,
calls: RouteCall[],
basePrefixes: string[],
): RouteRef[] {
const routes: RouteRef[] = [];
const controllerIndex = calls.findIndex((call) => call.name === "controller");
Expand All @@ -436,10 +473,7 @@ function controllerGroupRoutes(
if (controllerClass === null || body === null) {
return routes;
}
const groupPrefixes = [
...fileDefaultRoutePrefixes(file),
...routePrefixesFromCalls(calls.slice(0, groupIndex)),
];
const groupPrefixes = [...basePrefixes, ...routePrefixesFromCalls(calls.slice(0, groupIndex))];
for (const statement of routeStatements(body)) {
const route = routeFromCalls(
file,
Expand Down Expand Up @@ -707,6 +741,81 @@ function routePrefixesFromCalls(calls: RouteCall[]): string[] {
.filter((prefix) => prefix !== null);
}

function routeGroupAttributePrefixes(call: RouteCall): string[] {
const attributes = arrayArgs(call.args[0] ?? "");
if (attributes === null) {
return [];
}
const prefix = arrayLiteralStringValue(attributes, "prefix");
return prefix === null ? [] : [prefix];
}

function arrayLiteralStringValue(entries: string[], key: string): string | null {
for (const entry of entries) {
const pair = splitTopLevelKeyValue(entry);
if (pair === null) {
continue;
}
const entryKey = stringLiteralValue(pair[0]);
if (entryKey !== key) {
continue;
}
const value = stringLiteralValue(pair[1]);
if (value !== null) {
return value;
}
}
return null;
}

function splitTopLevelKeyValue(source: string): [string, string] | null {
let quote: string | null = null;
let escaped = false;
let parens = 0;
let brackets = 0;
let braces = 0;
for (let index = 0; index < source.length - 1; index += 1) {
const char = source[index];
if (char === undefined) {
continue;
}
if (quote !== null) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = null;
}
continue;
}
if (char === String.fromCharCode(39) || char === String.fromCharCode(34)) {
quote = char;
} else if (char === "(") {
parens += 1;
} else if (char === ")") {
parens = Math.max(0, parens - 1);
} else if (char === "[") {
brackets += 1;
} else if (char === "]") {
brackets = Math.max(0, brackets - 1);
} else if (char === "{") {
braces += 1;
} else if (char === "}") {
braces = Math.max(0, braces - 1);
} else if (
char === "=" &&
source[index + 1] === ">" &&
parens === 0 &&
brackets === 0 &&
braces === 0
) {
return [source.slice(0, index).trim(), source.slice(index + 2).trim()];
}
}
return null;
}

function arrayArgs(source: string): string[] | null {
const trimmed = source.trim();
if (!trimmed.startsWith("[")) {
Expand Down