diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 525b62110..43d91eca5 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -507,6 +507,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `TAGGED_TEMPLATE_CALL` | javascript | tag`…`. | | `DYNAMIC_CODE_CALL` | javascript | `eval(…)` / `new Function(…)` — unknowable by construction. | | `DYNAMIC_IMPORT_CALL` | javascript | `import(…)` — a module load that is also a site. | +| `JSX_ELEMENT` | javascript | `` — the renderer runs the component (a function component, or a class component's constructor and `render`) with the element's attributes as its props. Engine-authored: no written call; the site is the JSX element expression. An intrinsic tag (`
`) is no site. | | `SIMPLE_CALL` | python | `f(…)` — a bare name. | | `METHOD_CALL` | python | `obj.m(…)`. | | `CHAINED_CALL` | python | `a.b().c(…)` — the receiver is itself a call. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index dad9c147e..f94f8cd50 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -640,6 +640,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'TAGGED_TEMPLATE_CALL', languages: S, meaning: 'tag`…`.' }, { table: 'call_edges', column: 'kind', value: 'DYNAMIC_CODE_CALL', languages: S, meaning: '`eval(…)` / `new Function(…)` — unknowable by construction.' }, { table: 'call_edges', column: 'kind', value: 'DYNAMIC_IMPORT_CALL', languages: S, meaning: '`import(…)` — a module load that is also a site.' }, + { table: 'call_edges', column: 'kind', value: 'JSX_ELEMENT', languages: S, meaning: '`` — the renderer runs the component (a function component, or a class component\'s constructor and `render`) with the element\'s attributes as its props. Engine-authored: no written call; the site is the JSX element expression. An intrinsic tag (`
`) is no site.' }, // — Python (the parser's callKind, plus engine-authored decorator/metaclass forms) { table: 'call_edges', column: 'kind', value: 'SIMPLE_CALL', languages: P, meaning: '`f(…)` — a bare name.' }, { table: 'call_edges', column: 'kind', value: 'METHOD_CALL', languages: P, meaning: '`obj.m(…)`.' }, diff --git a/graph/javascript/README.md b/graph/javascript/README.md index b3595b612..6a26b2abb 100644 --- a/graph/javascript/README.md +++ b/graph/javascript/README.md @@ -191,8 +191,10 @@ constructor), `TYPE_ONLY_TARGET` (the compiler named a JSDoc function type), ## What is not built -* **JSX component calls do not exist in the IR.** The JavaScript schema has no JSX call - kind, so `` is neither a site nor a miss; the oracle counts and excludes them. +* **JSX components reached through a wrapper are not followed.** `` is an edge + (kind `JSX_ELEMENT`, call-edge-generation/jsx.dl) and its attributes are the props the + component receives; a tag whose value is `memo(C)`, `connect(…)(C)` or another unstaged + library's return stays `ambiguous_unknown`. The oracle still excludes JSX sites. * **Primitive results are not typed.** `this.greet().toUpperCase()` is `ambiguous_unknown`, not `ambient_terminal`: nothing here knows `greet` returns a string. diff --git a/graph/javascript/engine/call-edge-generation/jsx.dl b/graph/javascript/engine/call-edge-generation/jsx.dl new file mode 100644 index 000000000..9a63833e7 --- /dev/null +++ b/graph/javascript/engine/call-edge-generation/jsx.dl @@ -0,0 +1,84 @@ +// ============================================================================ +// CALL-EDGE-GEN · JSX ELEMENTS (`` renders Component) +// +// A JSX element whose tag is a reference — ``, `` — is a call +// of that component by the renderer: a function component runs with the element's +// props as its first argument; a class component is constructed with them and its +// `render` runs. The parser emits the element as a JSX_ELEMENT expression with the +// tag under JSX_TAG_NAME and each attribute as a JSX_ATTRIBUTE_VALUE row (role +// PROPERTY_KEY, name = the prop) whose PROPERTY_VALUE child is the value. No +// js_call_site row exists for it, so before this file the element was neither an +// edge nor a miss: `impact TodoList` answered "nothing uses it". +// +// An INTRINSIC tag (`
`, ``) is JSX_INTRINSIC_ELEMENT and has no +// tag reference: it names no project code and gets no row here. +// +// Two effects, both through the ordinary value layer: +// 1. an edge per element: call_chain_edge(Element, Caller, -, Component, …, +// "JSX_ELEMENT"), tiered like a call (one target known_edge, several +// multi_inferred); a tag with no value is ambiguous_unknown — a declared miss, +// never silence. +// 2. the element's attributes are an object value keyed by the element, and it is +// what the component receives: `` makes +// `onToggle(todo)` inside `TodoItem({ onToggle })` resolve to handleToggle. +// Brace children are the `children` prop (a render prop is a function). +// +// Not modelled: a component reached only through a library wrapper +// (`memo(C)`, `connect(…)(C)`, `forwardRef(fn)`) whose library is not staged — the +// tag's value is the wrapper's return, which is unknown, and the element stays an +// ambiguous_unknown row. +// ============================================================================ + +// ── the element and what its tag holds ────────────────────────────────────── +jsx_element_tag(je, tag) :- expr_kind(_, "JSX_ELEMENT", _, je), expr_child(_, je, "JSX_TAG_NAME", _, tag). +jsx_tag_value(je, k, i) :- jsx_element_tag(je, tag), expr_value(tag, k, i). +// A CommonJS module value renders through its default export, as a call of it does. +jsx_tag_value(je, k, i) :- jsx_element_tag(je, tag), expr_value(tag, "module", mod), + module_export_value(mod, "default", k, i), k != "module". + +// ── jsx_renders(Element, Component) — the callables the element runs ─────── +// A function component (a constructor FUNCTION is callable_value's "ctor" half). +jsx_renders(je, m) :- jsx_tag_value(je, k, i), callable_value(k, i, m). +// A class component: constructed, then rendered. +jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_construct_target(t, m). +jsx_renders(je, m) :- jsx_tag_value(je, "ctor", t), type_member(t, "render", m). + +// ── the props object ──────────────────────────────────────────────────────── +// Keyed by the element, as an object literal is keyed by the literal. +prop_value("obj", je, n, k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "PROPERTY_KEY", _, a), expr_kind(_, "JSX_ATTRIBUTE_VALUE", _, a), expr_name(_, n, a), + expr_child(_, a, "PROPERTY_VALUE", _, v), expr_value(v, k, i). +// `` hands over every property of `rest`. +prop_value("obj", je, n, k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "SPREAD_OPERAND", _, o), expr_value(o, k0, i0), prop_value(k0, i0, n, k, i). +// `{(x) => …}` — a brace child is `props.children`. +prop_value("obj", je, "children", k, i) :- expr_kind(_, "JSX_ELEMENT", _, je), + expr_child(_, je, "OPERAND", _, c), expr_value(c, k, i). + +// A function component's first parameter receives the props — client functions only, +// and not a fan-capped one, exactly as argument flow at a call. +param_value(p, "obj", je) :- jsx_tag_value(je, k, i), k != "ctor", callable_value(k, i, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + param_decl(_, _, "0", m, p), !param_is_rest(_, p). +// A class component: its constructor receives them, and `this.props` holds them. +param_value(p, "obj", je) :- jsx_tag_value(je, "ctor", t), type_construct_target(t, m), + method_decl("client", _, _, _, _, _, _, m), !method_is_hot(m, _), + param_decl(_, _, "0", m, p), !param_is_rest(_, p). +prop_value("inst", t, "props", "obj", je) :- jsx_tag_value(je, "ctor", t), type_decl("client", _, _, _, _, t). + +// ── the rows ──────────────────────────────────────────────────────────────── +// The caller is the callable whose body holds the markup; top-level markup +// (`root.render()`) belongs to the module initializer, as a top-level call does. +jsx_caller(je, m) :- jsx_element_tag(je, _), expr_owner(_, m, _, je), m != "". +jsx_caller(je, init) :- jsx_element_tag(je, _), expr_owner(_, "", mod, je), module_init(_, init, mod). +jsx_target_count(je, n) :- jsx_element_tag(je, _), n = count : { jsx_renders(je, _) }. +jsx_class(je, "known_edge") :- jsx_target_count(je, 1). +jsx_class(je, "multi_inferred") :- jsx_target_count(je, n), n >= 2. + +call_chain_edge(je, caller, "-", m, "client", cls, "JSX_ELEMENT") :- jsx_renders(je, m), + method_prov(m, "client"), jsx_caller(je, caller), jsx_class(je, cls). +call_chain_edge(je, caller, "-", m, "lib", "boundary_lib", "JSX_ELEMENT") :- jsx_renders(je, m), + method_prov(m, "lib"), jsx_caller(je, caller). +// A tag that names something the value layer could not follow: a declared unknown. +call_chain_edge(je, caller, "-", "-", "-", "ambiguous_unknown", "JSX_ELEMENT") :- + jsx_target_count(je, 0), jsx_caller(je, caller). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index c4ad3ef53..6a91bfde3 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -42,6 +42,14 @@ .decl accessor_target_count(c0:symbol, c1:symbol, c2:number) .decl accessor_class(c0:symbol, c1:symbol, c2:symbol) +// ── call-edge-generation/jsx.dl ── +.decl jsx_element_tag(c0:symbol, c1:symbol) +.decl jsx_tag_value(c0:symbol, c1:symbol, c2:symbol) +.decl jsx_renders(c0:symbol, c1:symbol) +.decl jsx_caller(c0:symbol, c1:symbol) +.decl jsx_target_count(c0:symbol, c1:number) +.decl jsx_class(c0:symbol, c1:symbol) + // ── call-edge-generation/calls.dl ── .decl method_prov(c0:symbol, c1:symbol) .decl client_calls_client(c0:symbol, c1:symbol, c2:symbol, c3:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index b4dbc3496..b90de230f 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -40,3 +40,4 @@ site_in_partial_module site-in-partial-module.csv package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv +jsx_renders jsx-renders.csv diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx new file mode 100644 index 000000000..538db49ad --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/App.jsx @@ -0,0 +1,26 @@ +import { TodoList } from './components/TodoList.jsx'; +import Panel from './components/Panel.jsx'; +import * as ui from './components/ui.jsx'; + +// CONTROL: a project function spelled like an intrinsic tag. `
` is intrinsic and +// must not become an edge to it. +function div() { return 'not a component'; } + +// CONTROL: named only in a string, never rendered — no edge. +function Unused() { return null; } +const label = 'Unused'; + +export default function App({ title }) { + const extra = { onClose: closeAll }; + return ( +
+ + + + {(item) => format(item)} +
+ ); +} + +function closeAll() { return div(); } +function format(item) { return String(item); } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx new file mode 100644 index 000000000..b3033b90a --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/Panel.jsx @@ -0,0 +1,7 @@ +// A class component with no constructor, as most are: rendered, and `this.props` +// holds the attributes. +export default class Panel { + render() { + return
this.props.onClose()} />; + } +} diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx new file mode 100644 index 000000000..b74829bcd --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoItem.jsx @@ -0,0 +1,4 @@ +// Props arrive destructured; the onToggle prop is whatever the element passed. +export default function TodoItem({ todo, onToggle }) { + return
  • onToggle(todo)}>{todo.text}
  • ; +} diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx new file mode 100644 index 000000000..c09e7390a --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/TodoList.jsx @@ -0,0 +1,9 @@ +import TodoItem from './TodoItem.jsx'; + +export function TodoList({ heading }) { + function handleToggle(todo) { return save(todo); } + const todos = [{ text: heading }]; + return
      {todos.map((t) => )}
    ; +} + +function save(todo) { return todo; } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx new file mode 100644 index 000000000..743dfa1a9 --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/components/ui.jsx @@ -0,0 +1,3 @@ +export function Badge(props) { return {props.count}; } +// A render prop: the brace child is props.children. +export function List(props) { return
      {[1, 2].map((x) => props.children(x))}
    ; } diff --git a/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx b/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx new file mode 100644 index 000000000..cc5033bb0 --- /dev/null +++ b/graph/test/javascript/cases/62-jsx-component-render/src/main.jsx @@ -0,0 +1,4 @@ +import App from './App.jsx'; +// Top-level markup belongs to the module initializer. +mount(); +function mount(el) { return el; } diff --git a/graph/test/javascript/expected/62-jsx-component-render.diag b/graph/test/javascript/expected/62-jsx-component-render.diag new file mode 100644 index 000000000..6073284d3 --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.diag @@ -0,0 +1,4 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved App.jsx:26:32 FUNCTION_CALL String no_target +unresolved components/TodoList.jsx:6:15 METHOD_CALL map no_target +unresolved components/ui.jsx:3:43 METHOD_CALL map no_target diff --git a/graph/test/javascript/expected/62-jsx-component-render.edges b/graph/test/javascript/expected/62-jsx-component-render.edges new file mode 100644 index 000000000..05290ffbf --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.edges @@ -0,0 +1,18 @@ +App.jsx:17:7 JSX_ELEMENT -> known_edge components/TodoList.jsx:3:1 TodoList +App.jsx:18:7 JSX_ELEMENT -> known_edge components/Panel.jsx:4:3 render +App.jsx:19:7 JSX_ELEMENT -> known_edge components/ui.jsx:1:1 Badge +App.jsx:20:27 FUNCTION_CALL format -> known_edge App.jsx:26:1 format +App.jsx:20:7 JSX_ELEMENT {(item) => format(item)} -> known_edge components/ui.jsx:3:1 List +App.jsx:25:30 FUNCTION_CALL div -> known_edge App.jsx:7:1 div +App.jsx:26:32 FUNCTION_CALL String -> ambient_terminal - +components/Panel.jsx:5:36 METHOD_CALL this.props.onClose -> known_edge App.jsx:25:1 closeAll +components/TodoItem.jsx:3:29 FUNCTION_CALL onToggle -> known_edge components/TodoList.jsx:4:3 handleToggle +components/TodoList.jsx:4:40 FUNCTION_CALL save -> known_edge components/TodoList.jsx:9:1 save +components/TodoList.jsx:6:15 METHOD_CALL todos.map -> ambient_terminal - +components/TodoList.jsx:6:15 METHOD_CALL todos.map -> callback_registered components/TodoList.jsx:6:25 +components/TodoList.jsx:6:32 JSX_ELEMENT -> known_edge components/TodoItem.jsx:2:1 TodoItem +components/ui.jsx:3:43 METHOD_CALL [1, 2].map -> ambient_terminal - +components/ui.jsx:3:43 METHOD_CALL [1, 2].map -> callback_registered components/ui.jsx:3:54 +components/ui.jsx:3:61 METHOD_CALL props.children -> known_edge App.jsx:20:17 +main.jsx:3:1 FUNCTION_CALL mount -> known_edge main.jsx:4:1 mount +main.jsx:3:7 JSX_ELEMENT -> known_edge App.jsx:13:1 App diff --git a/graph/test/javascript/expected/62-jsx-component-render.oracle b/graph/test/javascript/expected/62-jsx-component-render.oracle new file mode 100644 index 000000000..b79c5b4f1 --- /dev/null +++ b/graph/test/javascript/expected/62-jsx-component-render.oracle @@ -0,0 +1,8 @@ +App.jsx:20:27 FUNCTION_CALL format EXACT App.jsx:26:1 +App.jsx:25:30 FUNCTION_CALL div EXACT App.jsx:7:1 +App.jsx:26:32 FUNCTION_CALL String LIB_AMBIENT_OK +components/TodoList.jsx:4:40 FUNCTION_CALL save EXACT components/TodoList.jsx:9:1 +components/TodoList.jsx:6:15 METHOD_CALL map LIB_AMBIENT_OK +components/ui.jsx:3:43 METHOD_CALL map LIB_AMBIENT_OK +main.jsx:3:1 FUNCTION_CALL mount EXACT main.jsx:4:1 +# defects: 0 diff --git a/graph/test/javascript/tools/normalize_edges.py b/graph/test/javascript/tools/normalize_edges.py index c219389d2..8c515c322 100644 --- a/graph/test/javascript/tools/normalize_edges.py +++ b/graph/test/javascript/tools/normalize_edges.py @@ -51,7 +51,8 @@ def methods(d, tag): if h: i = {c: h.index(c) for c in ('jsExpressionUniqueHash', 'ownerModuleLinkHash', 'startLine', 'startColumn', 'expressionKind', 'text')} for r in exprs: - if r[i['expressionKind']] in ('PROPERTY_ACCESS', 'OPTIONAL_ACCESS', 'ELEMENT_ACCESS'): + # A JSX element edge is keyed on the JSX_ELEMENT expression, likewise no call site. + if r[i['expressionKind']] in ('PROPERTY_ACCESS', 'OPTIONAL_ACCESS', 'ELEMENT_ACCESS', 'JSX_ELEMENT'): access[r[i['jsExpressionUniqueHash']]] = '%s:%s:%s %%s %s' % ( modfile.get(r[i['ownerModuleLinkHash']], '?'), r[i['startLine']], r[i['startColumn']], r[i['text']].replace('\n', ' ')[:60])