diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f4a0618..59e276f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. +- In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 57fec8649..9032f27ea 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -314,6 +314,33 @@ export function second() { return probe(); } ])); }); + it.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], + ] as const)('typed styled tags are components, parsed as comparisons or not: %s', (ext, language) => { + // `styled.div\`…\`` parses as `(styled.div < Props) > \`…\``; the + // type argument's own operators sit between (``, `>`). + const result = assertParity(`styles.${ext}`, ` +import styled, { css } from 'styled-components'; +import { s } from './theme'; +type Props = { align: 'start' | 'end' }; +const Plain = styled.div\`color: red;\`; +export const Wrapper = styled.div\`color: \${(p) => s(p.align)};\`; +const CloseAction = styled.div<{ animation: Animation | null }>\`top: 0;\`; +const Content = styled(Plain)\`padding: 4px;\`; +const NudeButton = styled(Plain).attrs((props: Props) => ({ type: "button" }))\`\`; +const Either = styled.div\`color: red;\`; +const Nested = styled.div>\`color: red;\`; +const Mixin = css\`color: red;\`; +const Compared = styled.length < LIMIT > 2; +const lowerCase = styled.div\`color: red;\`; +`, language); + const kind = (name: string) => result.nodes.filter((n) => n.name === name).map((n) => n.kind); + for (const name of ['Plain', 'Wrapper', 'CloseAction', 'Content', 'NudeButton', 'Either', 'Nested']) { + expect(kind(name), name).toEqual(['component']); + } + for (const name of ['Mixin', 'Compared', 'lowerCase']) expect(kind(name), name).toEqual(['constant']); + }); + it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => { const file = path.join(FIXTURE_DIR, 'torture.tsx'); assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx'); diff --git a/__tests__/react-hoc-component.test.ts b/__tests__/react-hoc-component.test.ts index cac29b836..41e371704 100644 --- a/__tests__/react-hoc-component.test.ts +++ b/__tests__/react-hoc-component.test.ts @@ -60,6 +60,100 @@ export const Rewrapped = memo(Button); } }); + it('classifies typed styled consts as component nodes too', async () => { + // tree-sitter's tagged-template call takes no type arguments, so a type + // argument that also reads as an expression turns the initializer into + // comparisons: `(styled.div < WrapperProps) > \`…\``. These shapes come from + // outline/outline (FloatingToolbar's Wrapper and Background, Lightbox's + // CloseAction, NudeButton, HStack). + fs.writeFileSync( + path.join(dir, 'typed.tsx'), + `import styled from 'styled-components'; +import { s, depths } from './theme'; +type WrapperProps = { active: boolean }; +type Props = { align: 'start' | 'end' }; +const Plain = styled.div\`color: red;\`; +export const Wrapper = styled.div\` + z-index: \${depths.editorToolbar}; + color: \${(props) => (props.active ? s("accent") : s("text"))}; +\`; +const Background = styled.div<{ align: Props["align"] }>\`color: red;\`; +const CloseAction = styled.div<{ animation: Animation | null }>\`top: 0;\`; +const Content = styled(Plain)\`padding: 4px;\`; +const NudeButton = styled(Plain).attrs((props: Props) => ({ type: "button" }))\`width: 24px;\`; +export const HStack = styled(Plain) + .withConfig({ shouldForwardProp: (prop) => prop !== "spacing" }) + .attrs((props) => ({ align: props.align }))\`\`; +const Either = styled.div\`color: red;\`; +const Both = styled.span\`color: red;\`; +const Nested = styled.div>\`color: red;\`; +const Deeper = styled.div>>\`color: red;\`; +const Named = styled("figure")\`margin: 0;\`; +` + ); + const db = await index(); + for (const name of [ + 'Wrapper', 'Background', 'CloseAction', 'Content', 'NudeButton', 'HStack', + 'Either', 'Both', 'Nested', 'Deeper', 'Named', + ]) { + expect(kindsOf(db, name), `${name} should be a component`).toEqual(['component']); + } + }); + + it('gives a typed styled const its jsx-render callers', async () => { + fs.writeFileSync( + path.join(dir, 'toolbar.tsx'), + `import styled from 'styled-components'; +type WrapperProps = { active: boolean }; +export const Wrapper = styled.div\`opacity: 0;\`; +export function FloatingToolbar() { + return menu; +} +` + ); + fs.writeFileSync( + path.join(dir, 'page.tsx'), + `import { Wrapper } from './toolbar'; +export function Page() { + return ; +} +` + ); + const db = await index(); + const callers = 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 = 'Wrapper' + ORDER BY s.name` + ) + .all() + .map((r: any) => r.caller); + expect(callers).toEqual(['FloatingToolbar', 'Page']); + }); + + it('keeps other typed template tags and real comparisons constants (precision)', async () => { + fs.writeFileSync( + path.join(dir, 'tags.tsx'), + `import styled, { css, createGlobalStyle, keyframes } from 'styled-components'; +type Props = { open: boolean }; +export const Mixin = css\`color: red;\`; +export const GlobalStyle = createGlobalStyle\`body { margin: 0; }\`; +export const Fade = keyframes\`from { opacity: 0; }\`; +export const Styledish = styledLike\`color: red;\`; +export const Compared = styled.length < LIMIT > 2; +export const Untagged = styled.div < Props > other; +const lowerCase = styled.div\`color: red;\`; +` + ); + const db = await index(); + for (const name of ['Mixin', 'GlobalStyle', 'Fade', 'Styledish', 'Compared', 'Untagged', 'lowerCase']) { + expect(kindsOf(db, name), `${name} must stay a constant`).toEqual(['constant']); + } + }); + it('emits jsx-render edges so getCallers/getImpactRadius resolve a forwardRef component', async () => { fs.writeFileSync( path.join(dir, 'button.tsx'), diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 461ae6d39..1c84f8c33 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -103,6 +103,9 @@ impl<'t> Walker<'t> { /// inner is the inline render function, or None for `styled.x`/`memo(Ref)`. /// Outer None = not a component wrapper. fn react_component_hoc(&self, value: Node<'t>) -> Option>> { + if self.is_typed_styled_template(value) { + return Some(None); + } if value.kind() != "call_expression" { return None; } @@ -128,6 +131,35 @@ impl<'t> Walker<'t> { Some(inner) } + /// A typed styled tag, `styled.div` before a template literal, which + /// parses as comparisons: `(styled.div < Props) > template`. The leftmost + /// operand is the `styled` tag against a `<`; the rightmost is the + /// template_string against a `>` (`>>` when the type argument ends in its + /// own `<…>`). Mirrors TreeSitterExtractor.isTypedStyledTemplate. + fn is_typed_styled_template(&self, value: Node<'t>) -> bool { + if value.kind() != "binary_expression" { + return false; + } + let (mut tag_parent, mut tag) = (value, value.child_by_field_name("left")); + while let Some(t) = tag.filter(|t| t.kind() == "binary_expression") { + (tag_parent, tag) = (t, t.child_by_field_name("left")); + } + let (mut template_parent, mut template) = (value, value.child_by_field_name("right")); + while let Some(t) = template.filter(|t| t.kind() == "binary_expression") { + (template_parent, template) = (t, t.child_by_field_name("right")); + } + let (Some(tag), Some(template)) = (tag, template) else { + return false; + }; + let operator = |n: Node<'t>| n.child_by_field_name("operator").map(|o| o.kind()).unwrap_or(""); + let closing = operator(template_parent); + template.kind() == "template_string" + && operator(tag_parent) == "<" + && !closing.is_empty() + && closing.bytes().all(|b| b == b'>') + && util::styled_callee().is_match(self.text(tag)) + } + fn extract_react_component_node( &mut self, name: &str, diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9bcf903a1..6bec6039b 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2010,7 +2010,8 @@ export class TreeSitterExtractor { /** * 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 + * styled-components / emotion `styled.tag\`…\`` / `styled(Base)\`…\``, typed + * (`styled.tag\`…\``) or not. 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 `