fix(react): hook and context heuristics pick only the file's own in JS/TS (#2240)

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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 03:31:22 +00:00
committed by GitHub
parent a925fa0920
commit f976a050a0
5 changed files with 94 additions and 9 deletions
+1
View File
@@ -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`.
@@ -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<string, string> = {
'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');
});
});
+6 -3
View File
@@ -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');
});
});
+20 -4
View File
@@ -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
+2 -2
View File
@@ -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 `)`. */