From f976a050a035573f0fb3990e213879ec572fd268 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 03:31:22 +0000 Subject: [PATCH] fix(react): hook and context heuristics pick only the file's own in JS/TS (#2240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JS/TS module reaches another file's hook or context only through an import (already the import resolver's), so resolveHook/resolveContext pick from the reference's own file alone there; an optional call `f?.(…)` is a bare call. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/react-heuristic-same-file.test.ts | 65 +++++++++++++++++++++ __tests__/resolution.test.ts | 9 ++- src/resolution/frameworks/react.ts | 24 ++++++-- src/resolution/name-matcher.ts | 4 +- 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 __tests__/react-heuristic-same-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c636e17..d7f25e9c 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 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. - In R, a call like `c(1, 2)` is no longer linked to a data variable that happens to share a base function's name, since R skips non-function bindings when it looks up a call. Before, ggplot2's facet tests bound `c <- data_frame(b = 3)`, and every `c(…)` call in those files pointed at that data frame. Functions built by a factory, like ggplot2's `geom_point <- make_constructor(…)`, still resolve. - In Swift, a call to an overloaded method now reaches the overload whose argument labels it spells, with defaulted parameters optional. Before, only the argument count was compared. Alamofire's `self.tableView(tableView, numberOfRowsInSection:)` inside another `tableView` overload linked to itself, and `cancel(optionallyProducingResumeData:)` linked to `cancel(producingResumeData:)`. - A Dart `extension on X { … }` with no name is no longer mistaken for the type `X`, and its members are reachable only inside the file that declares it, as in Dart. Before, bloc's linter linked analyzer's `Token` and Dart's `Uri` to small extension blocks, and every flutter_test `find.text(…)` call linked to a `text` getter on another example's `TaskStatus`. diff --git a/__tests__/react-heuristic-same-file.test.ts b/__tests__/react-heuristic-same-file.test.ts new file mode 100644 index 00000000..3eef6bb7 --- /dev/null +++ b/__tests__/react-heuristic-same-file.test.ts @@ -0,0 +1,65 @@ +/** + * In a JS/TS module another file's binding is reached only through an + * import — the import resolver's to follow — so the React resolver's hook + * and context heuristics pick from the reference's own file alone. trpc's + * WebSocket adapter's `createContext?.(…)` (an option) went to an example + * app's `createContext`, and bulletproof-react's per-app `useUser()` to + * another app's hook. + */ +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-react-heur-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'trpc', private: true, dependencies: { react: '^18.0.0' } }), + 'examples/next-app/src/server/context.ts': `export async function createContext() { + return {}; +} +`, + 'apps/nextjs-app/src/lib/auth.tsx': `export function useUser() { + return null; +} +`, + 'packages/server/src/adapters/ws.ts': `export function getHandler(opts: any) { + const { createContext } = opts; + return createContext?.({}); +} +`, + 'apps/react-vite/src/lib/auth.tsx': `const { useUser } = configureAuth({}); + +export function AuthLoader() { + const user = useUser(); + return user; +} +`, + }; + 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 === 'calls').map((e) => cg.getNode(e.target)!.filePath); +}; + +describe('React name heuristics in JS/TS', () => { + it('never reach another file’s hook or context by name', () => { + expect(targetFilesFrom('packages/server/src/adapters/ws.ts')).not.toContain('examples/next-app/src/server/context.ts'); + expect(targetFilesFrom('apps/react-vite/src/lib/auth.tsx')).not.toContain('apps/nextjs-app/src/lib/auth.tsx'); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3196baef..9bdf768c 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -932,6 +932,8 @@ from ..services import auth_service const frameworks = detectFrameworks(context); const reactResolver = frameworks.find((f) => f.name === 'react'); + // In a JS/TS module another file's hook comes through an import (the + // import resolver's), never by name: App.tsx imports nothing here. const ref = { fromNodeId: 'component:src/App.tsx:App:1', referenceName: 'useAuth', @@ -941,10 +943,11 @@ from ..services import auth_service filePath: 'src/App.tsx', language: 'typescript' as const, }; + expect(reactResolver!.resolve(ref, context)).toBeNull(); - const result = reactResolver!.resolve(ref, context); - expect(result).not.toBeNull(); - expect(result?.targetNodeId).toBe('hook:src/hooks/useAuth.ts:useAuth:1'); + // The file's own hook resolves. + const own = reactResolver!.resolve({ ...ref, filePath: 'src/hooks/useAuth.ts', fromNodeId: 'function:src/hooks/useAuth.ts:x:30' }, context); + expect(own?.targetNodeId).toBe('hook:src/hooks/useAuth.ts:useAuth:1'); }); }); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index c4fa6946..55bbec58 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -79,7 +79,7 @@ export const reactResolver: FrameworkResolver = { // Pattern 2: Hook references (use*) if (ref.referenceName.startsWith('use') && ref.referenceName.length > 3) { - const result = resolveHook(ref.referenceName, ref.filePath, context); + const result = resolveHook(ref.referenceName, ref.filePath, context, ref.language); if (result) { return { original: ref, @@ -92,7 +92,7 @@ export const reactResolver: FrameworkResolver = { // Pattern 3: Context references if (ref.referenceName.endsWith('Context') || ref.referenceName.endsWith('Provider')) { - const result = resolveContext(ref.referenceName, context); + const result = resolveContext(ref.referenceName, ref, context); if (result) { return { original: ref, @@ -641,10 +641,16 @@ function resolveComponent( return components.length === 1 ? components[0]!.id : null; } +/** JS/TS (and their JSX dialects): modules where a cross-file name needs an import. */ +function isEsmLanguage(language?: string): boolean { + return language === 'typescript' || language === 'tsx' || language === 'javascript' || language === 'jsx'; +} + + /** * Resolve a custom hook reference using name-based lookup */ -function resolveHook(name: string, fromFile: string, context: ResolutionContext): string | null { +function resolveHook(name: string, fromFile: string, context: ResolutionContext, language?: string): string | null { const candidates = context.getNodesByName(name); if (candidates.length === 0) return null; @@ -657,6 +663,10 @@ function resolveHook(name: string, fromFile: string, context: ResolutionContext) if (hooks.length === 0) return null; const sameFile = hooks.find((n) => n.filePath === fromFile); if (sameFile) return sameFile.id; + // A JS/TS module reaches another file's hook only by importing it — the + // import resolver's to follow (an imported name never gets here) — never + // by name alone. + if (isEsmLanguage(language)) return null; // Prefer hooks directories const HOOK_DIRS = ['/hooks/', '/src/hooks/', '/lib/hooks/', '/utils/hooks/']; @@ -671,7 +681,13 @@ function resolveHook(name: string, fromFile: string, context: ResolutionContext) /** * Resolve a context reference using name-based lookup */ -function resolveContext(name: string, context: ResolutionContext): string | null { +function resolveContext(name: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + // In a JS/TS module only the file's own context is in reach by name; another + // file's comes through an import (trpc's adapters' `createContext?.(…)` is an + // option, not an example app's `createContext`). + if (isEsmLanguage(ref.language)) { + return context.getNodesByName(name).find((n) => n.filePath === ref.filePath)?.id ?? null; + } const candidates = context.getNodesByName(name); if (candidates.length === 0) { // Try without Context/Provider suffix diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c3221f24..cc54eea3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1821,8 +1821,8 @@ function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boo return BARE_CALL_KEYWORDS.has(line.slice(start, end)); } -/** `\s*[(<]` from a given index (sticky). */ -const CALL_OPENER = /\s*[(<]/y; +/** `\s*[(<]` from a given index (sticky) — an optional call's `?.(` too. */ +const CALL_OPENER = /\s*(?:\?\.\s*)?[(<]/y; const WHITESPACE = /\s/; const WORD_CHAR = /\w/; /** A character that ends a receiver: `.`, a word character, `$`, `]` or `)`. */