From b5015bf3ccc795bdaccd22a0bf4f5c54e6f72a67 Mon Sep 17 00:00:00 2001 From: rohitjavvadi Date: Mon, 18 May 2026 21:55:52 +0530 Subject: [PATCH 1/4] fix(mapper): map Laravel group prefixes --- src/mapper.test.ts | 46 +++++++++++++++++ src/mappers/laravel.ts | 109 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5265b83..3d459a7 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -10952,6 +10952,52 @@ 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", + " "admin"], function () {\n' + + ' Route::get("/users", UserController::class);\n' + + "});\n", + ); + await writeFixture( + root, + "app/Http/Controllers/UserController.php", + " 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 Laravel controller route groups", async () => { const root = await fixtureRoot("clawpatch-laravel-controller-groups-"); await writeFixture( diff --git a/src/mappers/laravel.ts b/src/mappers/laravel.ts index c8dc63f..2852614 100644 --- a/src/mappers/laravel.ts +++ b/src/mappers/laravel.ts @@ -411,12 +411,46 @@ async function laravelRoutes(root: string): Promise { if (route !== null) { routes.push(route); } + routes.push(...routeGroupRoutes(file, imports, calls)); routes.push(...controllerGroupRoutes(file, imports, calls)); } } return routes; } +function routeGroupRoutes( + file: string, + imports: Map, + calls: RouteCall[], +): 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); + if (groupAttributePrefixes.length === 0) { + return routes; + } + const body = closureBody(groupCall.args[1] ?? ""); + if (body === null) { + return routes; + } + const groupPrefixes = [ + ...fileDefaultRoutePrefixes(file), + ...routePrefixesFromCalls(calls.slice(0, groupIndex)), + ...groupAttributePrefixes, + ]; + for (const statement of routeStatements(body)) { + const route = routeFromCalls(file, imports, parseRouteCalls(statement), groupPrefixes); + if (route !== null) { + routes.push(route); + } + } + return routes; +} + function controllerGroupRoutes( file: string, imports: Map, @@ -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("[")) { From 3efb21536ca6663c03e20dd3f8ac6f8bc74047bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 01:58:37 +0100 Subject: [PATCH 2/4] docs(changelog): note Laravel group prefix fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570182c..f6f9cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From a46cd683864d8b0064d315c424b8a6638d75c781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:01:49 +0100 Subject: [PATCH 3/4] fix(mapper): recurse Laravel array group routes --- src/mapper.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/laravel.ts | 21 +++++++++++--------- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3d459a7..9e4acee 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -10998,6 +10998,50 @@ add_executable(headerapp include/headers.hpp) }); }); + 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", + " '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", + " 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 controller route groups", async () => { const root = await fixtureRoot("clawpatch-laravel-controller-groups-"); await writeFixture( diff --git a/src/mappers/laravel.ts b/src/mappers/laravel.ts index 2852614..eee0fd9 100644 --- a/src/mappers/laravel.ts +++ b/src/mappers/laravel.ts @@ -405,14 +405,15 @@ async function laravelRoutes(root: string): Promise { 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(...routeGroupRoutes(file, imports, calls)); - routes.push(...controllerGroupRoutes(file, imports, calls)); + routes.push(...routeGroupRoutes(file, imports, calls, filePrefixes)); + routes.push(...controllerGroupRoutes(file, imports, calls, filePrefixes)); } } return routes; @@ -422,6 +423,7 @@ function routeGroupRoutes( file: string, imports: Map, calls: RouteCall[], + basePrefixes: string[], ): RouteRef[] { const routes: RouteRef[] = []; const groupIndex = calls.findIndex((call) => call.name === "group"); @@ -438,15 +440,18 @@ function routeGroupRoutes( return routes; } const groupPrefixes = [ - ...fileDefaultRoutePrefixes(file), + ...basePrefixes, ...routePrefixesFromCalls(calls.slice(0, groupIndex)), ...groupAttributePrefixes, ]; for (const statement of routeStatements(body)) { - const route = routeFromCalls(file, imports, parseRouteCalls(statement), groupPrefixes); + 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; } @@ -455,6 +460,7 @@ function controllerGroupRoutes( file: string, imports: Map, calls: RouteCall[], + basePrefixes: string[], ): RouteRef[] { const routes: RouteRef[] = []; const controllerIndex = calls.findIndex((call) => call.name === "controller"); @@ -470,10 +476,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, From 27eac55d1a2136f891f3bd226d42e88e63b156f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 02:05:27 +0100 Subject: [PATCH 4/4] fix(mapper): recurse non-prefix Laravel groups --- src/mapper.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ src/mappers/laravel.ts | 5 +---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9e4acee..9e70edb 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11042,6 +11042,50 @@ add_executable(headerapp include/headers.hpp) 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", + " '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", + " 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( diff --git a/src/mappers/laravel.ts b/src/mappers/laravel.ts index eee0fd9..d785680 100644 --- a/src/mappers/laravel.ts +++ b/src/mappers/laravel.ts @@ -432,10 +432,7 @@ function routeGroupRoutes( return routes; } const groupAttributePrefixes = routeGroupAttributePrefixes(groupCall); - if (groupAttributePrefixes.length === 0) { - return routes; - } - const body = closureBody(groupCall.args[1] ?? ""); + const body = closureBody(groupCall.args[1] ?? groupCall.args[0] ?? ""); if (body === null) { return routes; }