diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d2099..bc2c7c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- React components declared with `forwardRef`, `memo`, or styled-components / emotion (`const Button = forwardRef(...)`, `const Card = memo(...)`, `const Box = styled.button\`…\``) are now recognized as components, so finding where they're used works. Before, they were indexed as plain constants, so `codegraph callers` and impact analysis reported "no callers found" even when the component was rendered across dozens of files — a dangerous false "safe to change" right before refactoring a shared component. Now every `; +} +` + ); + const db = await index(); + + // The render edge exists and is the synthesized jsx-render kind. + const edgeRows = db + .prepare( + `SELECT s.name caller FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + AND t.kind = 'component' AND t.name = 'Button'` + ) + .all(); + expect(edgeRows.map((r: any) => r.caller)).toContain('Page'); + + // ...and it surfaces through the public callers API (the issue's symptom: + // "No callers found" before the fix). + const buttonId = db + .prepare("SELECT id FROM nodes WHERE name='Button' AND kind='component'") + .get().id as string; + const callers = cg.getCallers(buttonId).map((c: any) => c.node.name); + expect(callers).toContain('Page'); + }); + + it('captures the inner render-fn body callees under the component', async () => { + fs.writeFileSync( + path.join(dir, 'widget.tsx'), + `import * as React from 'react'; +function useThing() { return 1; } +export const Widget = React.forwardRef((props, ref) => { + const v = useThing(); + return
{v}
; +}); +` + ); + const db = await index(); + const rows = db + .prepare( + `SELECT t.name FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'Widget' AND s.kind = 'component' + AND e.kind = 'calls' AND t.name = 'useThing'` + ) + .all(); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it('does not misclassify non-component PascalCase consts (precision)', async () => { + fs.writeFileSync( + path.join(dir, 'controls.tsx'), + `import * as React from 'react'; +const cache = memo(expensiveFn); +export const Config = loadConfig(); +export const Client = new ApiClient(); +export const Styles = styledHelper(); +export const Total = [1, 2].reduce((a, b) => a + b, 0); +export const Theme = { color: 'red' }; +` + ); + const db = await index(); + for (const name of ['Config', 'Client', 'Styles', 'Total', 'Theme']) { + expect(kindsOf(db, name), `${name} must stay a constant`).toContain('constant'); + expect(kindsOf(db, name), `${name} must not be a component`).not.toContain('component'); + } + // A lowercase-named memo() result is a memoization util, not a component. + expect(kindsOf(db, 'cache')).not.toContain('component'); + }); +}); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 13cda08..e8354c5 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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 `