mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `)`. */
|
||||
|
||||
Reference in New Issue
Block a user