diff --git a/src/graph/extract.ts b/src/graph/extract.ts index d0f9dff1..437df7a9 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -275,6 +275,15 @@ const KINDS_BY_LANG: Record> = { php: PHP_KINDS, }; +/** The two element shapes a JSX component usage takes. Named once so the call table + * and `calleeName` cannot drift apart. `jsx_closing_element` is deliberately absent: + * `` is the tail of the same usage the opening tag already recorded, and + * counting it would double every JSX edge. */ +const JSX_ELEMENT_TYPES: ReadonlySet = new Set([ + "jsx_opening_element", + "jsx_self_closing_element", +]); + /** * The node type(s) that constitute a call site, per language. * @@ -283,10 +292,19 @@ const KINDS_BY_LANG: Record> = { * codebase's constructor calls are a large share of its real edges. PHP is * likewise multi-shape: a call is a function / member / nullsafe-member / scoped * call, never a single `call_expression`. + * + * JSX is that same argument in a React codebase (#382). `` is how a + * component gets invoked — the runtime calls the function and passes it props — + * but it parses as `jsx_opening_element` / `jsx_self_closing_element`, so a + * component's consumers produced no edges at all and `callers`/`blast` went + * structurally blind across the whole component layer, the one place a "you + * changed this, these break" answer is worth most. Only the `tsx` grammar can + * reach these node types; `.ts` and `.js` are parsed by `typescript`, which has + * no JSX at all, so widening it would be dead weight. */ const CALL_TYPES: Record> = { typescript: new Set(["call_expression"]), - tsx: new Set(["call_expression"]), + tsx: new Set(["call_expression", ...JSX_ELEMENT_TYPES]), python: new Set(["call"]), go: new Set(["call_expression"]), java: new Set(["method_invocation", "object_creation_expression"]), @@ -2268,6 +2286,48 @@ if (lang === "kotlin") { if (lang === "php") return phpCallee(node); + if (lang === "tsx" && JSX_ELEMENT_TYPES.has(node.type)) { + // `` and ``: the element's `name` field is the + // callee. React calls the component with the props as its argument, so this + // is the same "X invokes Y" fact as `Widget({children})` — it just does not + // spell it as a `call_expression`. There is no receiver to type: an element + // name is a value in lexical scope, exactly like a bare call's callee. + // + // Casing is not a heuristic, and the test is deliberately for the INTRINSIC + // side rather than the component side. JSX's rule, as TypeScript's own + // `isIntrinsicJsxName` states it, is `ch >= 'a' && ch <= 'z' || name.includes("-")`: + // a name starting with an ASCII lowercase letter is a host element React + // forwards to the DOM as a string, and so is any hyphenated name (a custom + // element). EVERYTHING else is an ordinary binding in lexical scope. + // + // Asking "does it start A-Z" instead is not the same question, and gets three + // real cases wrong: `<Écran/>` (uppercase, but not ASCII), `<_Widget/>` and + // `<$Widget/>` — all three are bindings the grammar hands back as plain + // `identifier`, and all three would vanish silently. The ASCII range is correct + // here precisely because it is the lowercase half: TypeScript restricts the + // intrinsic test to a-z, so a non-ASCII initial is a component by definition. + // + // A namespaced name (``) arrives as `jsx_namespace_name`, not + // `identifier`, so the type check above already excludes it — which is right, + // since TypeScript treats those as intrinsic too. + // + // A dotted element name (``, ``) is a + // `member_expression`, not an `identifier`, and is left alone on purpose: it + // needs a receiver type the way `ui.button()` does, and the namespace import + // it usually comes from binds none — the same wall qualified construction + // hits in Java (see javaConstructedTypeName). Resolving the trailing segment + // on its own is the guess this module does not make. + // + // `kinds` rather than the function-only default: a class component + // (`class Boundary extends React.Component`) is as much a component as a + // function one, and this is scoped to element names, so an ordinary + // `Widget()` call in the same file still resolves against functions alone. + const name = node.childForFieldName("name"); + if (name?.type !== "identifier") return null; + if (/^[a-z]/.test(name.text) || name.text.includes("-")) return null; + return { name: name.text, viaMember: false, kinds: ["function", "class"] }; + } + const fn = node.childForFieldName("function"); if (!fn) return null; if (fn.type === "identifier") return { name: fn.text, viaMember: false }; diff --git a/test/graph-jsx.test.ts b/test/graph-jsx.test.ts new file mode 100644 index 00000000..3ca213d0 --- /dev/null +++ b/test/graph-jsx.test.ts @@ -0,0 +1,203 @@ +/** + * A JSX element is how a React component gets called (#382). + * + * `` and `Widget({children})` are the same invocation — the runtime does + * the second when it sees the first — but only one of them parses as a + * `call_expression`, so only one produced an edge. Since components are + * essentially always used as elements, that left the component layer with no + * incoming edges at all: `callers` reported none, and `blast` returned an empty + * impact set for a diff that changed a provider five test files mount. + * + * Reproduced from a real merged pull request whose source change touched one + * context provider and required updating the five test files that mount it. All + * five imported it, all five were indexed, and the blast radius was `0 symbols in + * 0 areas`; `grep` found them immediately, which is what pins this as a missing + * edge rather than a missing file. + * + * What these pin: + * - the edge, for both element spellings and for `.jsx` as well as `.tsx`; + * - one edge per usage — the closing tag is not a second one; + * - a class component, which is a component too; + * - the two names that are not symbols: a lowercase host element, and a dotted + * element name with no receiver to type. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { buildGraph } from "../src/graph/build.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import { tmpRepo } from "./helpers.js"; +import type { GraphV1 } from "../src/graph/types.js"; + +async function buildJsx(files: Record): Promise { + const root = tmpRepo("jsx-"); + for (const [rel, src] of Object.entries(files)) { + const file = join(root, rel); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, src); + } + await buildGraph(root, { reuse: false }); + const graph = readGraph(wiringPath(join(root, "graft"))); + assert.ok(graph, "graph built"); + return graph!; +} + +function callEdges(graph: GraphV1): Array<{ from: string; to: string }> { + const name = (id: string): string => graph.nodes.find((n) => n.id === id)?.name ?? id; + return graph.edges + .filter((e) => e.relation === "calls") + .map((e) => ({ from: name(e.source), to: name(e.target) })); +} + +test("jsx: mounting a component makes the consumer a caller, both spellings and both extensions", async () => { + const graph = await buildJsx({ + "lib/widget.tsx": ` +export function Widget({ children }: { children?: unknown }) { + return children; +} +`, + "app/paired.tsx": ` +import { Widget } from '../lib/widget'; + +export function renderPaired() { + return hi; +} +`, + "app/selfclosing.tsx": ` +import { Widget } from '../lib/widget'; + +export function renderSelfClosing() { + return ; +} +`, + "app/legacy.jsx": ` +import { Widget } from '../lib/widget'; + +export function renderFromJsxFile() { + return hi; +} +`, + "app/direct.tsx": ` +import { Widget } from '../lib/widget'; + +export function renderAsCall() { + return Widget({ children: 'hi' }); +} +`, + }); + const calls = callEdges(graph); + const into = calls.filter((c) => c.to === "Widget").map((c) => c.from).sort(); + assert.deepEqual(into, [ + "renderAsCall", + "renderFromJsxFile", + "renderPaired", + "renderSelfClosing", + ]); + // Exactly one edge per consumer: `` closes the usage the opening tag + // already recorded, and a paired element must not weigh twice as much as a + // self-closing one in anything that ranks by in-degree. + assert.equal(into.length, new Set(into).size, "one edge per mount, not one per tag"); +}); + +test("jsx: a class component is a component", async () => { + const graph = await buildJsx({ + "lib/boundary.tsx": ` +import React from 'react'; + +export class Boundary extends React.Component { + render() { + return null; + } +} +`, + "app/shell.tsx": ` +import { Boundary } from '../lib/boundary'; + +export function Shell() { + return ; +} +`, + }); + assert.ok( + callEdges(graph).some((c) => c.from === "Shell" && c.to === "Boundary"), + "an element whose target is a class resolves like one whose target is a function", + ); +}); + +test("jsx: a host element and a dotted element name are not symbols", async () => { + const graph = await buildJsx({ + "lib/panel.tsx": ` +export function Panel({ children }: { children?: unknown }) { + return children; +} +`, + "lib/ui.tsx": ` +export function Button() { + return null; +} +`, + "app/page.tsx": ` +import { Panel } from '../lib/panel'; +import * as UI from '../lib/ui'; + +export function Page() { + return ( +
+ title + body + +
+ ); +} +`, + }); + const from = callEdges(graph).filter((c) => c.from === "Page").map((c) => c.to).sort(); + // `div`/`span` are host elements React passes to the DOM as strings, so they are + // not names in scope at all. `UI.Button` is a name in scope, but reaching it needs + // the namespace import's type — resolving the trailing `Button` on its own is the + // guess resolve.ts refuses to make for `ui.button()` either. + assert.deepEqual(from, ["Panel"]); +}); + +test("jsx: the intrinsic test is JSX's own rule, not an A-Z check", async () => { + // TypeScript states the rule as `ch >= 'a' && ch <= 'z' || name.includes("-")` + // (isIntrinsicJsxName). Asking the opposite question — "does it start A-Z" — is not + // the complement: it drops `_Widget`, `$Widget` and every non-ASCII capital, all of + // which are ordinary bindings the grammar hands back as plain `identifier`. A + // French-named component is not a DOM tag. + const graph = await buildJsx({ + "lib/parts.tsx": ` +export function Écran() { + return null; +} + +export function _Widget() { + return null; +} + +export function $Widget() { + return null; +} +`, + "app/page.tsx": ` +import { Écran, _Widget, $Widget } from '../lib/parts'; + +export function Page() { + return ( +
+ <Écran /> + <_Widget /> + <$Widget /> + + +
+ ); +} +`, + }); + const from = callEdges(graph).filter((c) => c.from === "Page").map((c) => c.to).sort(); + // `my-element` is a custom element and `svg:circle` a namespaced name — both intrinsic, + // and the namespaced one does not even arrive as an `identifier`. + assert.deepEqual(from, ["$Widget", "_Widget", "Écran"]); +});