mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 06:22:38 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user