Просмотр исходного кода

feat(resolution): Laravel route → precise Controller@method (not bare action)

extractLaravelHandler discarded the controller: `Route::get([UserController::class,
'index'])` and `'UserController@index'` both emitted a BARE `index` ref. With the
route in routes/api.php (not the controller file), name-matching mis-resolved every
common action to the WRONG controller — realworld's GET user → ArticleController.index
(should be UserController), GET articles/feed → ArticleController (should be
FeedController), etc. The routes existed but pointed at the wrong handler.

Fix (frameworks/laravel.ts): emit precise `Controller@method` (array + string
syntax, namespace-stripped) and `claimsReference` it so resolveOne's pre-filter
doesn't drop it before Pattern-4 resolveControllerMethod runs (the recurring hook,
also needed by django ORM + Rails routing).

Validated: realworld all routes now resolve to the correct controller; bookstack
267/332 precise (GET pages → PageApiController.list, array syntax). No node
explosion. Agent A/B (bookstack page-view, large): with codegraph 2-3 reads / 1-2
grep / 51-60s vs without 4-6 / 3-5 / 60-74s. Residuals: firefly's fluent
->uses()/['uses'=>...] handler format (3/568 resolve), Eloquent dynamic finders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
0e12f28d53
2 измененных файлов с 30 добавлено и 9 удалено
  1. 12 1
      docs/design/dynamic-dispatch-coverage-playbook.md
  2. 18 8
      src/resolution/frameworks/laravel.ts

+ 12 - 1
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -189,7 +189,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 | Swift | Vapor | request → route → controller | ? | ⬜ |
 | Swift | Vapor | request → route → controller | ? | ⬜ |
 | C# | ASP.NET | request → controller; DI | ? | ⬜ |
 | C# | ASP.NET | request → controller; DI | ? | ⬜ |
 | Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ **RESTful `resources`/`resource` routing → controller#action** (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise `controller#action` too. 🔬 ActiveRecord dynamic finders (`Article.find_by_slug`) — metaprogramming frontier |
 | Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ **RESTful `resources`/`resource` routing → controller#action** (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise `controller#action` too. 🔬 ActiveRecord dynamic finders (`Article.find_by_slug`) — metaprogramming frontier |
-| PHP | Laravel / Drupal | request → controller; events | ? | ⬜ |
+| PHP | Laravel | request → route → controller → Eloquent | R | ✅ **precise `Route::get([Ctrl::class,'m'])` / `'Ctrl@m'` → Ctrl@method** (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every `index`→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) |
 | C/C++ | (callback structs / vtables) | function-pointer dispatch | ? | ⬜ |
 | C/C++ | (callback structs / vtables) | function-pointer dispatch | ? | ⬜ |
 | Dart | Flutter | setState → build | S | ⬜ |
 | Dart | Flutter | setState → build | S | ⬜ |
 | Lua / Luau | (Neovim / Roblox) | event/callback dispatch | S | ⬜ |
 | Lua / Luau | (Neovim / Roblox) | event/callback dispatch | S | ⬜ |
@@ -291,6 +291,17 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
   / 6 grep / 82–86s** — fewer reads, fewer greps, faster. No regression (wagtail/saleor route counts
   / 6 grep / 82–86s** — fewer reads, fewer greps, faster. No regression (wagtail/saleor route counts
   unchanged — purely additive). Residuals: signals (`post_save`→receiver), DRF viewset CRUD actions
   unchanged — purely additive). Residuals: signals (`post_save`→receiver), DRF viewset CRUD actions
   (inherited from the base class, not in the user's ViewSet), saleor's GraphQL resolvers.
   (inherited from the base class, not in the user's ViewSet), saleor's GraphQL resolvers.
+- **Laravel (validated 2026-05-23, realworld S / firefly M / bookstack L) — route precision fix.** The
+  resolver discarded the controller from the handler: `Route::get([UserController::class,'index'])` /
+  `'UserController@index'` emitted a BARE `index` ref, which name-matching mis-resolved to the WRONG
+  controller (every `index`/`show` → whichever it found first; realworld GET user → ArticleController.index,
+  should be UserController). Fix (`frameworks/laravel.ts`): emit precise `Controller@method` (array + string
+  syntax, namespace-stripped) + `claimsReference` it past the pre-filter → existing Pattern-4
+  `resolveControllerMethod`. realworld all routes correct; bookstack 267/332 precise (GET pages →
+  PageApiController.list). Agent A/B (bookstack page-view, large): codegraph **2–3 reads / 1–2 grep /
+  51–60s** vs without **4–6 / 3–5 / 60–74s**. No node explosion. Residuals: firefly resolves only 3/568
+  (its fluent `->uses()` / `['uses'=>...]` handler format isn't parsed); Eloquent dynamic finders
+  (metaprogramming frontier).
 - **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous
 - **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous
   callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard
   callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard
   remaining gap** (no identity → need synthesizer link-through-body, not yet built).
   remaining gap** (no identity → need synthesizer link-through-body, not yet built).

+ 18 - 8
src/resolution/frameworks/laravel.ts

@@ -44,6 +44,13 @@ export const laravelResolver: FrameworkResolver = {
     return context.fileExists('artisan') || context.fileExists('app/Http/Kernel.php');
     return context.fileExists('artisan') || context.fileExists('app/Http/Kernel.php');
   },
   },
 
 
+  // `Controller@method` route refs name no declared symbol, so resolveOne's
+  // pre-filter would drop them before resolve() runs (Pattern 4). Claim them —
+  // same hook the django ORM / Rails routing work needed.
+  claimsReference(name: string): boolean {
+    return /^[A-Za-z_][A-Za-z0-9_]*Controller@\w+$/.test(name);
+  },
+
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
     // Pattern 1: Model::method() - Eloquent static calls
     // Pattern 1: Model::method() - Eloquent static calls
     const modelMatch = ref.referenceName.match(/^([A-Z][a-zA-Z]+)::(\w+)$/);
     const modelMatch = ref.referenceName.match(/^([A-Z][a-zA-Z]+)::(\w+)$/);
@@ -185,18 +192,21 @@ export const laravelResolver: FrameworkResolver = {
  */
  */
 function extractLaravelHandler(expr: string): string | null {
 function extractLaravelHandler(expr: string): string | null {
   const trimmed = expr.trim();
   const trimmed = expr.trim();
+  const short = (s: string) => s.split('\\').pop()!; // strip namespace
 
 
-  // [Class::class, 'method'] — grab the string literal
-  const tupleMatch = trimmed.match(/^\[\s*[^,]+,\s*['"]([^'"]+)['"]\s*\]/);
-  if (tupleMatch) return tupleMatch[1]!;
+  // [Class::class, 'method'] → `Class@method` (PRECISE — keep the controller, so
+  // common action names like `index`/`show` resolve to the RIGHT controller, not
+  // whichever one name-matching happens to pick first).
+  const tupleMatch = trimmed.match(/^\[\s*([A-Za-z_\\][\w\\]*)::class\s*,\s*['"]([^'"]+)['"]\s*\]/);
+  if (tupleMatch) return `${short(tupleMatch[1]!)}@${tupleMatch[2]!}`;
 
 
-  // 'Controller@method'
+  // 'Controller@method' (possibly namespaced) → `Controller@method`
   const atMatch = trimmed.match(/^['"]([^'"@]+)@([^'"]+)['"]$/);
   const atMatch = trimmed.match(/^['"]([^'"@]+)@([^'"]+)['"]$/);
-  if (atMatch) return atMatch[2]!;
+  if (atMatch) return `${short(atMatch[1]!)}@${atMatch[2]!}`;
 
 
-  // Controller::class
-  const classMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)::class/);
-  if (classMatch) return classMatch[1]!;
+  // Class::class (Route::resource controller) → `Class`
+  const classMatch = trimmed.match(/^([A-Za-z_\\][\w\\]*)::class/);
+  if (classMatch) return short(classMatch[1]!);
 
 
   return null;
   return null;
 }
 }