From 33436464464d0295a69dec6348e7e3446684a31f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 03:52:28 +0000 Subject: [PATCH] fix(express): name patterns pick only the calling file's own declaration (#2243) The middleware/controller/service heuristics took the first same-named node anywhere; a JS/TS module reaches another file's only through an import or require (the import resolver's), so they now keep a same-file pick only. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/express-heuristic-same-file.test.ts | 60 +++++++++++++++++++ src/resolution/frameworks/express.ts | 20 +++++-- 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 __tests__/express-heuristic-same-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43abf835..e516c1b2 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 +- In JavaScript and TypeScript, the Express conventions for middleware (`validate…`, `auth`, `logger`, …), controllers and services now pick only the calling file's own declaration. Another file's is reached only through an `import` or `require`. Before, SvelteKit's remote functions linked `validate(arg)` to its config loader's `validate` in another package, and Express's examples linked `logger('dev')` to one example's `logger`. - An Objective-C class that conforms to a protocol of its own name now links to that protocol instead of to itself. Swift's naming conventions for views, models and controllers also no longer apply to Objective-C code. Before, every one of SDWebImage's `@interface SDDiskCache : NSObject `-style classes implemented itself. - In Solidity, a call to an overloaded function now reaches the overload whose parameter count it fits, as in the other languages with overloading. Before, OpenZeppelin's `_checkRole(role, _msgSender())` inside the one-argument `_checkRole(bytes32 role)` was linked to itself. - In JavaScript and TypeScript, the React conventions for hooks (`use…`) and contexts (`…Context`, `…Provider`) now pick only from the reference's own file. Another file's hook or context is reached only through an import. An optional call like `onSuccess?.(…)` now gets the same scope rules as a plain call. Before, trpc's WebSocket adapter's `createContext?.(…)` option linked to an example app's `createContext`, zod's `schemas.ParseContext` linked to the v3 copy instead of v4's, and bulletproof-react's apps linked `useUser()` to one another's. diff --git a/__tests__/express-heuristic-same-file.test.ts b/__tests__/express-heuristic-same-file.test.ts new file mode 100644 index 00000000..a02cf938 --- /dev/null +++ b/__tests__/express-heuristic-same-file.test.ts @@ -0,0 +1,60 @@ +/** + * The Express resolver's middleware / controller / service name patterns pick + * only the calling file's own declaration — another file's comes through an + * `import` / `require`, the import resolver's. SvelteKit's remote functions' + * `validate(arg)` went to the config loader's `validate` in another package, + * and express' examples' `logger('dev')` to one example's `logger`. + */ +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-heur-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'app', private: true, dependencies: { express: '^4.0.0' } }), + 'packages/kit/src/core/config/options.js': `export function validate(config) { + return config; +} +`, + 'packages/kit/src/runtime/remote/query.js': `export function query(validate, arg) { + return () => validate(arg); +} +`, + 'server/app.js': `const express = require('express'); +const app = express(); + +function validateBody(req, res, next) { + next(); +} + +app.post('/items', validateBody, (req, res) => res.send('ok')); +`, + }; + 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 targetFilesFrom = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!.filePath); +}; + +describe('Express name patterns', () => { + it('never reach another file’s declaration by name', () => { + expect(targetFilesFrom('packages/kit/src/runtime/remote/query.js')).not.toContain('packages/kit/src/core/config/options.js'); + }); +}); diff --git a/src/resolution/frameworks/express.ts b/src/resolution/frameworks/express.ts index 4b2c676d..ec8b21d5 100644 --- a/src/resolution/frameworks/express.ts +++ b/src/resolution/frameworks/express.ts @@ -128,9 +128,16 @@ export const expressResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // A JS/TS module reaches another file's middleware, controller or service + // only through an `import` / `require` — the import resolver's — never by + // name: SvelteKit's remote functions' `validate(arg)` went to the config + // loader's `validate` in another package. The patterns below stay for the + // file's own declarations. + const sameFile = (id: string | null): string | null => + id !== null && context.getNodeById?.(id)?.filePath === ref.filePath ? id : null; // Pattern 1: Middleware references if (isMiddlewareName(ref.referenceName)) { - const result = resolveMiddleware(ref.referenceName, context); + const result = sameFile(resolveMiddleware(ref.referenceName, context, ref.filePath)); if (result) { return { original: ref, @@ -145,7 +152,7 @@ export const expressResolver: FrameworkResolver = { const controllerMatch = ref.referenceName.match(/^(\w+)Controller\.(\w+)$/); if (controllerMatch) { const [, controller, method] = controllerMatch; - const result = resolveControllerMethod(controller!, method!, context); + const result = sameFile(resolveControllerMethod(controller!, method!, context)); if (result) { return { original: ref, @@ -160,7 +167,7 @@ export const expressResolver: FrameworkResolver = { const serviceMatch = ref.referenceName.match(/^(\w+)(Service|Helper|Utils?)\.(\w+)$/); if (serviceMatch) { const [, name, suffix, method] = serviceMatch; - const result = resolveServiceMethod(name! + suffix!, method!, context); + const result = sameFile(resolveServiceMethod(name! + suffix!, method!, context)); if (result) { return { original: ref, @@ -475,10 +482,11 @@ function isMiddlewareName(name: string): boolean { */ function resolveMiddleware( name: string, - context: ResolutionContext + context: ResolutionContext, + fromFile?: string, ): string | null { - // Try exact name first - const candidates = context.getNodesByName(name); + // Try exact name first — the calling file's own first. + const candidates = context.getNodesByName(name).sort((a, b) => Number(b.filePath === fromFile) - Number(a.filePath === fromFile)); const match = candidates.find((n) => n.name.toLowerCase() === name.toLowerCase() || n.name.toLowerCase() === name.replace(/Middleware$/i, '').toLowerCase()