fix(react): recognize forwardRef/memo/styled components + index JSX-file routes (#841)
forwardRef/memo/styled-wrapped component consts were classified as plain `constant` nodes (the initializer is a call/tagged-template, not a bare arrow), so the JSX-render synthesizer and component resolution skipped them — callers and impact returned empty for the entire shadcn/ui-style UI layer. Recognize them in the tree-sitter extractor as `component` nodes (correct body range + callee capture), PascalCase-gated so a memoization util stays a constant. Separately, the `react` resolver's `languages` lacked 'tsx'/'jsx', so its `extract()` never ran on JSX files — React Router `<Route>`/createBrowserRouter and Next.js page routes (which only live in .tsx/.jsx) were never indexed. Add 'tsx'/'jsx' and make `extract()` route-only: the component/hook regex it carried duplicated tree-sitter nodes (a `useAuth` became two `function` nodes) and is fully superseded by the extractor now. Validated before/after: taxonomy 0->99 component nodes (35 w/ callers) + 1->15 routes; radix 0->262 components (80 w/ callers); cypress-realworld-app 45->52 routes (7 <Route> tags from .tsx); non-React control unchanged; node count stable. New tests: react-hoc-component.test.ts + a route e2e in frameworks-integration.test.ts. Root-caused by @maxmilian (#846); reported by @Arlandaren. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b5090cbad5
commit
64426cad93
@@ -44,6 +44,10 @@ export { generateNodeId } from './tree-sitter-helpers';
|
||||
*/
|
||||
const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/;
|
||||
|
||||
/** React HOC callees whose result is itself a component — a PascalCase const
|
||||
* initialized with one of these is a component, not a constant (#841). */
|
||||
const REACT_COMPONENT_HOCS = new Set(['forwardRef', 'memo', 'React.forwardRef', 'React.memo']);
|
||||
|
||||
/** Vue store collections whose object-literal members are the symbols an agent
|
||||
* looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are
|
||||
* findable + readable (the foundation under any later dispatch-bridge synth). */
|
||||
@@ -1421,6 +1425,71 @@ export class TreeSitterExtractor {
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a React component declared via an HOC wrapper whose result is itself a
|
||||
* component: `forwardRef(...)`, `memo(...)`, `React.forwardRef/memo(...)`, and
|
||||
* styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``. These
|
||||
* initializers are a call / tagged-template (not a bare arrow), so the const is
|
||||
* otherwise classified `constant` — and a constant is skipped by both the
|
||||
* JSX-render edge synthesizer and component resolution, so `<Button/>` usages
|
||||
* get no edge and callers/impact silently return empty (#841).
|
||||
*
|
||||
* Returns `{ inner }` — the inline render function to extract as the component
|
||||
* body, or `null` when the wrapper has no inline function (`memo(Imported)`,
|
||||
* `styled.button\`…\``) and only a bodyless component node is minted — or
|
||||
* `undefined` when this initializer is not a recognized component wrapper.
|
||||
*/
|
||||
private reactComponentHoc(valueNode: SyntaxNode): { inner: SyntaxNode | null } | undefined {
|
||||
if (valueNode.type !== 'call_expression') return undefined;
|
||||
const callee = getChildByField(valueNode, 'function');
|
||||
if (!callee) return undefined;
|
||||
const calleeText = getNodeText(callee, this.source);
|
||||
// styled-components / emotion: `styled.button\`…\`` / `styled(Base)\`…\``.
|
||||
// tree-sitter models these tagged templates as a call_expression whose callee
|
||||
// is the `styled.x` / `styled(Base)` tag (\b avoids matching `styledFoo`).
|
||||
// No inline render fn — the argument is the CSS template.
|
||||
if (/^styled\b/.test(calleeText)) return { inner: null };
|
||||
// React HOCs: `forwardRef`/`memo`/`React.forwardRef`/`React.memo`.
|
||||
if (!REACT_COMPONENT_HOCS.has(calleeText)) return undefined;
|
||||
// The first arrow / function-expression argument is the render fn (if inline;
|
||||
// `memo(Imported)` passes a bare identifier and has none).
|
||||
const args = getChildByField(valueNode, 'arguments');
|
||||
let inner: SyntaxNode | null = null;
|
||||
if (args) {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const a = args.namedChild(i);
|
||||
if (a && (a.type === 'arrow_function' || a.type === 'function_expression')) {
|
||||
inner = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { inner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `component` node for an HOC-wrapped React component declaration (see
|
||||
* reactComponentHoc). Named by the declarator (`Button`) and located at it so
|
||||
* the node range spans the body. When the wrapper has an inline render
|
||||
* function, its body is walked so the component's callees (hooks, helpers) are
|
||||
* captured under the component node — matching how a plain
|
||||
* `const Foo = () => …` arrow component already behaves.
|
||||
*/
|
||||
private extractReactComponentNode(
|
||||
name: string,
|
||||
declarator: SyntaxNode,
|
||||
innerFn: SyntaxNode | null,
|
||||
extra: { docstring?: string; signature?: string; isExported?: boolean }
|
||||
): void {
|
||||
const compNode = this.createNode('component', name, declarator, extra);
|
||||
if (!compNode || !innerFn || !this.extractor) return;
|
||||
this.nodeStack.push(compNode.id);
|
||||
const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField)
|
||||
?? getChildByField(innerFn, this.extractor.bodyField);
|
||||
if (body) this.visitFunctionBody(body, compNode.id);
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a class
|
||||
*/
|
||||
@@ -2316,6 +2385,26 @@ export class TreeSitterExtractor {
|
||||
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
|
||||
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
|
||||
|
||||
// React HOC-wrapped components (`forwardRef`/`memo`/`styled`) — see
|
||||
// reactComponentHoc. The initializer is a call / tagged-template (not
|
||||
// a bare arrow), so without this the const is a plain `constant`,
|
||||
// which the JSX-render synthesizer and component resolution both skip
|
||||
// → `<Button/>` usages get no edge and callers/impact return empty
|
||||
// (the whole shadcn/ui design-system pattern, #841). PascalCase-gated
|
||||
// to the component naming convention so a memoization util
|
||||
// (`const cache = memo(fn)`) stays a constant.
|
||||
if (valueNode && /^[A-Z]/.test(name)) {
|
||||
const hoc = this.reactComponentHoc(valueNode);
|
||||
if (hoc) {
|
||||
this.extractReactComponentNode(name, child, hoc.inner, {
|
||||
docstring,
|
||||
signature: initSignature,
|
||||
isExported,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const varNode = this.createNode(kind, name, child, {
|
||||
docstring,
|
||||
signature: initSignature,
|
||||
|
||||
@@ -9,7 +9,12 @@ import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from
|
||||
|
||||
export const reactResolver: FrameworkResolver = {
|
||||
name: 'react',
|
||||
languages: ['javascript', 'typescript'],
|
||||
// Includes 'tsx'/'jsx' so route extraction runs on JSX files (where
|
||||
// `<Route element={<X/>}>` routes live) — without them the .tsx/.jsx grammars
|
||||
// were filtered out of the extract pass and those routes were never indexed.
|
||||
// (resolve() is unaffected — it runs for every detected framework regardless
|
||||
// of language; only the extract pass filters on `languages`.)
|
||||
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
|
||||
|
||||
detect(context: ResolutionContext): boolean {
|
||||
// Check for React in package.json
|
||||
@@ -90,70 +95,17 @@ export const reactResolver: FrameworkResolver = {
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Extract component definitions
|
||||
// function Component() or const Component = () =>
|
||||
const componentPatterns = [
|
||||
// Function components
|
||||
/(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g,
|
||||
// Arrow function components
|
||||
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:\([^)]*\)|[a-zA-Z_][a-zA-Z0-9_]*)\s*=>/g,
|
||||
// forwardRef components
|
||||
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?forwardRef/g,
|
||||
// memo components
|
||||
/(?:export\s+)?(?:const|let)\s+([A-Z][a-zA-Z0-9]*)\s*=\s*(?:React\.)?memo/g,
|
||||
];
|
||||
|
||||
for (const pattern of componentPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const [fullMatch, name] = match;
|
||||
const line = content.slice(0, match.index).split('\n').length;
|
||||
|
||||
// Check if it returns JSX (rough heuristic)
|
||||
const afterMatch = content.slice(match.index + fullMatch.length, match.index + fullMatch.length + 500);
|
||||
const hasJSX = afterMatch.includes('<') && (afterMatch.includes('/>') || afterMatch.includes('</'));
|
||||
|
||||
if (hasJSX) {
|
||||
nodes.push({
|
||||
id: `component:${filePath}:${name}:${line}`,
|
||||
kind: 'component',
|
||||
name: name!,
|
||||
qualifiedName: `${filePath}::${name}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: fullMatch.length,
|
||||
language: filePath.endsWith('.tsx') ? 'tsx' : 'jsx',
|
||||
isExported: fullMatch.includes('export'),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract custom hooks
|
||||
const hookPattern = /(?:export\s+)?(?:function|const|let)\s+(use[A-Z][a-zA-Z0-9]*)\s*[=(]/g;
|
||||
let hookMatch;
|
||||
while ((hookMatch = hookPattern.exec(content)) !== null) {
|
||||
const [fullMatch, name] = hookMatch;
|
||||
const line = content.slice(0, hookMatch.index).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `hook:${filePath}:${name}:${line}`,
|
||||
kind: 'function',
|
||||
name: name!,
|
||||
qualifiedName: `${filePath}::${name}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: fullMatch.length,
|
||||
language: filePath.endsWith('.ts') || filePath.endsWith('.tsx') ? 'typescript' : 'javascript',
|
||||
isExported: fullMatch.includes('export'),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
// Components and custom hooks are NOT extracted here. The tree-sitter
|
||||
// extractor already emits them natively across .ts/.tsx/.js/.jsx — function
|
||||
// and arrow components as `function` nodes, HOC-wrapped components
|
||||
// (`forwardRef`/`memo`/`styled`) as `component` nodes (#841), and `useX`
|
||||
// hooks as `function` nodes. Re-deriving them here with regex only ran on
|
||||
// .ts/.js anyway (this resolver's `languages` didn't include the 'tsx'/'jsx'
|
||||
// grammars), and it DUPLICATED those tree-sitter nodes (e.g. a `useAuth`
|
||||
// ended up as two `function` nodes). This `extract` now contributes only
|
||||
// what tree-sitter can't: route nodes (React Router + Next.js conventions),
|
||||
// which is why 'tsx'/'jsx' are now in `languages` — `<Route>`/`element={<X/>}`
|
||||
// routes live in JSX files and were previously skipped entirely.
|
||||
|
||||
// React Router: <Route path="/x" component={Comp}/> (v5) or
|
||||
// <Route path="/x" element={<Comp/>}/> (v6). Attributes appear in any order,
|
||||
|
||||
Reference in New Issue
Block a user