From 7a1186471e5eccc2b4dbe1bfd8d645400e5b7cbe Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 20:28:25 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/route-inline-handler-calls.test.ts | 75 ++++++++++++++++++++ src/resolution/frameworks/express.ts | 40 +++++++---- 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 __tests__/route-inline-handler-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c74ad989..8c8426c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/route-inline-handler-calls.test.ts b/__tests__/route-inline-handler-calls.test.ts new file mode 100644 index 000000000..dc7519a6e --- /dev/null +++ b/__tests__/route-inline-handler-calls.test.ts @@ -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 = { + 'package.json': JSON.stringify({ name: 'api', dependencies: { hono: '^4' } }), + 'src/client/types.ts': `export interface ClientResponse { + text(): Promise; +} +`, + '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'); + }); +}); diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 7aaa8af7f..4b2c676db 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -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(); + 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(); - 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(); - 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));