From fcff3b521f3a231e9368a5ca223bc321d0c46285 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 00:28:02 +0530 Subject: [PATCH] feat(trace): render per-instance attribution for shared components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Definition-level traces of a shared component (e.g. DataTable) surfaced no data sources, because such components own no data — each render site feeds them different data via props. - core: add InstanceAttribution + optional Lineage.perInstance; compute it in traceLineage by attributing each rendering parent's reachable data sources (minus the component's own) when a component is shared (>=2 parents render it). - cli: render a "per instance" block in the trace command showing each render site (Component@Parent) and its distinct endpoints. - eval: add c1-shared-datatable fixture (UsersPage -> /api/users, InvoicesPage -> /api/invoices, both rendering DataTable). Co-Authored-By: Claude Opus 4.8 --- .../c1-shared-datatable/app/DataTable.tsx | 38 ++++++ .../c1-shared-datatable/app/InvoicesPage.tsx | 26 ++++ .../c1-shared-datatable/app/UsersPage.tsx | 26 ++++ packages/cli/src/index.ts | 15 +++ packages/core/src/query.ts | 114 +++++++++++++----- 5 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 eval/fixtures/c1-shared-datatable/app/DataTable.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx create mode 100644 eval/fixtures/c1-shared-datatable/app/UsersPage.tsx diff --git a/eval/fixtures/c1-shared-datatable/app/DataTable.tsx b/eval/fixtures/c1-shared-datatable/app/DataTable.tsx new file mode 100644 index 0000000..2fa7287 --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/DataTable.tsx @@ -0,0 +1,38 @@ +// A shared, presentational table. It owns no data of its own — every render +// site passes different rows/columns via props. This is the headline C1 case: +// a definition-level trace of DataTable finds no data sources, yet each +// instance is fed by a distinct endpoint. + +interface Column { + key: string; + header: string; +} + +export function DataTable({ + rows, + columns, +}: { + rows: Record[]; + columns: Column[]; +}) { + return ( + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
{col.header}
{String(row[col.key])}
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx b/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx new file mode 100644 index 0000000..df71c7e --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/InvoicesPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "./DataTable"; + +export function InvoicesPage() { + const [invoices, setInvoices] = useState([]); + + useEffect(() => { + fetch("/api/invoices") + .then((res) => res.json()) + .then((data) => setInvoices(data)); + }, []); + + return ( +
+

Invoices

+ +
+ ); +} diff --git a/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx b/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx new file mode 100644 index 0000000..83c59bf --- /dev/null +++ b/eval/fixtures/c1-shared-datatable/app/UsersPage.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +import { DataTable } from "./DataTable"; + +export function UsersPage() { + const [users, setUsers] = useState([]); + + useEffect(() => { + fetch("/api/users") + .then((res) => res.json()) + .then((data) => setUsers(data)); + }, []); + + return ( +
+

Users

+ +
+ ); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 20818f2..cfbc70a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -100,6 +100,21 @@ program if (lineage.via.length > 0) { console.log(` via: ${lineage.via.map((v) => v.name).join(", ")}`); } + if (lineage.perInstance !== undefined && lineage.perInstance.length > 0) { + console.log(" per instance:"); + for (const inst of lineage.perInstance) { + console.log( + ` ${lineage.component.name}@${inst.parent.name} (${inst.parent.loc.file}:${inst.parent.loc.line})`, + ); + if (inst.dataSources.length === 0) { + console.log(" (no distinct data sources)"); + continue; + } + for (const ds of inst.dataSources) { + console.log(` → ${ds.method ?? "?"} ${ds.endpoint} (${ds.loc.file}:${ds.loc.line})`); + } + } + } }); function loadGraph(file: string): LineageGraph { diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index d25120e..f039bf5 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -44,6 +44,22 @@ export function matchComponentsByText(graph: LineageGraph, terms: string[]): Com return matches.sort((a, b) => b.score - a.score); } +/** + * Attribution of data for a single render site of a shared component. + * + * A component like `DataTable` often owns no data of its own — each parent that + * renders it passes different data via props. Statically resolving prop dataflow + * is out of scope for v0.1, so this uses the pragmatic approximation: the data + * sources reachable from the rendering `parent` that are not intrinsic to the + * shared component itself. + */ +export interface InstanceAttribution { + /** The component whose JSX renders this instance of the shared component. */ + parent: ComponentNode; + /** Data sources reachable from `parent` but not intrinsic to the shared component. */ + dataSources: DataSourceNode[]; +} + export interface Lineage { component: ComponentNode; dataSources: DataSourceNode[]; @@ -51,6 +67,20 @@ export interface Lineage { events: EventNode[]; /** Hooks and child components on the path, in discovery order. */ via: LineageNode[]; + /** + * Per-render-site attribution, present only when the component is shared + * (rendered by two or more parents). Distinguishes, e.g., + * `DataTable@UsersPage → /api/users` from `DataTable@InvoicesPage → /api/invoices`. + */ + perInstance?: InstanceAttribution[]; +} + +/** The reachable feeders of a component, ignoring per-instance attribution. */ +interface Reachable { + dataSources: DataSourceNode[]; + state: StateNode[]; + events: EventNode[]; + via: LineageNode[]; } /** @@ -69,36 +99,64 @@ export function traceLineage(graph: LineageGraph, componentId: string): Lineage const start = byId.get(componentId); if (start === undefined || start.kind !== "component") return null; - const lineage: Lineage = { component: start, dataSources: [], state: [], events: [], via: [] }; - const seen = new Set([componentId]); - const queue: string[] = [componentId]; - - while (queue.length > 0) { - const id = queue.shift(); - if (id === undefined) break; - for (const edge of out.get(id) ?? []) { - if (seen.has(edge.to)) continue; - seen.add(edge.to); - const node = byId.get(edge.to); - if (node === undefined) continue; - switch (node.kind) { - case "data-source": - lineage.dataSources.push(node); - break; - case "state": - lineage.state.push(node); - break; - case "event": - lineage.events.push(node); - queue.push(node.id); // events can trigger fetches/state writes - break; - case "hook": - case "component": - lineage.via.push(node); - queue.push(node.id); - break; + const walk = (rootId: string): Reachable => { + const reachable: Reachable = { dataSources: [], state: [], events: [], via: [] }; + const seen = new Set([rootId]); + const queue: string[] = [rootId]; + + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined) break; + for (const edge of out.get(id) ?? []) { + if (seen.has(edge.to)) continue; + seen.add(edge.to); + const node = byId.get(edge.to); + if (node === undefined) continue; + switch (node.kind) { + case "data-source": + reachable.dataSources.push(node); + break; + case "state": + reachable.state.push(node); + break; + case "event": + reachable.events.push(node); + queue.push(node.id); // events can trigger fetches/state writes + break; + case "hook": + case "component": + reachable.via.push(node); + queue.push(node.id); + break; + } } } + + return reachable; + }; + + const reachable = walk(componentId); + const lineage: Lineage = { component: start, ...reachable }; + + // Parents that render this component. When there are two or more, the + // component is shared and a single definition-level trace hides which data + // reaches which render site — so attribute data per parent. + const parents: ComponentNode[] = []; + for (const edge of graph.edges) { + if (edge.kind !== "renders" || edge.to !== componentId) continue; + const parent = byId.get(edge.from); + if (parent !== undefined && parent.kind === "component") parents.push(parent); + } + + if (parents.length >= 2) { + const ownSourceIds = new Set(reachable.dataSources.map((d) => d.id)); + const perInstance = parents + .map((parent) => ({ + parent, + dataSources: walk(parent.id).dataSources.filter((d) => !ownSourceIds.has(d.id)), + })) + .sort((a, b) => a.parent.name.localeCompare(b.parent.name)); + lineage.perInstance = perInstance; } return lineage;