diff --git a/CHANGELOG.md b/CHANGELOG.md index 08226636c..5b6119014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. +- In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/jsx-child-disambiguation.test.ts b/__tests__/jsx-child-disambiguation.test.ts index 5c8465a90..2aedc99b5 100644 --- a/__tests__/jsx-child-disambiguation.test.ts +++ b/__tests__/jsx-child-disambiguation.test.ts @@ -129,3 +129,212 @@ export function Header() { return

; } expect(rendersFrom(db, 'Header')).toEqual(['only/badge.tsx']); }); }); + +/** + * The pass reads tag names off a parent's source with a pattern, and two + * kinds of name it reads that way are no other file's component. + * + * - A type argument: ` …>` or `useState()` + * writes a type's name right after another name. outline's document list + * rendered its `Document` model class, its settings tables the `User` and + * `Group` classes. + * - A name the parent binds itself, as a `const` or a parameter, is that local + * wherever its tags sit. outline's menus pick a Radix part into `const + * Content = variant === 'dropdown' ? DropdownMenu.SubContent : + * ContextMenu.SubContent` and rendered the command bar's styled `Content` + * instead. A local is no node, so it renders nothing, unless the parent + * declares a component of that name inside itself. + */ +describe('JSX child: a name the parent writes only as a type, or binds itself', () => { + let dir: string; + let cg: any; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsx-local-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}'); + }); + + afterEach(() => { + cg?.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const write = (rel: string, body: string) => { + const p = path.join(dir, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + }; + + async function index(): Promise { + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + } + + /** `name file:line` of each node a jsx-render edge out of `parent` points at. */ + const renders = (parent: string): string[] => + cg.db.db + .prepare( + `SELECT t.name || ' ' || t.file_path || ':' || t.start_line AS r FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = ? AND json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render' + ORDER BY r` + ) + .all(parent) + .map((row: any) => row.r); + + it('renders nothing for a name written only as a type argument or parameter', async () => { + write('app/models/Document.ts', 'export default class Document {\n title = "";\n}\n'); + write('app/models/User.ts', 'export default class User {\n name = "";\n}\n'); + write( + 'app/components/PaginatedList.tsx', + 'export function PaginatedList({ items }: { items: T[] }) {\n return
    {items.length}
;\n}\n' + ); + write( + 'app/components/Badge.tsx', + 'import * as React from "react";\nexport class Badge extends React.Component {\n render() {\n return ;\n }\n}\n' + ); + write( + 'app/components/DocumentList.tsx', + `import * as React from 'react'; +import Document from '../models/Document'; +import User from '../models/User'; +import { Badge } from './Badge'; +import { PaginatedList } from './PaginatedList'; + +export function DocumentList({ items }: { items: Document[] }) { + const [owner] = React.useState(null); + const badge = React.useRef(null); + return ( +
+ items={items} /> + +
+ ); +} +` + ); + // bulletproof-react's table: a generic arrow function's type parameter. + write('app/profile/entry.tsx', 'export function Entry() {\n return
;\n}\n'); + write( + 'app/components/Table.tsx', + `type BaseEntity = { id: string }; + +export const Table = ({ data }: { data: Entry[] }) => { + return {data.length}
; +}; +` + ); + await index(); + // `Badge` is written as a type argument too, but a tag of it is enough. + // A generic tag's own name is not read at all: no tag ends after + // ` { + write( + 'app/components/CommandBarItem.tsx', + 'export function Content() {\n return
;\n}\n\nexport function Icon() {\n return ;\n}\n' + ); + write('shared/editor/Widget.tsx', 'export function Widget() {\n return
;\n}\n'); + write('app/components/Portal.tsx', 'export function Portal({ children }) {\n return
{children}
;\n}\n'); + write( + 'app/components/Menu.tsx', + `import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import * as ContextMenu from '@radix-ui/react-context-menu'; + +const Portal = DropdownMenu.Portal; + +export function SubMenuContent({ variant, children }) { + const Portal = + variant === 'dropdown' ? DropdownMenu.Portal : ContextMenu.Portal; + const Content = + variant === 'dropdown' + ? DropdownMenu.SubContent + : ContextMenu.SubContent; + return ( + + {children} + + ); +} + +export function SettingsLinks({ config }) { + return ( +
    + {config.map((item) => { + const Icon = item.icon; + return
  • ; + })} +
+ ); +} + +export function Widgets({ widgets }) { + return
{Object.values(widgets).map((Widget, index) => )}
; +} +` + ); + await index(); + // The file's own `Portal` is shadowed too: the tag means the local. + expect(renders('SubMenuContent')).toEqual([]); + expect(renders('SettingsLinks')).toEqual([]); + expect(renders('Widgets')).toEqual([]); + }); + + it('renders a component the parent declares inside itself, and the outer one for a tag above the local', async () => { + write('a-decoy/row.tsx', 'export function Row() {\n return ;\n}\n'); + write('app/icon.tsx', 'export function Icon() {\n return ;\n}\n'); + write( + 'app/table.tsx', + `import { Icon } from './icon'; + +function Row() { + return ; +} + +export function Table({ rows }) { + const Row = ({ row }) => {row.name}; + return {rows.map((row) => )}
; +} + +export function Toolbar({ actions }) { + return ( +
+ + {actions.map((action) => { + const Icon = action.icon; + return ; + })} +
+ ); +} +` + ); + await index(); + expect(renders('Table')).toEqual(['Row app/table.tsx:8']); + expect(renders('Toolbar')).toEqual(['Icon app/icon.tsx:1']); + }); + + it('keeps a destructured name on the same-named component, as a destructured call does', async () => { + // excalidraw's example app: `const { Sidebar, Footer, … } = excalidrawLib`. + // Destructuring is not the parent's own binding here, as it is not for a + // bare call (`const { t } = useI18n()`), so the name links by name. + write( + 'packages/excalidraw/components/WelcomeScreen.tsx', + 'export default function WelcomeScreen() {\n return
;\n}\n' + ); + write( + 'examples/app/ExampleApp.tsx', + `export default function ExampleApp({ excalidrawLib }) { + const { WelcomeScreen, exportToSvg } = excalidrawLib; + return
; +} +` + ); + await index(); + expect(renders('ExampleApp')).toEqual(['WelcomeScreen packages/excalidraw/components/WelcomeScreen.tsx:1']); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 87229132a..046c636e2 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -39,7 +39,7 @@ import { createYielder, type MaybeYield } from './cooperative-yield'; import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; -import { crossesCodeBoundary } from './name-matcher'; +import { crossesCodeBoundary, jsCodeBindsName } from './name-matcher'; const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/; const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i; @@ -1584,14 +1584,62 @@ function jsxChild( return candidates.find((n) => JSX_CHILD_LANGUAGES.includes(n.language)) ?? candidates[0]; } +/** + * Whether the `()`, `Array`, the + * `` of `` — and a tag never does, short of + * a `return` written up against it. A generic arrow function's type + * parameters read like a tag but constrain theirs: `({ data }) =>`. + */ +function opensTag(src: string, at: number, name: string): boolean { + const end = at + 1 + name.length; + if (/^\s+extends\s/.test(src.slice(end, end + 40))) return false; + return !/[\w$]/.test(src[at - 1] ?? '') || /\breturn$/.test(src.slice(Math.max(0, at - 7), at)); +} + +/** + * Whether `src` writes `name` other than in a tag (``), so the check for that runs only on these. + */ +function writtenBare(src: string, name: string): boolean { + for (let at = src.indexOf(name); at !== -1; at = src.indexOf(name, at + name.length)) { + if (/[\w$]/.test(src[at - 1] ?? '') || /[\w$]/.test(src[at + name.length] ?? '')) continue; + if (src[at - 1] === '<' || (src[at - 1] === '/' && src[at - 2] === '<')) continue; + return true; + } + return false; +} + +/** + * What a tag renders when its parent binds the name itself: a component the + * parent declares in its own lines (`const Row = ({ row }) => ` in a + * table's body), else nothing. Any other local is no node, and a same-named + * component elsewhere is only a guess at what it holds. + */ +function declaredInside(ctx: ResolutionContext, name: string, parent: Node, child: Node): Node | undefined { + const inside = (n: Node) => n.id !== parent.id && n.filePath === parent.filePath && + n.startLine >= parent.startLine && n.startLine <= parent.endLine; + if (inside(child)) return child; + return ctx.getNodesByName(name).find((n) => JSX_CHILD_KINDS.has(n.kind) && inside(n)); +} + /** * Phase 5: React JSX child rendering. A component that returns `` * mounts Child — React calls it — but JSX instantiation isn't a static call edge, * so a render tree (App.render → StaticCanvas → renderStaticScene) breaks at the * JSX hop. Link parent → each capitalized JSX child it renders. File-oriented - * (read each JSX file once). Precision gate: the child name must resolve to a - * component/function/class node — TS generics like `Array` resolve to a type - * (or nothing) and are dropped. + * (read each JSX file once). Precision gates: the parent must write the name as + * a tag, not only in a type argument or parameter list (`useState()`, + * `(…) =>`; outline's `` + * read its `Document` model class as a child), and the name must resolve to a + * component/function/class node. A name the parent binds itself by its first + * tag — a parameter, or a `const` like outline's `const Content = variant === + * "dropdown" ? DropdownMenu.SubContent : ContextMenu.SubContent` — is that + * local, and renders only a component declared inside the parent + * (`declaredInside`). A destructured name still links by name, as a + * destructured function call does. */ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise { let scannedFiles = 0; @@ -1620,26 +1668,41 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield): // over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The // file is split once, not once per function. let lines: string[] | null = null; - const tagsBySpan = new Map>(); + // Each name a span holds, and the line of its first tag: 0 while only type + // argument or parameter lists name it (`opensTag`). `bare` holds the + // names it also writes outside a tag (`writtenBare`). + const tagsBySpan = new Map; bare: Set }>(); for (const parent of parents) { if (!parent.startLine || !parent.endLine) continue; const span = `${parent.startLine}:${parent.endLine}`; let names = tagsBySpan.get(span); if (!names) { - names = new Set(); + names = { tags: new Map(), bare: new Set() }; lines ??= content.split('\n'); const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n'); if (src.includes('')) { + let line = parent.startLine; + let counted = 0; JSX_TAG_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!); + while ((m = JSX_TAG_RE.exec(src))) { + for (let nl = src.indexOf('\n', counted); nl !== -1 && nl < m.index; nl = src.indexOf('\n', nl + 1)) line++; + counted = m.index; + if (!names.tags.get(m[1]!)) names.tags.set(m[1]!, opensTag(src, m.index, m[1]!) ? line : 0); + } + for (const [name, first] of names.tags) if (first && writtenBare(src, name)) names.bare.add(name); } tagsBySpan.set(span, names); } let added = 0; - for (const name of names) { + for (const [name, line] of names.tags) { if (added >= MAX_JSX_CHILDREN) break; - const child = jsxChild(ctx, name, file, importsOf); + if (!line) continue; + let child = jsxChild(ctx, name, file, importsOf); + // A name the parent binds itself by its first tag is that local. + if (child && names.bare.has(name) && jsCodeBindsName(name, parent, file, line, ctx)) { + child = declaredInside(ctx, name, parent, child); + } if (!child || child.id === parent.id || crossesCodeBoundary(parent.language, child.language)) continue; const key = `${parent.id}>${child.id}`; if (seen.has(key)) continue; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 36365398d..84b6fb457 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6857,7 +6857,7 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut let scope: { start: number; end: number } | null = null; const fn = context.getNodeById?.(ref.fromNodeId); if (fn && (fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line && - jsCodeBindsName(name, fn, ref, context)) { + jsCodeBindsName(name, fn, ref.filePath, ref.line, context)) { scope = { start: fn.startLine, end: fn.endLine }; } memo.set(key, scope); @@ -6872,14 +6872,18 @@ function jsFunctionLocalScope(name: string, ref: UnresolvedRef, context: Resolut */ const JS_CODE_BINDS = new WeakMap>(); -/** Whether the function's code through the reference's line declares `name`, or names it in a parameter list. */ -function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { +/** + * Whether the function's code in `filePath`, from its first line through + * `line` (a reference's, or a JSX tag's), declares `name`, or names it in a + * parameter list. + */ +export function jsCodeBindsName(name: string, fn: Node, filePath: string, line: number, context: ResolutionContext): boolean { let memo = JS_CODE_BINDS.get(context); if (!memo) JS_CODE_BINDS.set(context, (memo = new Map())); - const key = `${ref.filePath}\0${fn.startLine}\0${ref.line}\0${name}`; + const key = `${filePath}\0${fn.startLine}\0${line}\0${name}`; let binds = memo.get(key); if (binds === undefined) { - const text = jsFunctionCodeThrough(fn, ref, context); + const text = jsFunctionCodeThrough(fn, filePath, line, context); const { param } = localBindingPatterns(name, 'g'); const n = name.replace(/\$/g, '\\$'); // A plain declaration. Destructuring re-binds what a call returns under @@ -6904,38 +6908,38 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re } /** - * Per context: the comment-stripped lines of the functions jsFunctionLocalScope + * Per context: the comment-stripped lines of the functions jsCodeBindsName * read last, and where each line ends in that text, most recent last. */ const JS_FN_CODE = new WeakMap>(); const JS_FN_CODE_KEEP = 64; /** - * The function's lines from its first through the reference's, comments - * blanked. Stripping looks at most one character ahead — past a line's end, - * a newline, which completes no comment marker — so this is the whole + * The function's lines from its first through `line`, comments blanked. + * Stripping looks at most one character ahead — past a line's end, a + * newline, which completes no comment marker — so this is the whole * function's stripped text cut at that line's end. Stripping the lines again * for every reference took time in the square of a large function's length; * in a minified script, every function's text runs to the end of its one * line (#2334). */ -function jsFunctionCodeThrough(fn: Node, ref: UnresolvedRef, context: ResolutionContext): string { +function jsFunctionCodeThrough(fn: Node, filePath: string, line: number, context: ResolutionContext): string { let fns = JS_FN_CODE.get(context); if (!fns) JS_FN_CODE.set(context, (fns = new Map())); - const key = `${ref.filePath}\0${fn.startLine}\0${fn.endLine}`; + const key = `${filePath}\0${fn.startLine}\0${fn.endLine}`; let own = fns.get(key); if (own) { fns.delete(key); } else { - const lines = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []) + const lines = (context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []) .slice(fn.startLine - 1, fn.endLine); let end = -1; - own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((line) => (end += line.length + 1)) }; + own = { code: stripCommentsForRegex(lines.join('\n'), 'javascript'), lineEnds: lines.map((text) => (end += text.length + 1)) }; if (fns.size >= JS_FN_CODE_KEEP) fns.delete(fns.keys().next().value!); } fns.set(key, own); - // Through the reference's line, or the file's last when it ends sooner. - const last = Math.min(ref.line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine; + // Through `line`, or the file's last when it ends sooner. + const last = Math.min(line, fn.startLine - 1 + own.lineEnds.length) - fn.startLine; return last < 0 ? '' : own.code.slice(0, own.lineEnds[last]); }