fix(express): a function-expression handler is the route's body, like an arrow (#2261)

An Express handler written inline as `function (req, res) {…}` — the form
Express's own examples use — fell to the named-handler branch, so the route
linked to nothing and Steps drew a lone box for `POST /login`. The handler
is now the registration's LAST argument, inline as an arrow or a function
expression, bare or inside a wrapper call, past a trailing comma; an inline
middleware before a named handler no longer stands in for it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 20:11:26 +00:00
committed by GitHub
parent 34ede4de88
commit d45edca3fd
3 changed files with 164 additions and 23 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- An Express route whose handler is written inline as `function (req, res) {…}`, the form Express's own examples use, now links to what that handler calls, just like an arrow handler does. The handler is the route's last argument, so an inline middleware before a named handler no longer stands in for it, and a wrapper like `asyncHandler(async function (req, res) {…})` and a trailing comma both work. Before, a route such as `POST /login` linked to nothing.
- A Python module member used through the imported module, like `generic.ObjectEditView` after `from netbox.views import generic`, `serializers.ValidationError` or `utils.assert_deserialize(…)`, now also follows the package's re-exports to its definition. Before, netbox's view classes lost their `generic.*` base classes, and mealie's test helpers called through `utils.` went unresolved.
- A Python name imported from a package now follows the package's re-exports to where it's defined. That covers both `from .users import *` and `from .tokens import Token` in its `__init__.py`. Before, httpx's own tests' `httpx.Client(…)` and `httpx.URL(…)`, pytest's `pytest.raises(…)` and `pytest.param(…)`, and netbox's `from users.models import User` went unresolved or to a same-named class elsewhere, such as a test's `User` or an Alembic migration's copy of a model.
- In NestJS projects, the provider naming convention (`…Service`, `…Controller`, …) now picks a class only from the reference's own file. Another file's provider is reached through its import, or by ordinary name matching that prefers the nearest declaration. Before, the convention's preference for `*.service.ts` files sent Nest's own specs' `TransientService` and `TestService` to same-named classes in unrelated sample apps.
@@ -0,0 +1,106 @@
/**
* An Express handler written inline as `function (req, res) {…}` — the form
* Express's own examples use — is the route's body just like an arrow is:
* its calls are the route's, so Steps draws what `POST /login` does instead
* of a lone box. The handler is the LAST argument; an inline middleware
* before a named handler is not it, and a trailing comma changes nothing.
*/
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-express-fnexpr-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', private: true, dependencies: { express: '^4.0.0' } }),
'app.js': `const express = require('express');
const app = express();
const router = express.Router();
function authenticate(name, pass, fn) {
fn(null, { name });
}
function restrict(req, res, next) {
next();
}
function loadUser(id) {
return { id };
}
function listPosts() {
return [];
}
function wrap(fn) {
return fn;
}
function showPost(req, res) {
res.send('post');
}
app.post('/login', function (req, res, next) {
authenticate(req.body.username, req.body.password, function (err, user) {
res.redirect('/');
});
});
app.get('/restricted', restrict, function named(req, res) {
res.send(loadUser(req.params.id));
});
router.get(
'/posts',
wrap(async function (req, res) {
res.json(listPosts());
}),
);
app.get('/post/:id', (req, res, next) => next(), showPost);
router.route('/users/:id').get(function (req, res) {
res.json(loadUser(req.params.id));
});
`,
};
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 fromRoute = (name: string, kind: string) => {
const route = cg.getNodesByKind('route').find((r) => r.name === name);
expect(route, name).toBeDefined();
return cg.getOutgoingEdgesFrom([route!.id]).filter((e) => e.kind === kind).map((e) => cg.getNode(e.target)!.name);
};
describe('Express function-expression handlers', () => {
it('lend the route their calls', () => {
expect(fromRoute('POST /login', 'calls')).toContain('authenticate');
expect(fromRoute('GET /restricted', 'calls')).toContain('loadUser');
expect(fromRoute('GET /users/:id', 'calls')).toContain('loadUser');
});
it('are found inside a wrapper call, past a trailing comma', () => {
expect(fromRoute('GET /posts', 'calls')).toContain('listPosts');
});
it('leave a named last argument the handler, even after an inline middleware', () => {
expect(fromRoute('GET /post/:id', 'references')).toContain('showPost');
expect(fromRoute('GET /post/:id', 'calls')).toEqual([]);
});
});
+57 -23
View File
@@ -217,26 +217,16 @@ export const expressResolver: FrameworkResolver = {
const openParen = safe.indexOf('(', match.index);
const closeParen = openParen >= 0 ? matchDelim(safe, openParen, '(', ')') : -1;
const args = closeParen > openParen ? safe.slice(openParen + 1, closeParen) : '';
const arrowAt = args.indexOf('=>');
const inline = inlineHandlerBody(args);
if (arrowAt >= 0) {
// Inline arrow handler (`router.post('/x', async (req,res) => {…})`). The
// arrow is anonymous, so its body — the actual request→service flow — would
// be lost. Attribute the body's calls to the route node as `calls` edges so
// `trace(route, service)` connects. Body = balanced `{…}` after `=>`, or the
// single-expression tail for `=> expr` arrows.
const afterArrow = args.slice(arrowAt + 2);
const braceAt = afterArrow.indexOf('{');
let body = afterArrow;
let bodyStart = openParen + 1 + arrowAt + 2;
if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') {
const end = matchDelim(afterArrow, braceAt, '{', '}');
if (end > braceAt) {
body = afterArrow.slice(braceAt + 1, end);
bodyStart += braceAt + 1;
}
}
for (const name of handlerCallNames(body)) {
if (inline) {
// Inline handler (`router.post('/x', async (req,res) => {…})`,
// `app.get('/x', function (req, res) {…})`). It is anonymous, so its
// body — the actual request→service flow — would be lost. Attribute the
// body's calls to the route node as `calls` edges so
// `trace(route, service)` connects.
const bodyStart = openParen + 1 + inline.start;
for (const name of handlerCallNames(inline.body)) {
references.push({
fromNodeId: routeNode.id,
referenceName: name,
@@ -247,7 +237,7 @@ export const expressResolver: FrameworkResolver = {
language: lang,
});
}
references.push(...replyRefs(safe, bodyStart, bodyStart + body.length, routeNode.id, filePath, lang));
references.push(...replyRefs(safe, bodyStart, bodyStart + inline.body.length, routeNode.id, filePath, lang));
} else {
// Named handler: the LAST comma-separated arg (earlier ones are middleware).
const parts = args.split(',').map((s) => s.trim()).filter(Boolean);
@@ -296,11 +286,13 @@ export const expressResolver: FrameworkResolver = {
updatedAt: now,
};
nodes.push(routeNode);
if (args.includes('=>')) {
for (const name of handlerCallNames(args)) {
const inline = inlineHandlerBody(args);
if (inline) {
for (const name of handlerCallNames(inline.body)) {
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));
const bodyStart = openParen + 1 + inline.start;
references.push(...replyRefs(safe, bodyStart, bodyStart + inline.body.length, routeNode.id, filePath, lang));
} else {
const parts = splitTopLevel(args).map((s) => s.trim()).filter(Boolean);
const last = parts[parts.length - 1];
@@ -430,6 +422,48 @@ function splitTopLevel(args: string): string[] {
return out;
}
/**
* The body of a handler written inline as a registration's LAST argument — an
* arrow (`async (req, res) => {…}`, `req => …`) or a function expression
* (`function (req, res) {…}`, the form Express's own examples use), bare or
* inside a wrapper call (`asyncHandler(async (req, res) => {…})`). `start` is
* the body's offset into `args`. Null when the last argument names a handler
* instead: an inline middleware before a named handler is not the handler.
*/
function inlineHandlerBody(args: string): { body: string; start: number } | null {
// A trailing comma (Prettier's default) leaves an empty last part.
const parts = splitTopLevel(args);
let lastStart = args.length;
let last = '';
for (let i = parts.length - 1, end = args.length; i >= 0; i--) {
const start = end - parts[i]!.length;
if (parts[i]!.trim() !== '') {
last = parts[i]!;
lastStart = start;
break;
}
end = start - 1;
}
const arrowAt = last.indexOf('=>');
const fn = /(?:^|[^\w$.])function\b\s*\*?\s*[\w$]*\s*\(/.exec(last);
if (fn && (arrowAt < 0 || fn.index < arrowAt)) {
const open = fn.index + fn[0].length - 1;
const close = matchDelim(last, open, '(', ')');
const brace = close > open ? last.indexOf('{', close) : -1;
const end = brace >= 0 ? matchDelim(last, brace, '{', '}') : -1;
if (end < 0) return null;
return { body: last.slice(brace + 1, end), start: lastStart + brace + 1 };
}
if (arrowAt < 0) return null;
const afterArrow = last.slice(arrowAt + 2);
const braceAt = afterArrow.indexOf('{');
if (braceAt >= 0 && afterArrow.slice(0, braceAt).trim() === '') {
const end = matchDelim(afterArrow, braceAt, '{', '}');
if (end > braceAt) return { body: afterArrow.slice(braceAt + 1, end), start: lastStart + arrowAt + 2 + braceAt + 1 };
}
return { body: afterArrow, start: lastStart + arrowAt + 2 };
}
/** `/api` + `/users` → `/api/users`; `/api/` + `/` → `/api`. */
function joinPaths(prefix: string, path: string): string {
const a = prefix.replace(/\/+$/, '');