fix(vapor): bind a route to the handler on the type it names (#2106)

`use: SearchController.show` kept only `show`, and name matching bound
every controller's `show` route to whichever `show` came first — on
SwiftPackageIndex-Server 7 of 13 routes pointed at another endpoint's
code (Search, HealthCheck, ValidateSPIManifest and ReadyForSwift6's
`show` all at AuthorController.show).

The route's handler ref now keeps the type: `Type@method` (dotted for
nested types, `API.PackageController@get`), `@method` for `self.x` and a
bare `x`, and a `let todos = TodoController()` receiver's type. The Vapor
resolver claims these and resolves on the owner's qualified name:
nested types, handlers declared in `extension Type` in another file, and
`extension API.PackageController` (whose node carries only the last
segment, so its declaration line decides). `self.x` / bare `x` take the
method of the type the route is written in. Of one type's overloads, the
one declared to take a `Request` wins. Two types, or none, leave the
ref unresolved rather than guess.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-29 19:50:24 +00:00
committed by GitHub
parent 26d6ccece5
commit 48b1a19b19
4 changed files with 325 additions and 13 deletions
+1
View File
@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
- A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading.
- A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading.
## [1.6.1] - 2026-09-29
+11 -11
View File
@@ -1652,7 +1652,7 @@ describe('vaporResolver.extract', () => {
const src = `app.get("users", use: listUsers)\n`;
const { nodes, references } = vaporResolver.extract!('routes.swift', src);
expect(nodes[0].name).toBe('GET /users');
expect(references[0].referenceName).toBe('listUsers');
expect(references[0].referenceName).toBe('@listUsers');
});
it('extracts grouped RouteCollection routes with the group prefix and no path arg', () => {
@@ -1673,9 +1673,9 @@ func boot(routes: RoutesBuilder) throws {
'POST /todos',
]);
expect(references.map((r) => r.referenceName).sort()).toEqual([
'create',
'delete',
'index',
'@create',
'@delete',
'@index',
]);
});
@@ -1683,7 +1683,7 @@ func boot(routes: RoutesBuilder) throws {
const src = `router.get("users", User.parameter, "edit", use: self.editUserHandler)\n`;
const { nodes, references } = vaporResolver.extract!('UserController.swift', src);
expect(nodes[0].name).toBe('GET /users/edit');
expect(references[0].referenceName).toBe('editUserHandler');
expect(references[0].referenceName).toBe('@editUserHandler');
});
it('ignores non-route .get calls that lack use: (e.g. Environment.get)', () => {
@@ -1725,11 +1725,11 @@ app.get(
'GET /multi/line',
]);
expect(references.map((r) => r.referenceName)).toEqual([
'list',
'listUsers',
'edit',
'update',
'multiLine',
'@list',
'@listUsers',
'UserController@edit',
'@update',
'@multiLine',
]);
});
});
@@ -2014,7 +2014,7 @@ app.get("real", use: listUsers)
`;
const { nodes, references } = vaporResolver.extract!('routes.swift', src);
expect(nodes.map((n) => n.name)).toEqual(['GET /real']);
expect(references.map((r) => r.referenceName)).toEqual(['listUsers']);
expect(references.map((r) => r.referenceName)).toEqual(['@listUsers']);
});
it('nestjs: skips // and /* */ commented decorators', () => {
+194
View File
@@ -0,0 +1,194 @@
/**
* A Vapor route binds to the handler it names — on the type it names.
*
* `app.get("search", use: SearchController.show)` used to keep only `show`,
* and name matching bound every controller's `show` route to whichever `show`
* came first (SwiftPackageIndex: 7 of 13 routes drew another endpoint's code).
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const projects: string[] = [];
afterAll(() => {
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
});
async function project(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vapor-handlers-'));
projects.push(root);
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);
}
return CodeGraph.init(root, { index: true });
}
/** Each route's handler, as `METHOD path -> Owner::method (file)`. */
function handlers(cg: CodeGraph): string[] {
return cg
.getNodesByKind('route')
.map((route) => {
const bound = cg.getCallees(route.id).map((c) => `${c.node.qualifiedName} (${path.basename(c.node.filePath)})`);
return `${route.name} :${route.startLine} -> ${bound.join(', ') || 'nothing'}`;
})
.sort();
}
const PACKAGE = `// swift-tools-version:5.9
import PackageDescription
let package = Package(name: "App", dependencies: [.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")])
`;
const controller = (name: string, method = 'show') => `import Vapor
enum ${name} {
static func ${method}(req: Request) async throws -> String { "${name}" }
}
`;
describe('Vapor: use: Type.method binds to that type', () => {
it('binds same-named handlers on different controllers, nested API types and extensions to their own', async () => {
const cg = await project({
'Package.swift': PACKAGE,
'Sources/App/routes.swift': `import Vapor
func routes(_ app: Application) throws {
app.get("authors", use: AuthorController.show)
app.get("search", use: SearchController.show)
app.get("health", use: HealthCheckController.show)
app.get("packages", use: PackageController.show)
app.get("api", "packages", use: API.PackageController.get)
app.get("api", "search", use: API.SearchController.get)
app.get("missing", use: MissingController.show)
}
`,
'Sources/App/Controllers/AuthorController.swift': controller('AuthorController'),
'Sources/App/Controllers/SearchController.swift': controller('SearchController'),
'Sources/App/Controllers/HealthCheckController.swift': controller('HealthCheckController'),
'Sources/App/Controllers/PackageController.swift': `import Vapor
enum PackageController {}
`,
// A handler declared in an extension of the type, in another file.
'Sources/App/Controllers/PackageController+routes.swift': `import Vapor
extension PackageController {
static func show(req: Request) async throws -> String { "package" }
static func get(req: Request) async throws -> String { "html get" }
}
`,
'Sources/App/Controllers/API/API.swift': 'enum API {}\n',
'Sources/App/Controllers/API/API+PackageController.swift': `import Vapor
extension API {
enum PackageController {
static func get(req: Request) async throws -> String { "api package" }
}
}
`,
// Declared in \`extension API.SearchController\` — its node is named
// \`SearchController\`, the same qualified name the HTML controller's has.
'Sources/App/Controllers/API/API+SearchController.swift': `import Vapor
extension API {
enum SearchController {}
}
extension API.SearchController {
static func get(req: Request) async throws -> String { "api search" }
}
`,
});
try {
expect(handlers(cg)).toEqual([
'GET /api/packages :7 -> API::PackageController::get (API+PackageController.swift)',
'GET /api/search :8 -> SearchController::get (API+SearchController.swift)',
'GET /authors :3 -> AuthorController::show (AuthorController.swift)',
'GET /health :5 -> HealthCheckController::show (HealthCheckController.swift)',
// Never another controller's `show`.
'GET /missing :9 -> nothing',
'GET /packages :6 -> PackageController::show (PackageController+routes.swift)',
'GET /search :4 -> SearchController::show (SearchController.swift)',
]);
} finally {
cg.close();
}
});
it("picks the overload that takes the request, wherever it sits in the file", async () => {
const cg = await project({
'Package.swift': PACKAGE,
'Sources/App/routes.swift': `import Vapor
func routes(_ app: Application) throws {
app.get("sitemap", use: SiteMapController.index)
}
`,
'Sources/App/Controllers/SiteMapController.swift': `import Vapor
enum SiteMapController {
static func index(packages: [String]) -> String { packages.joined() }
@Sendable
static func index(req: Request) async throws -> String { index(packages: []) }
}
`,
});
try {
const route = cg.getNodesByKind('route')[0]!;
expect(cg.getCallees(route.id).map((c) => `${c.node.qualifiedName}:${c.node.startLine}`)).toEqual(['SiteMapController::index:4']);
} finally {
cg.close();
}
});
it('binds use: self.x and a bare x to the method of the type the route is written in', async () => {
const cg = await project({
'Package.swift': PACKAGE,
'Sources/App/Controllers/Collections.swift': `import Vapor
struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos")
todos.get(use: index)
todos.post(use: self.create)
}
func index(req: Request) async throws -> String { "todos" }
func create(req: Request) async throws -> String { "new todo" }
}
struct UserController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let users = routes.grouped("users")
users.get(use: index)
}
func index(req: Request) async throws -> String { "users" }
}
`,
// A handler declared in another extension of the collection's type.
'Sources/App/Controllers/Tags.swift': `import Vapor
struct TagController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
routes.get("tags", use: self.index)
}
}
`,
'Sources/App/Controllers/Tags+handlers.swift': `import Vapor
extension TagController {
func index(req: Request) async throws -> String { "tags" }
}
`,
'Sources/App/routes.swift': `import Vapor
func routes(_ app: Application) throws {
let todos = TodoController()
app.get("all-todos", use: todos.index)
app.get("hello", use: hello)
}
func hello(req: Request) -> String { "hi" }
`,
});
try {
expect(handlers(cg)).toEqual([
'GET /all-todos :4 -> TodoController::index (Collections.swift)',
'GET /hello :5 -> hello (routes.swift)',
'GET /tags :4 -> TagController::index (Tags+handlers.swift)',
'GET /todos :5 -> TodoController::index (Collections.swift)',
'GET /users :14 -> UserController::index (Collections.swift)',
'POST /todos :6 -> TodoController::create (Collections.swift)',
]);
} finally {
cg.close();
}
});
});
+119 -2
View File
@@ -291,7 +291,24 @@ export const vaporResolver: FrameworkResolver = {
return false;
},
// A route's handler ref (`SearchController@show`, `@index`) names no declared
// symbol, so resolveOne's pre-filter would drop it before resolve() runs.
claimsReference(name: string): boolean {
return VAPOR_HANDLER.test(name);
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Pattern 0: a route's handler — `use: SearchController.show` arrives as
// `SearchController@show`, `use: self.index` / `use: index` as `@index`.
// Resolved on the type the route names, never by the method's name alone:
// every controller has a `show`. No match, or two, is left unresolved.
const handler = VAPOR_HANDLER.exec(ref.referenceName);
if (handler) {
if (ref.language !== 'swift') return null;
const target = resolveVaporHandler(handler[1] ?? null, handler[2]!, ref, context);
return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null;
}
// Pattern 1: Controller references
if (ref.referenceName.endsWith('Controller')) {
const result = resolveByNameAndKind(ref.referenceName, VAPOR_CONTROLLER_KINDS, VAPOR_CONTROLLER_DIRS, context);
@@ -378,6 +395,10 @@ export const vaporResolver: FrameworkResolver = {
// be indexed as a route (groups `["req","get","foo.","bar"]`) where both
// this pattern and the original match nothing.
const routeRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,)*\s*)use:\s*([A-Za-z_][\w.]*)/g;
// `let todos = TodoController()` — what a `use: todos.index` receiver is.
const receiverTypes = new Map<string, string>();
const receiverRegex = /\b(?:let|var)\s+([a-z_]\w*)\s*(?::\s*([A-Z]\w*)\s*)?=\s*([A-Z][\w.]*)\s*\(/g;
while ((gm = receiverRegex.exec(safe)) !== null) receiverTypes.set(gm[1]!, gm[2] ?? gm[3]!);
let match: RegExpExecArray | null;
while ((match = routeRegex.exec(safe)) !== null) {
const [, receiver, method, segsStr, handlerExpr] = match;
@@ -400,8 +421,7 @@ export const vaporResolver: FrameworkResolver = {
};
nodes.push(routeNode);
// Last segment of a dotted handler (self.list / UserController.list -> list)
const handlerName = handlerExpr!.split('.').pop();
const handlerName = vaporHandlerRef(handlerExpr!, receiverTypes);
if (handlerName) {
references.push({
fromNodeId: routeNode.id,
@@ -430,6 +450,103 @@ const VAPOR_CONTROLLER_DIRS = ['/Controllers/', '/Controller/', '/Routes/'];
const FLUENT_MODEL_DIRS = ['/Models/', '/Model/', '/Entities/', '/Database/'];
const VAPOR_MIDDLEWARE_DIRS = ['/Middleware/', '/Middlewares/'];
/** A Vapor route's handler ref: `Type@method` (the type may be dotted), or `@method`. */
const VAPOR_HANDLER = /^((?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*)?@([A-Za-z_]\w*)$/;
/**
* The handler ref for `use: <expr>`, keeping the type the method is on:
* `API.PackageController.get` → `API.PackageController@get`; `self.index` and
* a bare `index` → `@index` (the type the route is written in); `todos.index`
* → the type `todos` was made from, when the file says.
*/
function vaporHandlerRef(expr: string, receiverTypes: ReadonlyMap<string, string>): string | null {
const segs = expr.split('.').filter((s) => s.length > 0);
const method = segs.pop();
if (!method) return null;
if (segs[0] === 'self' || segs[0] === 'Self') segs.shift();
if (segs.length === 0) return `@${method}`;
if (/^[A-Z]/.test(segs[0]!)) return `${segs.join('.')}@${method}`;
const type = segs.length === 1 ? receiverTypes.get(segs[0]!) : undefined;
return type ? `${type}@${method}` : `@${method}`;
}
/**
* One type's method among `candidates`, or null when they belong to different
* types. Of a type's overloads, the one declared to take a `Request` — what
* Vapor calls a handler with — else the earliest.
*/
function oneOwnersMethod(candidates: Node[], ownerOf: (n: Node) => string, context: ResolutionContext): string | null {
if (candidates.length === 0) return null;
if (new Set(candidates.map(ownerOf)).size > 1) return null;
const earliest = (nodes: Node[]) => nodes.reduce((a, b) => (a.startLine <= b.startLine ? a : b)).id;
if (candidates.length === 1) return candidates[0]!.id;
const takesRequest = candidates.filter((n) => {
const lines = context.readFile(n.filePath)?.split(/\r?\n/) ?? [];
const head = lines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 2)).join(' ');
return /\(\s*(?:\w+\s+)?\w+\s*:\s*Request\b/.test(head);
});
return earliest(takesRequest.length > 0 ? takesRequest : candidates);
}
function resolveVaporHandler(typePath: string | null, method: string, ref: UnresolvedRef, context: ResolutionContext): string | null {
const callables = context
.getNodesByName(method)
.filter((n) => (n.kind === 'method' || n.kind === 'function') && n.language === 'swift');
if (callables.length === 0) return null;
const ownerOf = (n: Node): string => n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.length - method.length - 2));
if (typePath === null) {
// `use: self.index` / `use: index`: the type whose body holds the route —
// its own method in this file, else one declared in another extension of it.
const owner = context
.getNodesInFile(ref.filePath)
.filter((n) => SWIFT_TYPE_KINDS.has(n.kind) && n.startLine <= ref.line && n.endLine >= ref.line)
.reduce<Node | null>((inner, n) => (!inner || n.startLine >= inner.startLine ? n : inner), null);
if (!owner) {
// A route in a top-level `func routes(_ app:)` names a function in scope.
const functions = callables.filter((n) => n.kind === 'function');
const sameFile = functions.filter((n) => n.filePath === ref.filePath);
if (sameFile.length > 0) return oneOwnersMethod(sameFile, ownerOf, context);
return functions.length === 1 ? functions[0]!.id : null;
}
const own = callables.filter(
(n) => n.filePath === ref.filePath && n.startLine >= owner.startLine && n.endLine <= owner.endLine
);
if (own.length > 0) return oneOwnersMethod(own, ownerOf, context);
typePath = owner.qualifiedName.replace(/::/g, '.');
}
const segs = typePath.split('.');
const type = segs[segs.length - 1]!;
const full = segs.join('::');
const exact = callables.filter((n) => ownerOf(n) === full);
if (exact.length > 0) return oneOwnersMethod(exact, ownerOf, context);
const nested = callables.filter((n) => ownerOf(n).endsWith(`::${full}`));
if (nested.length > 0) return oneOwnersMethod(nested, ownerOf, context);
if (segs.length > 1) {
// `extension API.PackageController { static func get }` names its node by
// the last segment (`PackageController::get`) — the same QN a top-level
// `PackageController`'s method has, so the extension's own line decides.
const declared = new RegExp(String.raw`\bextension\s+${segs.join(String.raw`\s*\.\s*`)}\b`);
const inExtension = callables.filter((n) => {
if (ownerOf(n) !== type) return false;
const owner = context
.getNodesInFile(n.filePath)
.filter((o) => SWIFT_TYPE_KINDS.has(o.kind) && o.name === type && o.startLine <= n.startLine && o.endLine >= n.endLine)
.reduce<Node | null>((inner, o) => (!inner || o.startLine >= inner.startLine ? o : inner), null);
if (!owner) return false;
// The node starts at its attributes (`@available(…)`), the keyword a line or two on.
const head = (context.readFile(n.filePath)?.split(/\r?\n/) ?? []).slice(owner.startLine - 1, owner.startLine + 2).join(' ');
return declared.test(head);
});
return oneOwnersMethod(inExtension, ownerOf, context);
}
// A type named without the namespace it is nested in — only when one type fits.
return oneOwnersMethod(callables.filter((n) => ownerOf(n).endsWith(`::${type}`)), ownerOf, context);
}
const SWIFT_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'protocol']);
const VIEW_KINDS = new Set(['struct', 'component']);
const CLASS_KINDS = new Set(['class']);
const MODEL_KINDS = new Set(['struct', 'class']);