فهرست منبع

feat(resolution): Rails RESTful resources routing → controller#action

The rails resolver only saw explicit `get '/x' => 'c#a'` routes, so apps using
the dominant `resources :articles` / `resource :user` RESTful routing had ZERO
route nodes (realworld + spree: 0 routes despite full routes.rb files). The whole
request→controller flow was disconnected.

Fix (frameworks/ruby.ts):
- extract: expand `resources`/`resource` into their REST actions (only/except
  filters; pluralize the singular `resource :user` → users_controller), emit a
  precise `controller#action` ref per action. Explicit routes now also reference
  `controller#action` instead of a bare ambiguous `action`.
- resolve: new `controller#action` pattern → the action method in
  <ctrl>_controller.rb (file convention + controller-class fallback).
- claimsReference: claim `controller#action` refs so resolveOne's pre-filter
  doesn't drop them before resolve() runs (same hook the django ORM work needed —
  these refs name no declared symbol).

Validated: realworld 0→16, forem 0→635 precise route→action edges (GET /articles→
index, resource :user→users#show, etc.), pluralization correct, no node explosion
(route nodes proportional to resources). Agent A/B (forem, large): with codegraph
1-4 reads / 0 grep / 47-53s vs without 4-5 reads / 2-3 grep / 66-85s. Framework-
scoped (zero blast radius off Rails). Residuals: Rails Engine routing (spree
mounts an engine), ActiveRecord dynamic finders (metaprogramming frontier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 ماه پیش
والد
کامیت
5aa291d2a2
2فایلهای تغییر یافته به همراه116 افزوده شده و 3 حذف شده
  1. 13 1
      docs/design/dynamic-dispatch-coverage-playbook.md
  2. 103 2
      src/resolution/frameworks/ruby.ts

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

@@ -188,7 +188,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 | Kotlin | (coroutines / DI) | flow/callback dispatch | ? | ⬜ |
 | Swift | Vapor | request → route → controller | ? | ⬜ |
 | C# | ASP.NET | request → controller; DI | ? | ⬜ |
-| Ruby | Rails / Sinatra | request → controller → view; callbacks | ? | ⬜ |
+| 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 | ? | ⬜ |
 | C/C++ | (callback structs / vtables) | function-pointer dispatch | ? | ⬜ |
 | Dart | Flutter | setState → build | S | ⬜ |
@@ -257,6 +257,18 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
   (246 dup nodes) because the file walk only respects `.gitignore` with no default build-dir ignore. Real
   apps (immich/amplication) gitignore `dist/` (0 dup nodes), so it's narrow — a default ignore for
   `dist/build/out/.next/coverage` is a clean follow-up, deferred (core-indexer change, the user's call).
+- **Rails (validated 2026-05-23, realworld S / spree M / forem L) — high-value RESTful-routing fix.** The
+  `rails` resolver only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps (the dominant
+  pattern) had ZERO route nodes (realworld + spree). Fixed (`frameworks/ruby.ts`): expand `resources :x` /
+  `resource :x` into their RESTful actions (only/except filters + pluralization for the singular `resource`),
+  reference a precise `controller#action`, and resolve that to the action method in `<ctrl>_controller.rb`
+  (explicit routes fixed too — they referenced a bare ambiguous `action`). realworld **0→16**, forem
+  **0→635** precise route→action edges. Agent A/B (forem comment-creation, large): codegraph **1–4 reads /
+  0 grep / 47–53s** vs without **4–5 reads / 2–3 grep / 66–85s** — fewer reads, no grep, faster. **The
+  `claimsReference` pre-filter was the gotcha:** `articles#index` names no declared symbol, so `resolveOne`
+  dropped it before `resolve()` ran — needed the same claim hook as the django ORM work. Residuals: **Rails
+  Engine routing** (spree still 0 — it mounts an engine, not `config/routes.rb` resources); ActiveRecord
+  dynamic finders (`Article.find_by_slug` — metaprogramming frontier).
 - **Difficulty gradient is real:** named-ref dispatch (resolver) is cheap; anonymous
   callback dispatch (synthesizer) is medium; **anonymous-arrow handlers are the hard
   remaining gap** (no identity → need synthesizer link-through-body, not yet built).

+ 103 - 2
src/resolution/frameworks/ruby.ts

@@ -12,6 +12,13 @@ export const railsResolver: FrameworkResolver = {
   name: 'rails',
   languages: ['ruby'],
 
+  // `controller#action` route refs name no declared symbol, so resolveOne's
+  // pre-filter would drop them before resolve() runs. Claim them (like the django
+  // `_iterable_class` hook) so they reach Pattern 0.
+  claimsReference(name: string): boolean {
+    return /^[\w/]+#\w+$/.test(name);
+  },
+
   detect(context: ResolutionContext): boolean {
     // Check for Gemfile with rails
     const gemfile = context.readFile('Gemfile');
@@ -32,6 +39,18 @@ export const railsResolver: FrameworkResolver = {
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    // Pattern 0: route action `controller#action` (from RESTful `resources` or an
+    // explicit route) → the action method in that controller. Precise — avoids the
+    // bare-`action` ambiguity (every controller has an `index`/`show`).
+    const ca = ref.referenceName.match(/^([\w/]+)#(\w+)$/);
+    if (ca) {
+      const result = resolveControllerAction(ca[1]!, ca[2]!, context);
+      if (result) {
+        return { original: ref, targetNodeId: result, confidence: 0.85, resolvedBy: 'framework' };
+      }
+      return null;
+    }
+
     // Pattern 1: Model references (ActiveRecord)
     if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
       const result = resolveModel(ref.referenceName, context);
@@ -99,7 +118,7 @@ export const railsResolver: FrameworkResolver = {
     const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g;
     let match: RegExpExecArray | null;
     while ((match = routeRegex.exec(safe)) !== null) {
-      const [, method, routePath, _controller, action] = match;
+      const [, method, routePath, ctrl, action] = match;
       const line = safe.slice(0, match.index).split('\n').length;
       const upper = method!.toUpperCase();
       const routeNode: Node = {
@@ -119,7 +138,7 @@ export const railsResolver: FrameworkResolver = {
 
       references.push({
         fromNodeId: routeNode.id,
-        referenceName: action!,
+        referenceName: `${ctrl}#${action}`, // precise controller#action, not bare action
         referenceKind: 'references',
         line,
         column: 0,
@@ -128,12 +147,94 @@ export const railsResolver: FrameworkResolver = {
       });
     }
 
+    // RESTful resources: `resources :articles` / `resource :user` (the dominant
+    // Rails routing) generate a controller action per REST verb. The old resolver
+    // only saw explicit `get '/x' => 'c#a'` routes, so resource-routed apps had
+    // ZERO route nodes. Expand each into its actions → `controller#action` refs.
+    const resRegex = /\b(resources?)\s+:(\w+)([^\n]*)/g;
+    while ((match = resRegex.exec(safe)) !== null) {
+      const plural = match[1] === 'resources';
+      const resName = match[2]!;
+      const tail = match[3] || '';
+      let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS;
+      const only = tail.match(/only:\s*\[([^\]]*)\]/);
+      const except = tail.match(/except:\s*\[([^\]]*)\]/);
+      const symList = (s: string) => new Set(s.split(',').map((x) => x.trim().replace(/^:/, '')));
+      if (only) { const s = symList(only[1]!); actions = actions.filter((a) => s.has(a)); }
+      else if (except) { const s = symList(except[1]!); actions = actions.filter((a) => !s.has(a)); }
+      // `resources :articles` → ArticlesController; `resource :user` → UsersController.
+      const ctrl = plural ? resName : pluralize(resName);
+      const line = safe.slice(0, match.index).split('\n').length;
+      for (const action of actions) {
+        const spec = RESTFUL_ROUTES[action]!;
+        const path = spec.path(resName);
+        const routeNode: Node = {
+          id: `route:${filePath}:${line}:${spec.method}:${ctrl}#${action}`,
+          kind: 'route',
+          name: `${spec.method} ${path}`,
+          qualifiedName: `${filePath}::route:${ctrl}#${action}`,
+          filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length,
+          language: 'ruby', updatedAt: now,
+        };
+        nodes.push(routeNode);
+        references.push({
+          fromNodeId: routeNode.id,
+          referenceName: `${ctrl}#${action}`,
+          referenceKind: 'references',
+          line, column: 0, filePath, language: 'ruby',
+        });
+      }
+    }
+
     return { nodes, references };
   },
 };
 
 // Helper functions
 
+// RESTful action → HTTP verb + path. `resources` gets all seven; a singular
+// `resource` omits `index`.
+const RESTFUL_ROUTES: Record<string, { method: string; path: (r: string) => string }> = {
+  index:   { method: 'GET',    path: (r) => `/${r}` },
+  create:  { method: 'POST',   path: (r) => `/${r}` },
+  new:     { method: 'GET',    path: (r) => `/${r}/new` },
+  show:    { method: 'GET',    path: (r) => `/${r}/:id` },
+  edit:    { method: 'GET',    path: (r) => `/${r}/:id/edit` },
+  update:  { method: 'PATCH',  path: (r) => `/${r}/:id` },
+  destroy: { method: 'DELETE', path: (r) => `/${r}/:id` },
+};
+const PLURAL_ACTIONS = ['index', 'create', 'new', 'show', 'edit', 'update', 'destroy'];
+const SINGULAR_ACTIONS = ['create', 'new', 'show', 'edit', 'update', 'destroy'];
+
+/** Naive ActiveSupport-style pluralize — covers the common resource names. */
+function pluralize(w: string): string {
+  if (/[^aeiou]y$/.test(w)) return w.slice(0, -1) + 'ies';
+  if (/(s|x|z|ch|sh)$/.test(w)) return w + 'es';
+  return w + 's';
+}
+
+/** snake_case → CamelCase (`user_profiles` → `UserProfiles`). */
+function camelize(s: string): string {
+  return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
+}
+
+/** Resolve a `controller#action` route ref to the action method in that controller. */
+function resolveControllerAction(ctrlPath: string, action: string, context: ResolutionContext): string | null {
+  // Rails convention: `articles` → app/controllers/articles_controller.rb.
+  const direct = `app/controllers/${ctrlPath}_controller.rb`;
+  if (context.fileExists(direct)) {
+    const m = context.getNodesInFile(direct).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
+    if (m) return m.id;
+  }
+  // Fall back: controller class by name, then the action method in its file.
+  const cls = camelize(ctrlPath.split('/').pop()!) + 'Controller';
+  for (const ctrl of context.getNodesByName(cls).filter((n) => n.kind === 'class')) {
+    const m = context.getNodesInFile(ctrl.filePath).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
+    if (m) return m.id;
+  }
+  return null;
+}
+
 function resolveModel(name: string, context: ResolutionContext): string | null {
   // Try direct file path lookup first (Rails convention: CamelCase -> snake_case.rb)
   const snakeName = name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);