fix(rails): routes are read with their namespaces, scopes and nesting (#2155)

- Route files (`routes.draw`, or under config/routes/) are read as a block
  structure: `namespace` / `scope` add path + controller module, nested
  `resources … do` sit under `/:parent_id`, `member` / `collection` add
  `/:id/x` / `/x` actions, `root` and `resource` / `resources` options
  (`only:` / `except:` across lines, `path:`, `controller:`) are honored;
  `do` and `if` / `unless` / `case` / `begin` open frames an `end` closes.
- Detection accepts `gem "rails"` (double quotes), railties, and a Rails
  engine's `config/routes.rb` anywhere (solidus).
- A namespaced `controller#action` only reaches controllers of its own
  module path when any exist — an admin controller that inherits `index` is
  not the API's `ZonesController#index`.

Route→action edges main → this branch: mastodon 221→578, solidus 0→319,
rubygems.org 108→192, maybe 127→189, lobsters 141→196; no non-route edge
lost except solidus's CamelCase refs now resolved by the Rails model
resolver (`Order` → Spree::Order model, was a state-machine module).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 11:12:36 +00:00
committed by GitHub
parent 11cc3d9647
commit 110208973f
3 changed files with 266 additions and 6 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Rails routes are now read with their nesting: `namespace` and `scope` add their path and controller module, nested `resources` sit under their parent's `:id`, and `member` / `collection` blocks add their actions. A namespaced route now links to its own module's controller. Routes in a Rails engine's `config/routes.rb` are found too: solidus went from no routes to over 600, and mastodon's route-to-action links nearly tripled.
- Flask routes registered with `add_url_rule(…)` are now found and linked to their view function or class-based view. So are routes registered through a project's own helper that passes a list of paths and a `view_func=`. flaskbb, which registers every view that way, went from no routes to over 100.
- In Lua and Luau, a call through a standard or host library table (`string`, `table`, `io`, `vim`, `ngx`, busted's `assert`…) or a string method like `line:find(…)` no longer lands on the one project method with the same name. Functions the project itself defines on those tables still link. On kong, the specs' `assert.truthy(…)` and `assert.falsy(…)` used to land on a condition helper over 1,700 times; on telescope, `line:find(…)` on the picker's `find`.
- In PHP, a call through a variable nothing types no longer lands on an unrelated class's method just because it's the only method with that name. The variable now needs to be named after the method's class, or after a class that inherits it (`$page->save()` where `Page` extends `Entity`). On guzzle, the tests' `$response->getHeaderLine()` used to land on a test double; on symfony/console, `$e->getMessage()` on a progress bar.
+113
View File
@@ -0,0 +1,113 @@
/**
* A Rails routes file is read with its blocks: `namespace :admin` prefixes
* paths and controllers, `scope` its path and module, a `resources … do`
* block nests children under `/:parent_id`, and `member` / `collection`
* blocks add actions. A Rails engine's routes (`Spree::Core::Engine.routes.draw`
* in `backend/config/routes.rb`) count too — solidus had no routes at all —
* and a namespaced route reaches its own module's controller.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rails-scoped-'));
const files: Record<string, string> = {
'Gemfile': 'source "https://rubygems.org"\ngem "rails", "~> 7.1"\n',
'backend/config/routes.rb': `Spree::Core::Engine.routes.draw do
namespace :admin do
get "/search/users", to: "search#users", as: :search_users
resources :zones, only: [
:index,
:show
]
resources :products do
resources :images
member do
get :preview
end
collection do
post :update_positions
end
end
end
scope "/v2", module: "api" do
resource :account, only: [:show]
end
root to: "home#index"
end
`,
'backend/app/controllers/spree/admin/zones_controller.rb': `module Spree
module Admin
class ZonesController < ResourceController
def show; end
end
end
end
`,
'api/app/controllers/spree/api/zones_controller.rb': `module Spree
module Api
class ZonesController < BaseController
def index; end
def show; end
end
end
end
`,
'backend/app/controllers/spree/admin/products_controller.rb': `module Spree
module Admin
class ProductsController < ResourceController
def preview; end
def update_positions; end
end
end
end
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const handlerOf = (name: string): string | undefined => {
const route = cg.getNodesByKind('route').find((r) => r.name === name);
if (!route) return undefined;
const target = cg.getOutgoingEdges(route.id).find((e) => e.kind === 'references');
return target ? cg.getNode(target.target)!.filePath : '';
};
describe('Rails routes with their nesting', () => {
it('prefixes namespaces and scopes, nests resources, adds member and collection actions', () => {
const names = cg.getNodesByKind('route').map((r) => r.name).sort();
expect(names).toEqual(expect.arrayContaining([
'GET /admin/search/users',
'GET /admin/zones', 'GET /admin/zones/:id',
'GET /admin/products/:product_id/images',
'GET /admin/products/:id/preview',
'POST /admin/products/update_positions',
'GET /v2/account',
'GET /',
]));
// `only:` written across lines still limits the actions.
expect(names).not.toContain('DELETE /admin/zones/:id');
});
it('reaches the controller of the route’s own module, not another module’s namesake', () => {
expect(handlerOf('GET /admin/zones/:id')).toBe('backend/app/controllers/spree/admin/zones_controller.rb');
// The admin controller inherits `index`; the API's `index` is not it.
expect(handlerOf('GET /admin/zones')).toBe('');
expect(handlerOf('GET /admin/products/:id/preview')).toBe('backend/app/controllers/spree/admin/products_controller.rb');
});
});
+152 -6
View File
@@ -5,7 +5,7 @@
*/
import { Node } from '../../types';
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { FrameworkResolver, FrameworkExtractionResult, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
import { stripCommentsForRegex } from '../strip-comments';
export const railsResolver: FrameworkResolver = {
@@ -22,7 +22,7 @@ export const railsResolver: FrameworkResolver = {
detect(context: ResolutionContext): boolean {
// Check for Gemfile with rails
const gemfile = context.readFile('Gemfile');
if (gemfile && gemfile.includes("'rails'")) {
if (gemfile && /\bgem\s+["'](?:rails|railties)["']/.test(gemfile)) {
return true;
}
@@ -31,10 +31,12 @@ export const railsResolver: FrameworkResolver = {
return true;
}
// Check for typical Rails directory structure
// Check for typical Rails directory structure — or a Rails engine's routes
// file anywhere (solidus keeps each engine's under `backend/config/`).
return (
context.fileExists('app/controllers/application_controller.rb') ||
context.fileExists('config/routes.rb')
context.fileExists('config/routes.rb') ||
context.getAllFiles().some((f) => /(?:^|\/)config\/routes\.rb$/.test(f))
);
},
@@ -113,6 +115,12 @@ export const railsResolver: FrameworkResolver = {
const now = Date.now();
const safe = stripCommentsForRegex(content, 'ruby');
// A routes file is read with its blocks: `namespace`, `scope`, nested
// `resources … do`, `member` / `collection`.
if (/\broutes\.draw\b/.test(safe) || /(?:^|\/)config\/routes\//.test(filePath)) {
return extractScopedRailsRoutes(filePath, safe);
}
// get/post/put/patch/delete/match '/path', to: 'controller#action'
// Also: get '/path' => 'controller#action'
const routeRegex = /\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]/g;
@@ -192,6 +200,136 @@ export const railsResolver: FrameworkResolver = {
// Helper functions
interface RailsFrame {
kind: 'namespace' | 'scope' | 'resource' | 'member' | 'collection' | 'block';
/** Path prefix routes written inside this frame get. */
path: string;
/** Controller module prefix (`admin/`). */
module: string;
/** For a resource: its controller, collection path and member path. */
controller?: string;
collectionPath?: string;
memberPath?: string;
}
/**
* A Rails routes file with its nesting: `namespace :admin` prefixes paths and
* controllers (`/admin/zones` → `admin/zones#index`), `scope` its path and
* module, a `resources :products do` block nests its children under
* `/products/:product_id`, and `member` / `collection` blocks add
* `/products/:id/preview` / `/products/search` actions. Each `do`, and each
* `if` / `unless` / `case` / `begin` line, opens a frame an `end` closes.
*/
function extractScopedRailsRoutes(filePath: string, safe: string): FrameworkExtractionResult {
const nodes: Node[] = [];
const references: UnresolvedRef[] = [];
const now = Date.now();
const seen = new Set<string>();
const stack: RailsFrame[] = [{ kind: 'block', path: '', module: '' }];
const top = () => stack[stack.length - 1]!;
const join = (...parts: string[]) => ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
const sym = (s: string) => s.replace(/^:/, '').replace(/^["']|["']$/g, '');
const option = (tail: string, key: string) => new RegExp(`\\b${key}:\\s*(:\\w+|["'][^"']*["'])`).exec(tail)?.[1];
const emit = (method: string, path: string, target: string, line: number) => {
const id = `route:${filePath}:${line}:${method}:${path}:${target}`;
if (seen.has(id)) return;
seen.add(id);
nodes.push({
id, kind: 'route', name: `${method} ${path}`, qualifiedName: `${filePath}::route:${target}`,
filePath, startLine: line, endLine: line, startColumn: 0, endColumn: 0, language: 'ruby', updatedAt: now,
});
references.push({ fromNodeId: id, referenceName: target, referenceKind: 'references', line, column: 0, filePath, language: 'ruby' });
};
const lines = safe.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = i + 1;
let text = lines[i]!.trim();
if (!text) continue;
// A statement whose `[` / `(` continues on the next lines (`only: [\n :a,\n :b ]`).
const open = (t: string) => (t.match(/[[(]/g)?.length ?? 0) - (t.match(/[\])]/g)?.length ?? 0);
while (open(text) > 0 && i + 1 < lines.length && !/\bdo\s*(?:\|[^|]*\|)?\s*$/.test(text)) text += ' ' + lines[++i]!.trim();
if (/^end\b/.test(text)) {
if (stack.length > 1) stack.pop();
continue;
}
const opens = /\bdo\s*(?:\|[^|]*\|)?\s*$/.test(text);
const frame = top();
let pushed: RailsFrame | null = null;
let m: RegExpExecArray | null;
if ((m = /^namespace\s+(:\w+|["'][\w/-]+["'])(.*)$/.exec(text))) {
const name = sym(m[1]!);
const custom = option(m[2]!, 'path');
pushed = { kind: 'namespace', path: join(frame.path, custom !== undefined ? sym(custom) : name), module: `${frame.module}${name}/` };
} else if ((m = /^scope\b\s*\(?\s*(.*?)\)?\s*(?:do\b.*)?$/.exec(text)) && opens) {
const args = m[1]!;
const lead = /^(["'])([^"']*)\1/.exec(args)?.[2];
const pathOpt = option(args, 'path');
const moduleOpt = option(args, 'module');
const path = lead ?? (pathOpt ? sym(pathOpt) : '');
pushed = { kind: 'scope', path: join(frame.path, path), module: moduleOpt ? `${frame.module}${sym(moduleOpt)}/` : frame.module };
} else if ((m = /^(resources?)\s+:(\w+)(.*)$/.exec(text))) {
const plural = m[1] === 'resources';
const name = m[2]!;
const tail = m[3]!;
const segment = option(tail, 'path') ? sym(option(tail, 'path')!) : name;
const controller = frame.module + (option(tail, 'controller') ? sym(option(tail, 'controller')!) : plural ? name : pluralize(name));
const collectionPath = join(frame.path, segment);
const memberPath = plural ? join(collectionPath, ':id') : collectionPath;
let actions = plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS;
const only = /only:\s*(?:\[([^\]]*)\]|(:\w+))/.exec(tail);
const except = /except:\s*(?:\[([^\]]*)\]|(:\w+))/.exec(tail);
const symList = (x: RegExpExecArray) => new Set((x[1] ?? x[2] ?? '').split(',').map((v) => sym(v.trim())));
if (only) { const keep = symList(only); actions = actions.filter((a) => keep.has(a)); }
else if (except) { const drop = symList(except); actions = actions.filter((a) => !drop.has(a)); }
for (const action of actions) {
const spec = RESTFUL_ROUTES[action]!;
const path = action === 'index' || action === 'create' ? collectionPath
: action === 'new' ? join(collectionPath, 'new')
: action === 'edit' ? join(memberPath, 'edit') : memberPath;
emit(spec.method, path, `${controller}#${action}`, line);
}
if (opens) {
const nested = plural ? join(collectionPath, `:${singularize(name)}_id`) : collectionPath;
pushed = { kind: 'resource', path: nested, module: frame.module, controller, collectionPath, memberPath };
}
} else if (/^member\b/.test(text) && opens) {
pushed = { ...frame, kind: 'member' };
} else if (/^collection\b/.test(text) && opens) {
pushed = { ...frame, kind: 'collection' };
} else if ((m = /^root\s*\(?\s*(?:to:\s*)?["']([\w/]+)#(\w+)["']/.exec(text))) {
emit('GET', join(frame.path) || '/', `${frame.module}${m[1]}#${m[2]}`, line);
} else if ((m = /^(get|post|put|patch|delete|match)\s*\(?\s*(:\w+|["'][^"']*["'])(.*)$/.exec(text))) {
const method = m[1]!.toUpperCase() === 'MATCH' ? 'ANY' : m[1]!.toUpperCase();
const raw = sym(m[2]!);
const tail = m[3]!;
const to = /(?:\bto:\s*|=>\s*)["']([\w/]+)#(\w+)["']/.exec(tail);
const resource = stack.slice().reverse().find((f) => f.kind === 'resource' || f.kind === 'member' || f.kind === 'collection');
if (to) {
const base = frame.kind === 'member' ? frame.memberPath! : frame.kind === 'collection' ? frame.collectionPath! : frame.path;
emit(method, join(base, raw), `${frame.module}${to[1]}#${to[2]}`, line);
} else if (resource?.controller && /^[\w-]+$/.test(raw)) {
const action = sym(option(tail, 'action') ?? raw).replace(/-/g, '_');
const base = frame.kind === 'collection' ? resource.collectionPath! : resource.memberPath!;
emit(method, join(base, raw), `${resource.controller}#${action}`, line);
}
}
if (pushed) {
if (opens) stack.push(pushed);
} else if (opens || /^(?:if|unless|case|begin|while|until)\b/.test(text)) {
stack.push({ ...frame, kind: 'block' });
}
}
return { nodes, references };
}
/** Naive singularize for a resource's nested `:x_id` segment. */
function singularize(w: string): string {
if (/ies$/.test(w)) return w.slice(0, -3) + 'y';
if (/(ss|us)$/.test(w)) return w;
if (/(x|ch|sh|ses)$/.test(w) && w.endsWith('es')) return w.slice(0, -2);
return w.replace(/s$/, '');
}
// 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 }> = {
@@ -226,9 +364,17 @@ function resolveControllerAction(ctrlPath: string, action: string, context: Reso
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.
// Fall back: controller class by name, then the action method in its file —
// only the ones whose path ends with the route's module path when there are
// any (an engine's `spree/admin/zones_controller.rb` for `admin/zones`).
const cls = camelize(ctrlPath.split('/').pop()!) + 'Controller';
for (const ctrl of context.getNodesByName(cls).filter((n) => n.kind === 'class')) {
const suffix = `/${ctrlPath}_controller.rb`;
const all = context.getNodesByName(cls).filter((n) => n.kind === 'class');
// A controller of the route's own module that inherits the action is not
// another module's same-named controller that defines it.
const own = ctrlPath.includes('/') ? all.filter((n) => ('/' + n.filePath).endsWith(suffix)) : [];
const classes = own.length > 0 ? own : all;
for (const ctrl of classes) {
const m = context.getNodesInFile(ctrl.filePath).find((n) => (n.kind === 'method' || n.kind === 'function') && n.name === action);
if (m) return m.id;
}