diff --git a/CHANGELOG.md b/CHANGELOG.md index 365bc1a10..19c04006a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -265,6 +265,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`. - In TypeScript, a type a file uses without importing it, like `Omit`, `Record`, the DOM's `Event`, or a generic's own `T`, no longer links to a type of that name another module of your project declares, like a local `export type Omit` or an enum member `T`: it links to the file's own type or a global one, declared in a script `.d.ts` or a `declare global` block. A type you import, write qualified (`vscode.TextDocument`), bring in with `import X = ns.X`, or name inside a `declare module` block links as before. Re-index TypeScript projects after upgrading. - In TypeScript React files, a type annotation no longer links to a component of the same name. When a file declares a type next to a component or value of the same name, like `type ModalRegistration` beside a `ModalRegistration` component, its annotations now link to the type while its calls still link to the component, and a type the file declares on its own, like `type Option`, or a generic's type parameter, like ``, no longer links to an `Option` or `Item` component elsewhere in the project. Before, `codegraph callers` and impact listed those annotations as uses of the component. Re-index TypeScript React projects after upgrading. +- In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/expo-router.test.ts b/__tests__/expo-router.test.ts index 3463e111d..5ac1c939c 100644 --- a/__tests__/expo-router.test.ts +++ b/__tests__/expo-router.test.ts @@ -741,8 +741,10 @@ export default function ReviewScreen() { // One transition into /capture/review, and it comes from /capture. expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']); - // Written right there: no chain, and no Swift guard smuggled in. - expect(links[0]!.via).toEqual([]); + // Written in this screen's own file, reached through the memo wrapper it + // renders, and no Swift guard smuggled in. + expect(links[0]!.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']); + expect(links[0]!.via.every((v) => v.language !== 'swift')).toBe(true); expect(links[0]!.when).toBe('!isRetakeBatchActive'); expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx'); // …and /capture/review is not left feeding only itself. diff --git a/__tests__/fixtures/kernel-parity/torture.js b/__tests__/fixtures/kernel-parity/torture.js index 6221c7a77..abe40574f 100644 --- a/__tests__/fixtures/kernel-parity/torture.js +++ b/__tests__/fixtures/kernel-parity/torture.js @@ -102,3 +102,13 @@ async function exprReceivers(x) { this.a.b.run(); new Runner().go(); } +// --- component wrappers walked under the component ------------------------------ +const PANEL_GAP = 8; +const Panel = styled(Widget)` + gap: ${PANEL_GAP}px; + color: ${(props) => colorOf(props.theme)}; +`; +const Row = styled.div.attrs((props) => ({ role: roleOf(props) }))` + ${Panel} { margin: 0; } +`; +const MemoPanel = React.memo(Panel, legacyHelper); diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx index 313542284..ad5fdfaa0 100644 --- a/__tests__/fixtures/kernel-parity/torture.tsx +++ b/__tests__/fixtures/kernel-parity/torture.tsx @@ -128,6 +128,31 @@ export const Styled = styled.button` `; const memoCache = memo(computeThing); // lowercase — stays a constant +// Wrappers with no render function: the initializer is walked under the +// component — interpolations, `.attrs()` / `.withConfig()` / options +// arguments, a style function, a comparator — but not the wrapper's own chain, +// and the wrapped component is no fn-ref (a typed tag's type is not walked). +const BASE_MIXIN = css`color: red;`; +export const StyledBase = styled(Button)` + color: ${(p) => themeColor(p, 'accent')}; + ${BASE_MIXIN} + ${breakpoint('tablet')`padding: 0;`} +`; +export const AttrsWrapped = styled(ImportedComponent).attrs((props: ButtonProps) => ({ title: labelFor(props) }))` + width: ${(props) => sizeOf(props)}px; +`; +export const Configured = styled(MemoRow) + .withConfig({ shouldForwardProp: (prop) => !isInternalProp(prop) }) + .attrs((props) => ({ align: alignOf(props) }))``; +export const TypedTag = styled.section`margin: ${spacing(2)};`; +export const MuiStyled = styled(ImportedComponent, { shouldForwardProp: helperFn })(({ theme }) => ({ + padding: theme.spacing(1), + color: pickColor(theme, CONFIG_TABLE), +})); +export const MemoCompared = memo(ImportedComponent, helperFn); +export const MemoForward = memo(forwardRef(RenderInput)); +export const RefForwarded = React.forwardRef(RenderInput); + export function App() { const nodes: GraphNode[] = []; return ( diff --git a/__tests__/react-wrapper-initializers.test.ts b/__tests__/react-wrapper-initializers.test.ts new file mode 100644 index 000000000..614a44d44 --- /dev/null +++ b/__tests__/react-wrapper-initializers.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; +import { resetKernelForTests } from '../src/extraction/kernel'; + +/** + * A React component declared through a wrapper with no render function of its + * own — `styled.div\`…\``, outline's `styled(ActionButton).attrs(…)\`…\``, + * MUI's `styled(Box)(({ theme }) => …)`, `memo(Button)` — is a `component` node + * (#841, #2429) that nothing walked: the calls in its template's + * interpolations went nowhere, where a plain `const X = …` attributes them to X + * (#693), and nothing linked it to the component it wraps, so a render flow + * Page → NudeButton → ActionButton stopped at NudeButton. + * + * Now the initializer is walked under the component, minus the wrapper's own + * chain (`styled`, `styled.div`, `.attrs`, `memo`: the library, which no project + * code answers), and the wrapped component is what the wrapper renders: a + * `jsx-render` hop bound as a tag of that name in the file would be, so a + * package's `styled(Link)` renders no project `Link`. Both engines. + */ + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelBuilt = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; +const ENGINES = [ + { engine: 'wasm', kernel: '0' as string | undefined }, + ...(kernelBuilt ? [{ engine: 'kernel', kernel: undefined as string | undefined }] : []), +]; + +const FILES: Record = { + 'package.json': JSON.stringify({ + dependencies: { react: '^18.0.0', 'styled-components': '^6.0.0', 'react-router-dom': '^6.0.0', antd: '^5.0.0' }, + }), + 'theme.ts': `export function s(key: string) { return key; } +export function breakpoint(size: string) { return (strings: TemplateStringsArray) => size + strings.join(''); } +export function labelFor(props: unknown) { return String(props); } +export function areEqual(a: unknown, b: unknown) { return a === b; } +export function pickColor(theme: unknown) { return String(theme); } +`, + 'ActionButton.tsx': `export default function ActionButton(props: { title?: string }) { + return ; +} +`, + // A project namesake of react-router's Link: what styled(Link) must not render. + 'Link.tsx': `export function Link(props: { to: string }) { + return ; +} +`, + // Namesakes a guess would take for names the wrappers below don't bind to them. + 'other.tsx': `import styled from 'styled-components'; +export const Icon = styled.i\`\`; +export const Text = styled.span\`\`; +`, + // A base imported through a path nothing resolves (a Meteor app's + // `/imports/…`), and one the file destructures from a library. + 'unbound.tsx': `import styled from 'styled-components'; +import Icon from '/imports/ui/components/icon/component'; +import { Typography } from 'antd'; +const { Text } = Typography; +export const BigIcon = styled(Icon)\`\`; +export const StyledText = styled(Text)\`\`; +`, + 'ui.tsx': `import styled, { css } from 'styled-components'; +import { Link } from 'react-router-dom'; +import { memo } from 'react'; +import ActionButton from './ActionButton'; +import { s, breakpoint, labelFor, areEqual, pickColor } from './theme'; + +type Props = { active: boolean }; +const BASE_CSS = css\`color: red;\`; + +export const Plain = styled.div\` + color: \${(p) => s('accent')}; + \${BASE_CSS} + \${breakpoint('tablet')\`padding: 0;\`} +\`; +export const NudeButton = styled(ActionButton).attrs((props: Props) => ({ title: labelFor(props) }))\` + width: \${(props) => s('width')}; +\`; +export const Root = styled(ActionButton)(({ theme }) => ({ color: pickColor(theme) })); +export const Memoized = memo(ActionButton, areEqual); +const Box = styled.div\`\`; +export const Card = styled(Box)\`\`; +export const StyledLink = styled(Link)\`\`; +`, + // A styles file: TypeScript, no markup of its own. + 'styles.ts': `import styled from 'styled-components'; +import ActionButton from './ActionButton'; +export const Panel = styled(ActionButton)\`margin: 0;\`; +`, + // A wrapper of an element only, in a file no synthesis pass reads yet. + 'bare.ts': `import styled from 'styled-components'; +import ActionButton from './ActionButton'; +export const Bare = styled.div\`margin: 0;\`; +`, + 'Page.tsx': `import { NudeButton } from './ui'; +export function Page() { + return ; +} +`, +}; + +describe.each(ENGINES)('a wrapper component walks its initializer and renders what it wraps ($engine)', ({ kernel }) => { + let dir: string; + let cg: any; + let saved: string | undefined; + + beforeEach(() => { + saved = process.env.CODEGRAPH_KERNEL; + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + resetKernelForTests(); + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-wrap-')); + for (const [file, text] of Object.entries(FILES)) fs.writeFileSync(path.join(dir, file), text); + }); + + afterEach(() => { + cg?.close?.(); + cg = undefined; + if (saved === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = saved; + resetKernelForTests(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function index() { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + return (cg as any).db.db; + } + + /** `kind` edges from a component to a node, as `Source>target`. */ + const edgesFrom = (db: any, kind: string, where = '1=1'): string[] => + db + .prepare( + `SELECT s.name src, t.name dst FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = ? AND s.kind = 'component' AND ${where} + ORDER BY s.name, t.name` + ) + .all(kind) + .map((r: any) => `${r.src}>${r.dst}`); + + it('attributes the calls and values in its template, attrs and style function to the component', async () => { + const db = await index(); + const calls = new Set(edgesFrom(db, 'calls', "json_extract(e.metadata, '$.synthesizedBy') IS NULL")); + for (const edge of ['Plain>s', 'Plain>breakpoint', 'NudeButton>labelFor', 'NudeButton>s', 'Root>pickColor']) { + expect(calls, edge).toContain(edge); + } + // The style function MUI hands the wrapper is the component's code, not a + // function of its own named for the declarator. + expect(db.prepare("SELECT kind FROM nodes WHERE name = 'Root'").all().map((r: any) => r.kind)).toEqual(['component']); + // A file-scope value the template reads, and a memo comparator passed by name. + expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.valueRef') = 1")).toContain('Plain>BASE_CSS'); + expect(edgesFrom(db, 'references', "json_extract(e.metadata, '$.fnRef') = 1")).toEqual(['Memoized>areEqual']); + }); + + it('records none of the wrapper machinery, and no function-as-value reference to the wrapped component', async () => { + const db = await index(); + const fromComponents = db + .prepare( + `SELECT r.reference_name name FROM unresolved_refs r JOIN nodes n ON n.id = r.from_node_id + WHERE n.kind = 'component'` + ) + .all() + .map((r: any) => r.name as string); + for (const machinery of ['styled', 'styled.div', 'styled().attrs', 'memo', 'React.memo']) { + expect(fromComponents, machinery).not.toContain(machinery); + } + expect(fromComponents.filter((n) => n.startsWith('styled('))).toEqual([]); + // The wrapped component is rendered, not passed as a value: no fn-ref from the + // component or from the file, resolved or not. + const fnRefs = db + .prepare( + `SELECT r.reference_name name FROM unresolved_refs r WHERE r.reference_kind = 'function_ref' + UNION ALL + SELECT t.name FROM edges e JOIN nodes t ON t.id = e.target WHERE json_extract(e.metadata, '$.fnRef') = 1` + ) + .all() + .map((r: any) => r.name as string); + for (const wrapped of ['ActionButton', 'Box', 'Link']) expect(fnRefs, wrapped).not.toContain(wrapped); + }); + + it('renders the component it wraps, bound as a tag of that name in its file', async () => { + const db = await index(); + const rows = db + .prepare( + `SELECT s.name src, t.name dst, t.file_path file, e.metadata meta FROM edges e + JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.wrapped') = 1 ORDER BY s.name, t.name` + ) + .all(); + expect(rows.map((r: any) => `${r.src}>${r.dst}@${r.file}`)).toEqual([ + 'Card>Box@ui.tsx', + 'Memoized>ActionButton@ActionButton.tsx', + 'NudeButton>ActionButton@ActionButton.tsx', + 'Panel>ActionButton@ActionButton.tsx', + 'Root>ActionButton@ActionButton.tsx', + ]); + for (const r of rows) { + expect(JSON.parse(r.meta)).toMatchObject({ synthesizedBy: 'jsx-render', via: r.dst, wrapped: true }); + } + // react-router's Link renders no project namesake, nor does a name the file + // imports from a module the index can't follow, or destructures itself. + for (const wrapper of ['StyledLink', 'BigIcon', 'StyledText']) { + expect(edgesFrom(db, 'calls').filter((e) => e.startsWith(`${wrapper}>`)), wrapper).toEqual([]); + } + }); + + it('lets explore follow a render flow through the wrapper to the component it wraps', async () => { + await index(); + const res = await new ToolHandler(cg).execute('codegraph_explore', { query: 'Page NudeButton ActionButton' }); + const text: string = res.content?.[0]?.text ?? ''; + const flow = text.slice(text.indexOf('**Flow')); + expect(text).toContain('**Flow'); + const steps = [...flow.matchAll(/^(\d+)\. (\w+) \(/gm)].map((m) => `${m[1]}. ${m[2]}`); + expect(steps.slice(0, 3)).toEqual(['1. Page', '2. NudeButton', '3. ActionButton']); + }); + + it('draws the render link when a sync turns a wrapper in a file with no markup onto a component', async () => { + const db = await index(); + const wrapped = () => + db + .prepare( + `SELECT s.name src, t.name dst FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata, '$.wrapped') = 1 AND s.name = 'Bare'` + ) + .all() + .map((r: any) => `${r.src}>${r.dst}`); + expect(wrapped()).toEqual([]); + // No markup, no synthesized edge and the same component names before and + // after: only the wrapper itself says the JSX edges need redrawing. + fs.writeFileSync(path.join(dir, 'bare.ts'), FILES['bare.ts']!.replace('styled.div`', 'styled(ActionButton)`')); + await cg.sync(); + expect(wrapped()).toEqual(['Bare>ActionButton']); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 2effd5ba3..fb8650b55 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -9,6 +9,7 @@ use super::{ is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker, }; use crate::buffers::edge_kind_index; +use std::collections::HashSet; use tree_sitter::Node; impl<'t> Walker<'t> { @@ -170,14 +171,131 @@ impl<'t> Walker<'t> { let Some(row) = self.create_node("component", name, declarator, extra) else { return; }; - let Some(inner) = inner_fn else { return }; self.stack.push(Scope { row, kind: "component", name: name.to_string() }); - if let Some(body) = body_of(inner) { - self.visit_function_body(body); + match inner_fn { + Some(inner) => { + if let Some(body) = body_of(inner) { + self.visit_function_body(body); + } + } + // Without a render function, the initializer is walked instead. + None => { + if let Some(value) = declarator.child_by_field_name("value") { + self.walk_component_wrapper(value); + } + } } self.stack.pop(); } + /// A wrapper with no render function of its own (`styled.div\`…\``, + /// `styled(Base).attrs(…)

\`…\``, `styled(Base)(styles)`, `memo(Base)`), + /// walked with its component on the stack as a constant's initializer is + /// (#693): the template's interpolations and the `.attrs()` / options / + /// style-function / comparator arguments are the component's code. The + /// wrapper's own chain (`styled`, `styled.div`, `.attrs`, `memo`) is not + /// recorded, the component it wraps is left to the JSX synthesizer (it is + /// what this one renders), and a typed tag's type operands are not walked. + /// Mirrors TreeSitterExtractor.walkComponentWrapper. + fn walk_component_wrapper(&mut self, link: Node<'t>) { + stack_guard!(); + match link.kind() { + "binary_expression" => { + // `(styled(Base) < Props) > \`…\``: the tag is the leftmost + // operand and the template the rightmost; between is the type. + let mut tag = Some(link); + while let Some(t) = tag.filter(|t| t.kind() == "binary_expression") { + tag = t.child_by_field_name("left"); + } + let mut template = Some(link); + while let Some(t) = template.filter(|t| t.kind() == "binary_expression") { + template = t.child_by_field_name("right"); + } + if let Some(tag) = tag { + self.walk_component_wrapper(tag); + } + if let Some(template) = template { + self.visit_function_body(template); + } + } + "call_expression" => { + let callee = link.child_by_field_name("function"); + if let Some(c) = callee { + self.walk_component_wrapper(c); + } + let Some(args) = link.child_by_field_name("arguments") else { return }; + // A tagged template's argument is its template: the styles. + if args.kind() != "arguments" { + self.visit_function_body(args); + return; + } + // `styled(Base)`, `memo(Base)`: the first argument is the + // component wrapped, or a wrapper around it. + let callee_text = callee.map(|c| self.text(c)).unwrap_or(""); + let root = callee_text == "styled" || is_react_hoc(callee_text); + let base = args + .named_child(0) + .filter(|f| root && matches!(f.kind(), "identifier" | "member_expression")); + for i in 0..args.named_child_count() { + let Some(arg) = args.named_child(i) else { continue }; + if base.map(|b| b.start_byte() == arg.start_byte()).unwrap_or(false) { + continue; + } + if root && i == 0 && arg.kind() == "call_expression" && self.is_component_wrapper_call(arg) { + self.walk_component_wrapper(arg); + } else if matches!(arg.kind(), "arrow_function" | "function_expression") { + // A style or `.attrs()` function runs as the component's + // code: its body is walked, the function never named. + if let Some(body) = body_of(arg) { + self.visit_function_body(body); + } + } else { + self.visit_function_body(arg); + } + } + self.capture_wrapper_argument_refs(args, base); + } + "member_expression" | "subscript_expression" => { + if let Some(object) = link.child_by_field_name("object") { + self.walk_component_wrapper(object); + } + } + "non_null_expression" | "parenthesized_expression" | "instantiation_expression" => { + // `styled.div<{ open: boolean }>` error-recovers into a call on + // `non_null_expression(instantiation_expression(styled.div, …))`. + if let Some(inner) = link.child_by_field_name("function").or_else(|| link.named_child(0)) { + self.walk_component_wrapper(inner); + } + } + _ => {} + } + } + + /// A call reactComponentHoc reads as a component wrapper: `styled…(…)`, + /// `memo(…)`, `forwardRef(…)`. Mirrors isComponentWrapperCall. + fn is_component_wrapper_call(&self, call: Node<'t>) -> bool { + let Some(callee) = call.child_by_field_name("function") else { return false }; + let text = self.text(callee); + util::styled_callee().is_match(text) || is_react_hoc(text) + } + + /// Function-as-value candidates (#756) among a wrapper call's arguments, + /// attributed to the component — never the component the call wraps. + /// Mirrors captureWrapperArgumentRefs. + fn capture_wrapper_argument_refs(&mut self, args: Node<'t>, base: Option>) { + let Some(mode) = super::fnref::dispatch(args.kind()) else { return }; + if self.stack.is_empty() { + return; + } + let from = self.top_row(); + for (cand, _mode) in super::fnref::capture(args, mode, self.src) { + if base.map(|b| b.start_byte() == cand.column_byte).unwrap_or(false) { + continue; + } + self.fn_ref_cands.push((from, cand)); + } + } + // --- extractClass ------------------------------------------------------------ pub(super) fn extract_class(&mut self, node: Node<'t>) { @@ -412,7 +530,11 @@ impl<'t> Walker<'t> { } } - pub(super) fn extract_variable(&mut self, node: Node<'t>) { + /// Returns the initializers it walked under a component wrapper, whose + /// fn-ref candidates that walk already took (the caller's scan of the + /// declaration skips them). Mirrors TreeSitterExtractor.extractVariable. + pub(super) fn extract_variable(&mut self, node: Node<'t>) -> Option> { + let mut walked: Option> = None; let is_const = self.is_const_decl(node); let kind: &'static str = if is_const { "constant" } else { "variable" }; let docstring = crate::docstring::preceding_docstring(node, self.src); @@ -468,6 +590,9 @@ impl<'t> Walker<'t> { ..Extra::default() }, ); + if inner.is_none() { + walked.get_or_insert_with(HashSet::new).insert(v.id()); + } continue; } } @@ -574,6 +699,7 @@ impl<'t> Walker<'t> { self.extract_object_literal_functions(coll); } } + walked } /// extractRtkHookBindings — `export const { useGetXQuery } = api`. diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 2471dc0bb..4fd30ced0 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -490,7 +490,9 @@ impl<'t> Walker<'t> { *self.fs_value_counts.entry(name.to_string()).or_insert(0) += 1; } } - if matches!(kind, "function" | "method" | "constant" | "variable") { + // A React wrapper component reads values as a constant's declaration + // does (`${BaseMenuItemCSS}` in a styled template). + if matches!(kind, "function" | "method" | "constant" | "variable" | "component") { self.value_scopes.push(ValueScope { row, node, name: name.to_string() }); } } @@ -605,11 +607,15 @@ impl<'t> Walker<'t> { } /// scanFnRefSubtree: capture-only walk of subtrees the main walkers skip. - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + /// `walked`: subtrees a body walk has already been through. + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: Option<&HashSet>) { stack_guard!(); if depth > 12 { return; } + if walked.map(|w| w.contains(&node.id())).unwrap_or(false) { + return; + } let kind = node.kind(); if depth > 0 && (is_function_type(kind) || matches!(kind, "lambda_literal" | "lambda_expression")) @@ -619,7 +625,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } @@ -699,7 +705,7 @@ impl<'t> Walker<'t> { self.visit_function_body(value); self.stack.pop(); } - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, None); } else { self.extract_method(node); } @@ -713,8 +719,10 @@ impl<'t> Walker<'t> { } else if self.variant.is_ts() && kind == "type_alias_declaration" { skip_children = self.extract_type_alias(node); } else if is_variable_type(kind) && !self.inside_class_like() { - self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + // A component wrapper's initializer extract_variable walked under + // the component captured its own candidates; the scan skips it. + let walked = self.extract_variable(node); + self.scan_fn_ref_subtree(node, 0, walked.as_ref()); skip_children = true; } else if kind == "import_statement" { self.extract_import(node); @@ -750,7 +758,7 @@ impl<'t> Walker<'t> { // says which member wants the type, where `Api → PageId` only said // the file did. self.extract_property(node); - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, None); skip_children = true; } diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 5739b73d7..e94571780 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -411,7 +411,16 @@ Each of these cost real debugging time; they are not hypothetical. hands over `Login` (`defaultExportedName`). A tag's edge now depends on the modules the file's lazy values load, so those are synthesis inputs (`lazyValueModules`): a sync that gives one its default export redraws the - tag, as a fresh index would draw it. + tag, as a fresh index would draw it. A component node minted for a wrapper + with no render function of its own (outline's `styled(ActionButton).attrs(…) + \`…\``, `memo(Button)`) renders the component it wraps, with no tag to + read: `wrappedComponentName` reads the name off the declaration, and the + `jsx-render` synthesizer binds it as `jsxChild` binds a tag, but only where + the file binds the name itself, by a declaration or an import of a project + module (`wrappedChild`). bigbluebutton imports through Meteor `/imports/…` + paths no resolver follows, and a guess among its dozens of styled `Icon`s + and `FlexColumn`s was wrong every time. A file that declares such a wrapper + is a synthesis input even with no markup. 20. **An import that looks like a package can be a path.** Create React App and Vite admin templates (berry, mantis, matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f6657357a..e0ae68852 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1048,7 +1048,8 @@ export class TreeSitterExtractor { /** * Record value-reference bookkeeping as nodes are created: file-scope const/var symbols with * distinctive names become reference targets; function/method/const/var symbols become reader - * scopes whose bodies flushValueRefs scans. + * scopes whose bodies flushValueRefs scans, and so do React wrapper components, whose + * declaration reads values as a constant's does (`${BaseMenuItemCSS}` in a styled template). */ private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode, valueTarget = true): void { // Pascal targets `constant` only: its extractor emits function PARAMETERS @@ -1079,7 +1080,7 @@ export class TreeSitterExtractor { this.fileScopeValueCounts.set(name, (this.fileScopeValueCounts.get(name) ?? 0) + 1); } } - if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable') { + if (kind === 'function' || kind === 'method' || kind === 'constant' || kind === 'variable' || kind === 'component') { this.valueRefScopes.push({ id, node, name }); } } @@ -1584,16 +1585,18 @@ export class TreeSitterExtractor { const declaredType = this.walkDeclaredTypes ? getChildByField(node, 'type') : null; const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null; if (definedType) this.visitNode(definedType); - this.extractVariable(node); + const walked = this.extractVariable(node); // extractVariable doesn't walk every initializer shape (object literals // are deliberately skipped; Python/Ruby don't walk at all), so scan the // declaration subtree for function-as-value candidates — `const routes = // { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts // at nested function definitions (their bodies are walked — and // attributed — separately) and flush-time dedup absorbs any overlap with - // initializers extractVariable DOES walk. A type walked above captured - // its own. - this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : undefined); + // initializers extractVariable DOES walk. A type walked above, and a + // component wrapper's initializer extractVariable walked under the + // component (which must not hand the component it wraps to the file), + // captured their own. + this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : walked); skipChildren = true; // extractVariable handles children } // Swift properties inside a type. A stored instance property becomes a `field` @@ -2230,7 +2233,8 @@ export class TreeSitterExtractor { * 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. + * `const Foo = () => …` arrow component already behaves. Without one, the + * initializer is walked instead (walkComponentWrapper). */ private extractReactComponentNode( name: string, @@ -2239,14 +2243,129 @@ export class TreeSitterExtractor { extra: { docstring?: string; signature?: string; isExported?: boolean } ): void { const compNode = this.createNode('component', name, declarator, extra); - if (!compNode || !innerFn || !this.extractor) return; + if (!compNode || !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); + if (innerFn) { + const body = this.extractor.resolveBody?.(innerFn, this.extractor.bodyField) + ?? getChildByField(innerFn, this.extractor.bodyField); + if (body) this.visitFunctionBody(body, compNode.id); + } else { + const value = getChildByField(declarator, 'value'); + if (value) this.walkComponentWrapper(value); + } this.nodeStack.pop(); } + /** + * Walk a wrapper with no render function of its own — `styled.div\`…\``, + * `styled(Base).attrs(…)

\`…\``, emotion's and MUI's `styled(Base)(styles)`, + * `memo(Base)` — with its component on the stack, the way a constant's + * initializer is walked with the constant on it (#693). The template's + * interpolations, the `.attrs()` / `.withConfig()` / options arguments, a + * style function and a `memo` comparator are the component's own code. + * + * The wrapper's own chain is the library it is declared with: `styled`, + * `styled.div`, `.attrs`, `memo` name no project code, so their calls are not + * recorded (every one would park as a reference nothing answers). Nor is the + * component it wraps (`styled(ActionButton)`, `memo(Button)`) a function + * passed as a value: React renders it whenever it renders this one, so the + * JSX synthesizer links it as a render, a hop explore can follow. A type + * argument read as comparisons (`styled.div`, see + * isTypedStyledTemplate) is a type, and is not walked. Mirrored in the kernel + * (tsjs/extractors.rs walk_component_wrapper). + */ + private walkComponentWrapper(link: SyntaxNode): void { + switch (link.type) { + case 'binary_expression': { + // `(styled(Base) < Props) > \`…\``: the tag is the leftmost operand + // and the template the rightmost; the operands between are the type. + let tag: SyntaxNode | null = link; + while (tag?.type === 'binary_expression') tag = getChildByField(tag, 'left'); + let template: SyntaxNode | null = link; + while (template?.type === 'binary_expression') template = getChildByField(template, 'right'); + if (tag) this.walkComponentWrapper(tag); + if (template) this.visitFunctionBody(template, ''); + return; + } + case 'call_expression': { + const callee = getChildByField(link, 'function'); + if (callee) this.walkComponentWrapper(callee); + const args = getChildByField(link, 'arguments'); + if (!args) return; + // A tagged template's argument is its template: the styles. + if (args.type !== 'arguments') { + this.visitFunctionBody(args, ''); + return; + } + // `styled(Base)`, `memo(Base)`: the first argument is the component + // wrapped, or a wrapper around it (`memo(forwardRef(Inner))`). + const calleeText = callee ? getNodeText(callee, this.source) : ''; + const root = calleeText === 'styled' || REACT_COMPONENT_HOCS.has(calleeText); + const first = args.namedChild(0); + const base = root && (first?.type === 'identifier' || first?.type === 'member_expression') ? first : null; + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (!arg || (base && arg.startIndex === base.startIndex)) continue; + if (root && i === 0 && arg.type === 'call_expression' && this.isComponentWrapperCall(arg)) { + this.walkComponentWrapper(arg); + } else if (arg.type === 'arrow_function' || arg.type === 'function_expression') { + // A style or `.attrs()` function runs as the component's code; its + // body is walked as a render function's is, never named for the + // declarator (curriedWrapperBoundName would take MUI's + // `styled(Box)(({ theme }) => …)` for a function of its own). + const body = this.extractor!.resolveBody?.(arg, this.extractor!.bodyField) + ?? getChildByField(arg, this.extractor!.bodyField); + if (body) this.visitFunctionBody(body, ''); + } else { + this.visitFunctionBody(arg, ''); + } + } + this.captureWrapperArgumentRefs(args, base); + return; + } + case 'member_expression': + case 'subscript_expression': { + const object = getChildByField(link, 'object'); + if (object) this.walkComponentWrapper(object); + return; + } + case 'non_null_expression': + case 'parenthesized_expression': + case 'instantiation_expression': { + // `styled.div<{ open: boolean }>` error-recovers into a call on + // `non_null_expression(instantiation_expression(styled.div, …))`. + const inner = getChildByField(link, 'function') ?? link.namedChild(0); + if (inner) this.walkComponentWrapper(inner); + return; + } + } + } + + /** A call of the kind reactComponentHoc reads as a component wrapper: `styled…(…)`, `memo(…)`, `forwardRef(…)`. */ + private isComponentWrapperCall(call: SyntaxNode): boolean { + const callee = getChildByField(call, 'function'); + if (!callee) return false; + const text = getNodeText(callee, this.source); + return /^styled\b/.test(text) || REACT_COMPONENT_HOCS.has(text); + } + + /** + * Function-as-value candidates (#756) among a wrapper call's arguments — a + * `memo` comparator, a style function passed by name — attributed to the + * component. The component the call wraps is not one: it is what the + * component renders. + */ + private captureWrapperArgumentRefs(args: SyntaxNode, base: SyntaxNode | null): void { + const spec = this.fnRefSpec; + const rule = spec?.dispatch.get(args.type); + const fromNodeId = this.nodeStack[this.nodeStack.length - 1]; + if (!spec || !rule || !fromNodeId) return; + for (const cand of captureFnRefCandidates(args, rule, spec, this.source)) { + if (base && cand.line === base.startPosition.row + 1 && cand.column === base.startPosition.column) continue; + this.fnRefCandidates.push({ ...cand, fromNodeId }); + } + } + /** * Extract a class */ @@ -3434,9 +3553,14 @@ export class TreeSitterExtractor { * * Extracts top-level and module-level variable declarations. * Captures the variable name and first 100 chars of initializer in signature for searchability. + * + * Returns the initializers it walked under a component wrapper, whose + * function-as-value candidates that walk already took (the caller's scan of + * the declaration skips them). */ - private extractVariable(node: SyntaxNode): void { - if (!this.extractor) return; + private extractVariable(node: SyntaxNode): Set | undefined { + if (!this.extractor) return undefined; + let walked: Set | undefined; // Different languages have different variable declaration structures // TypeScript/JavaScript: lexical_declaration contains variable_declarator children @@ -3508,6 +3632,7 @@ export class TreeSitterExtractor { signature: initSignature, isExported, }); + if (!hoc.inner) (walked ??= new Set()).add(valueNode.id); continue; } } @@ -3864,6 +3989,7 @@ export class TreeSitterExtractor { } } } + return walked; } /** Resolve a Lua assignment target into its callable name and optional table receiver. */ diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 1fbd4327f..86db4faf1 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -40,7 +40,7 @@ import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequest import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; import { cppSpecializationArguments, crossesCodeBoundary, jsCodeBindsName } from './name-matcher'; -import { declaredComponent, exportedComponent, lazyValueModules } from './frameworks/react'; +import { declaredComponent, exportedComponent, lazyValueModules, wrappedComponentName } from './frameworks/react'; import { jsxCode } from './jsx-code'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; @@ -61,6 +61,9 @@ const JSX_FAMILY = ['tsx', 'jsx', 'javascript']; // the next match, which `opensTag` reads as a type argument, not a tag. const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)(?=[\s/><])/g; const MAX_JSX_CHILDREN = 30; +// A wrapper around a component — `styled(ActionButton)`, `memo(Button)`, +// `forwardRef(Render)` — renders it without a tag (`wrappedComponentName`). +const WRAPPER_CALL_RE = /\b(?:styled|memo|forwardRef)\s*\(\s*[A-Z]/; // Vue SFC templates: kebab-case child components ( → ElButton) and // event bindings (@click="fn" / v-on:click="fn"). PascalCase children () // are already caught by JSX_TAG_RE via the SFC component node. @@ -1889,6 +1892,32 @@ function jsxChild( return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0]; } +/** + * The component a wrapper component wraps, by the name its declaration hands + * the wrapper (`wrappedComponentName`), bound the way a tag of that name in the + * file is (`jsxChild`), but only where the file binds the name itself: a value + * or a component it declares, or an import of a project module the index + * holds. Anything else names no component of the project, and a namesake + * elsewhere is no guess worth an edge: bigbluebutton's `styled(Icon)`, imported through a + * Meteor `/imports/…` path no resolver follows, would render whichever of its + * dozens of `Icon`s came first, and SigNoz's `styled(Text)` of `const { Text } + * = Typography` a `Text` from another page's styles. + */ +function wrappedChild( + ctx: ResolutionContext, + name: string, + file: string, + importsOf: () => Map, + ownValueOf: (name: string) => ReturnType, + exportOf: (module: string, exported: string) => Node | null +): Node | undefined { + const declared = ownValueOf(name) !== undefined || + (ctx.getNodesInFileNamed?.(file, name) ?? ctx.getNodesInFile(file).filter((n) => n.name === name)) + .some((n) => JSX_CHILD_KINDS.has(n.kind)); + if (!declared && !projectImport(ctx, name, file)) return undefined; + return jsxChild(ctx, name, file, importsOf, ownValueOf, exportOf); +} + /** * Whether the `()`, `Array`, the @@ -2249,12 +2278,47 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): if ((++scannedFiles & 15) === 0) await onYield(); if ((++scanned & 255) === 0) await onYield(); // #1091: yield mid-scan on huge graphs const content = ctx.readFile(file); - if (!content || (!content.includes(''))) continue; // JSX-file gate + if (!content) continue; + const hasTags = content.includes(''); + const wraps = WRAPPER_CALL_RE.test(content); + if (!hasTags && !wraps) continue; // JSX-file gate // File-level language gate, not merely a project-level one: mixed C/JS // monorepos must not interpret `""` inside C as JSX (#1560). A // TypeScript `.ts` file holds no JSX either: a `` there is a type // assertion or text, like outline's OpenSearch XML template. const inFile = ctx.getNodesInFile(file); + // Once per file and name: the value the file declares under it, if any. + const ownValues = new Map>(); + const ownValueOf = (name: string) => { + if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx)); + return ownValues.get(name); + }; + // A wrapper component renders the component it wraps, with no tag to read: + // outline's `const NudeButton = styled(ActionButton)…`, a `memo(Button)`. + // Without the link a render flow through it ends at the wrapper. The name + // binds as a tag of that name in this file would, where the file binds it + // (`wrappedChild`), so a package's `styled(Link)` renders nothing here. Any + // JS-family file: a styles `.ts` file declares wrappers too. + if (wraps) { + let wrapperImports: Map | null = null; + for (const wrapper of inFile) { + if (wrapper.kind !== 'component' || !JS_FAMILY.includes(wrapper.language)) continue; + const name = wrappedComponentName(wrapper, ctx); + if (!name) continue; + const importsOf = () => (wrapperImports ??= importedFrom(ctx, file, wrapper.language)); + const child = wrappedChild(ctx, name, file, importsOf, ownValueOf, exportOf); + if (!child || child.id === wrapper.id || crossesCodeBoundary(wrapper.language, child.language)) continue; + const key = `${wrapper.id}>${child.id}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + source: wrapper.id, target: child.id, kind: 'calls', line: wrapper.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'jsx-render', via: name, wrapped: true }, + }); + } + } + if (!hasTags) continue; const fns = inFile.filter((n) => PARENT_KINDS.has(n.kind) && JSX_FAMILY.includes(n.language)); // JSX outside every function renders from where a plain call there is // attributed (#2587): a top-level `const ICONS = { a: }` from the @@ -2308,12 +2372,6 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): let imports: Map | null = null; const importsOf = () => (imports ??= importedFrom(ctx, file, parents[0]!.language)); - // Once per file and name: the value the file declares under it, if any. - const ownValues = new Map>(); - const ownValueOf = (name: string) => { - if (!ownValues.has(name)) ownValues.set(name, declaredComponent(name, file, ctx)); - return ownValues.get(name); - }; // A function renders the tags its own lines hold, so functions that span // the same lines share them: every function of a minified bundle spans // its one line, and reading that line again per function took 9 to 25 s @@ -4855,6 +4913,8 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true; if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true; if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true; + // A wrapper renders the component it wraps (jsxEdges), in a file with no markup of its own too. + if (WRAPPER_CALL_RE.test(content)) return true; if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true; if (/[\w$]\s*\[\s*[A-Za-z_$]/.test(content) || /\b(?:dispatch|commit)\s*\(/.test(content)) return true; const patterns = [THUNK_DECL_RE, CELERY_TASK_DECORATOR_RE, CELERY_DISPATCH_RE, diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 0a61b2651..93eccf257 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -883,6 +883,27 @@ function handedComponent(value: Node, context: ResolutionContext, nodes = contex : null; } +/** + * The name of the component a wrapper component's declaration hands its + * rendering to: what `styled(…)` is given (outline's `styled(ActionButton)`, + * whatever `.attrs(…)`, `.withConfig(…)`, type arguments or template follow), + * or what a `memo` or `forwardRef` call wraps (`memo(Button)`, `React.memo(Row, + * areEqual)`, `memo(forwardRef(Input))`; `wrappedName`). React renders it + * whenever it renders the wrapper, so a JSX tag's render flow goes on to it. + * Null for a wrapper of an element (`styled.div`, `styled('a')`) and for one + * around an inline render function, which has a body of its own. + */ +export function wrappedComponentName(component: Node, context: ResolutionContext): string | null { + const code = stripCommentsForRegex(declarationText(component, context), 'typescript'); + const init = initializerAt(code, component.name.length); + if (init < 0) return null; + const expression = code.slice(init, statementEnd(code, init)).trim(); + const styled = /^styled\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/.exec(expression); + const name = styled ? styled[1]! + : /^(?:React\s*\.\s*)?(?:memo|forwardRef)\b/.test(expression) ? wrappedName(expression) : null; + return name && isPascalCase(name) && name !== component.name ? name : null; +} + /** * The component a route's `layout:` reference names, found the way the * route's own component is: through the route file's import of it, else