Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/graph/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,15 @@ const KINDS_BY_LANG: Record<Language, Record<string, Kind>> = {
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:
* `</Widget>` 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<string> = new Set([
"jsx_opening_element",
"jsx_self_closing_element",
]);

/**
* The node type(s) that constitute a call site, per language.
*
Expand All @@ -283,10 +292,19 @@ const KINDS_BY_LANG: Record<Language, Record<string, Kind>> = {
* 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). `<Widget/>` 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<Language, ReadonlySet<string>> = {
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"]),
Expand Down Expand Up @@ -2268,6 +2286,48 @@ if (lang === "kotlin") {

if (lang === "php") return phpCallee(node);

if (lang === "tsx" && JSX_ELEMENT_TYPES.has(node.type)) {
// `<Widget>…</Widget>` and `<Widget/>`: 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 (`<svg:circle/>`) 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 (`<UI.Button/>`, `<Widget.Slot/>`) 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 };
Expand Down
203 changes: 203 additions & 0 deletions test/graph-jsx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* A JSX element is how a React component gets called (#382).
*
* `<Widget/>` 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<string, string>): Promise<GraphV1> {
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 <Widget>hi</Widget>;
}
`,
"app/selfclosing.tsx": `
import { Widget } from '../lib/widget';

export function renderSelfClosing() {
return <Widget />;
}
`,
"app/legacy.jsx": `
import { Widget } from '../lib/widget';

export function renderFromJsxFile() {
return <Widget>hi</Widget>;
}
`,
"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: `</Widget>` 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 <Boundary />;
}
`,
});
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 (
<div className="page">
<span>title</span>
<Panel>body</Panel>
<UI.Button />
</div>
);
}
`,
});
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 (
<div className="page">
<Écran />
<_Widget />
<$Widget />
<my-element />
<svg:circle />
</div>
);
}
`,
});
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"]);
});
Loading