fix(routes): inline handler calls keep their receivers (#2200)

The Express-style route extractor attributed an inline arrow handler's calls
to the route by bare name - `c.text('Hello')` became `text` - so each
matched whatever same-named symbol the project had: hono's 419 `c.text(...)`
routes called its client's `ClientResponse.text`, `c.html(...)` its `html`
helper, `c.req.parseBody()` a utility `parseBody`. A member call now keeps
its receiver (`c.text`, `userService.lookup`) and resolves like any member
call; a member of an expression (`a.b().c(`) names nothing to follow. Both
the plain and the `router.route(...).get(...)` forms share the scanner.

hono -538 +71 (`Context::text` / `Context::html` where the receiver
resolves; `c.req.*` 3-link chains now stay unresolved like any other
untyped chain); express, react-redux-realworld, trpc, kit, BookStack
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 20:28:25 +00:00
committed by GitHub
parent 7493fdef21
commit 7a1186471e
3 changed files with 102 additions and 14 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Calls inside an inline route handler now keep their receivers, so they resolve like any other method call. Before, Hono's `app.get('/', (c) => c.text('Hello'))` linked the route to the one other `text` method in the project, its client's `ClientResponse.text`; that happened in over 400 routes. A call like `userService.lookup(id)` reaches the service it names.
- A Scala chained call like `xs.map { … }` no longer lands on an object that happens to be named `map`, as with cats' `alleycats.std.map` and its `syntax`/`instances` objects. A type or object in a chain is only matched when the receiver names what holds it, as in `sttp.client4.Response(…)`.
- A Python call to a name the function or module binds itself now means that local value, not a same-named function in another file. In Django REST Framework's tests, `view = SomeView.as_view()` followed by `view(request)` had pointed at one test file's `def view`. The same goes for a parameter or a `for`/`with … as` target. A pytest fixture a test takes as a parameter is still the fixture.
- A method call on a variable is no longer guessed to be a test double's method just because the double has the only method of that name. That covered classes named `Mocked…`, `Fake…`, `Stub…` or `Dummy…`: django-allauth's `resp.json()` calls had all gone to its `MockedResponse`. A test that names the double still calls it.
@@ -0,0 +1,75 @@
/**
* The calls an inline route handler makes keep their receivers. Read bare,
* hono's `app.get('/', (c) => c.text('Hello'))` called the one other `text`
* in the project — its client's `ClientResponse.text` — 419 times; kept as
* `c.text` it resolves like any other member call, and `userService.lookup()`
* reaches the service it names.
*/
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-route-inline-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'api', dependencies: { hono: '^4' } }),
'src/client/types.ts': `export interface ClientResponse {
text(): Promise<string>;
}
`,
'src/services.ts': `export const userService = {
lookup(id: number) {
return { id };
},
};
`,
'src/routes/app.ts': `import { Hono } from 'hono';
import { userService } from '../services';
const app = new Hono();
app.get('/hello', (c) => c.text('Hello'));
app.get('/users/:id', async (c) => {
const user = await userService.lookup(1);
return c.json(user);
});
export default app;
`,
};
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 routeCalls = (name: string) => {
const route = cg.getNodesByKind('route').find((r) => r.name.endsWith(name));
if (!route) return [];
return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'calls').map((e) => {
const t = cg.getNode(e.target)!;
return `${t.filePath}: ${t.qualifiedName}`;
}).sort();
};
describe('inline route handler calls', () => {
it('are not a same-named method of an unrelated type', () => {
expect(routeCalls('/hello')).not.toContain('src/client/types.ts: ClientResponse::text');
});
it('reach the service a member call names', () => {
expect(routeCalls('/users/:id')).toContain('src/services.ts: lookup');
});
});
+26 -14
View File
@@ -49,6 +49,30 @@ const RESERVED_CALLS = new Set([
'Date', 'Math', 'JSON', 'Promise', 'require', 'fail', 'redirect',
]);
/**
* The calls an inline handler's body makes, each once, framework noise aside.
* A member call keeps its receiver (`userService.find`, `c.text`) so it
* resolves as one: bare, hono's `c.text('…')` matched its client's
* `ClientResponse.text` 423 times. A member of an expression (`a.b().c(`)
* names nothing this can follow.
*/
function handlerCallNames(body: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
const callRe = /((?:[A-Za-z_$][\w$]*\s*\??\.\s*)*)([A-Za-z_$][\w$]*)\s*\(/g;
let cm: RegExpExecArray | null;
while ((cm = callRe.exec(body)) !== null) {
const method = cm[2]!;
const receiver = cm[1]!.replace(/\s|\?/g, '').replace(/\.$/, '');
if (!receiver && /\.\s*$/.test(body.slice(0, cm.index))) continue;
const name = receiver ? `${receiver}.${method}` : method;
if (seen.has(name) || RESERVED_CALLS.has(method)) continue;
seen.add(name);
out.push(name);
}
return out;
}
/**
* The replies an inline handler makes — `res.status(404).json({…})`,
* `res.json(user)`, `reply.send(…)`, `ctx.body = …` aside — as references the
@@ -205,13 +229,7 @@ export const expressResolver: FrameworkResolver = {
bodyStart += braceAt + 1;
}
}
const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g;
const seen = new Set<string>();
let cm: RegExpExecArray | null;
while ((cm = callRe.exec(body)) !== null) {
const name = cm[1]!;
if (seen.has(name) || RESERVED_CALLS.has(name)) continue;
seen.add(name);
for (const name of handlerCallNames(body)) {
references.push({
fromNodeId: routeNode.id,
referenceName: name,
@@ -272,13 +290,7 @@ export const expressResolver: FrameworkResolver = {
};
nodes.push(routeNode);
if (args.includes('=>')) {
const callRe = /\b([A-Za-z_$][\w$]*)\s*\(/g;
const seen = new Set<string>();
let cm: RegExpExecArray | null;
while ((cm = callRe.exec(args)) !== null) {
const name = cm[1]!;
if (seen.has(name) || RESERVED_CALLS.has(name)) continue;
seen.add(name);
for (const name of handlerCallNames(args)) {
references.push({ fromNodeId: routeNode.id, referenceName: name, referenceKind: 'calls', line, column: 0, filePath, language: lang });
}
references.push(...replyRefs(safe, openParen + 1, closeParen, routeNode.id, filePath, lang));