diff --git a/TRACKER.md b/TRACKER.md
index 4411ae0..e4b51be 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 2 — Instance graph & cross-file data flow
-- **Next step:** 2.2 — Prop-flow: data attribution per instance
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1
+- **Next step:** 2.3 — Handler resolution through props
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1, 2.2 (headline: per-instance attribution green)
- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) · Gate 1 (precision 1.000 ≥ 0.90, recall 0.895 ≥ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits)
## What CodeRadar is
@@ -148,7 +148,7 @@ The heart of the project. C1 and B1 live here.
- Design-system components (imported from `node_modules` or a configured `designSystemPackages` list): instances are still created, flagged `external-definition` — the instance is ours even when the definition isn't.
**Accept:** fixture `a5-design-system` green (match resolves to the usage site); instance counts asserted in c1 golden; barrel-file resolution unit-tested.
-### [ ] 2.2 Prop-flow: data attribution per instance
+### [x] 2.2 Prop-flow: data attribution per instance
**Failure modes:** C1 (the headline)
**Build:**
- For each instance, connect prop values to their origins in the parent scope: identifier props trace back through variable declarations to hook results / fetch results / store reads within the parent.
diff --git a/eval/fixtures/c1-prop-variants/app/HookPage.tsx b/eval/fixtures/c1-prop-variants/app/HookPage.tsx
new file mode 100644
index 0000000..209b62d
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/app/HookPage.tsx
@@ -0,0 +1,13 @@
+import { useMembers } from "./hooks/useMembers";
+import { Table } from "./Table";
+
+export function HookPage() {
+ const { members } = useMembers();
+
+ return (
+
+ Member rows
+
+
+ );
+}
diff --git a/eval/fixtures/c1-prop-variants/app/QueryPage.tsx b/eval/fixtures/c1-prop-variants/app/QueryPage.tsx
new file mode 100644
index 0000000..5fee0fc
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/app/QueryPage.tsx
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { fetchBilling } from "./api/billing";
+import { Table } from "./Table";
+
+export function QueryPage() {
+ const { data } = useQuery({ queryKey: ["billing"], queryFn: fetchBilling });
+
+ return (
+
+ Billing rows
+
+
+ );
+}
diff --git a/eval/fixtures/c1-prop-variants/app/Table.tsx b/eval/fixtures/c1-prop-variants/app/Table.tsx
new file mode 100644
index 0000000..49e02ba
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/app/Table.tsx
@@ -0,0 +1,10 @@
+export function Table({ rows }: { rows: string[] }) {
+ if (rows.length === 0) return
Nothing to show
;
+ return (
+
+ {rows.map((r) => (
+ - {r}
+ ))}
+
+ );
+}
diff --git a/eval/fixtures/c1-prop-variants/app/api/billing.ts b/eval/fixtures/c1-prop-variants/app/api/billing.ts
new file mode 100644
index 0000000..b069500
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/app/api/billing.ts
@@ -0,0 +1,4 @@
+export async function fetchBilling() {
+ const res = await fetch("/api/billing");
+ return res.json();
+}
diff --git a/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts b/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts
new file mode 100644
index 0000000..36b368b
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from "react";
+
+export function useMembers() {
+ const [members, setMembers] = useState([]);
+ useEffect(() => {
+ fetch("/api/members")
+ .then((res) => res.json())
+ .then(setMembers);
+ }, []);
+ return { members };
+}
diff --git a/eval/fixtures/c1-prop-variants/golden.json b/eval/fixtures/c1-prop-variants/golden.json
new file mode 100644
index 0000000..e9602b2
--- /dev/null
+++ b/eval/fixtures/c1-prop-variants/golden.json
@@ -0,0 +1,37 @@
+{
+ "failureMode": "C1",
+ "note": "Prop-origin variants beyond the useState/useEffect pattern: a react-query result prop (with ?? [] derivation) and a custom-hook result prop. The same Table component must carry a different API per page.",
+ "expect": {
+ "components": [{ "name": "Table", "instances": 2 }],
+ "attributions": [
+ {
+ "component": "Table",
+ "instanceAt": "QueryPage.tsx",
+ "endpoints": ["/api/billing"]
+ },
+ {
+ "component": "Table",
+ "instanceAt": "HookPage.tsx",
+ "endpoints": ["/api/members"]
+ }
+ ],
+ "forbidden": [
+ {
+ "component": "Table",
+ "instanceAt": "QueryPage.tsx",
+ "endpoint": "/api/members",
+ "note": "poison: hook-page API attributed to the query-page table"
+ },
+ {
+ "component": "Table",
+ "instanceAt": "HookPage.tsx",
+ "endpoint": "/api/billing",
+ "note": "poison: query-page API attributed to the hook-page table"
+ }
+ ],
+ "queries": [
+ { "terms": ["Billing rows"], "status": "ok", "top": "QueryPage" },
+ { "terms": ["Member rows"], "status": "ok", "top": "HookPage" }
+ ]
+ }
+}
diff --git a/eval/fixtures/c1-shared-datatable/golden.json b/eval/fixtures/c1-shared-datatable/golden.json
index 04424b9..5704b1d 100644
--- a/eval/fixtures/c1-shared-datatable/golden.json
+++ b/eval/fixtures/c1-shared-datatable/golden.json
@@ -11,14 +11,12 @@
{
"component": "DataTable",
"instanceAt": "pages/UsersPage.tsx",
- "endpoints": ["/api/users"],
- "expectedFail": "phase-2: per-instance attribution requires prop-flow (step 2.2)"
+ "endpoints": ["/api/users"]
},
{
"component": "DataTable",
"instanceAt": "pages/InvoicesPage.tsx",
- "endpoints": ["/api/invoices"],
- "expectedFail": "phase-2: per-instance attribution requires prop-flow (step 2.2)"
+ "endpoints": ["/api/invoices"]
},
{
"component": "UsersPage",
diff --git a/eval/history.jsonl b/eval/history.jsonl
index a052df0..332b128 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -6,3 +6,4 @@
{"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:04:05.130Z","commitSha":"d13b90dd99c993889f57218997984e3e2e601cfd","pass":91,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:10:56.348Z","commitSha":"b628c816231c4896f030ebc68a6621d0963d183c","pass":99,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
+{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
diff --git a/eval/thresholds.json b/eval/thresholds.json
index b7ecceb..6233934 100644
--- a/eval/thresholds.json
+++ b/eval/thresholds.json
@@ -2,6 +2,6 @@
"maxFail": 0,
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1,
- "minLineagePrecision": 0.9,
- "minLineageRecall": 0.88
+ "minLineagePrecision": 0.95,
+ "minLineageRecall": 0.95
}
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 9210eb1..d4ccb1c 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -111,6 +111,12 @@ export function matchComponentsByText(
return ok(candidates);
}
+export interface InstanceAttribution {
+ instance: InstanceNode;
+ /** Data flowing INTO this call site through props (provides-data edges). */
+ dataSources: DataSourceNode[];
+}
+
export interface Lineage {
component: ComponentNode;
/** The instance the trace started from, when an instance id was given. */
@@ -120,6 +126,12 @@ export interface Lineage {
events: EventNode[];
/** Hooks, instances, and child components on the path, in discovery order. */
via: LineageNode[];
+ /**
+ * Definition-level traces only: what each call site receives, kept SEPARATE
+ * per instance — never merged, because merging is exactly the C1 poison
+ * (the users-page table would appear to consume the invoices API).
+ */
+ perInstance?: InstanceAttribution[];
}
/**
@@ -165,6 +177,35 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult {
+ if (edge.kind !== "provides-data" || edge.to !== node.id) return [];
+ const source = byId.get(edge.from);
+ return source !== undefined && source.kind === "data-source" ? [source] : [];
+ });
+ perInstance.push({ instance: node, dataSources: incoming });
+ }
+ if (perInstance.length > 0) lineage.perInstance = perInstance;
+ }
+
while (queue.length > 0) {
const currentId = queue.shift();
if (currentId === undefined) break;
diff --git a/packages/parser-react/src/propflow.test.ts b/packages/parser-react/src/propflow.test.ts
new file mode 100644
index 0000000..490f8ea
--- /dev/null
+++ b/packages/parser-react/src/propflow.test.ts
@@ -0,0 +1,64 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { traceLineage } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { scanReact } from "./scan.js";
+
+const fixtures = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures",
+);
+
+function instanceEndpoints(graph: ReturnType, name: string, file: string) {
+ const instance = graph.nodes.find(
+ (n) => n.kind === "instance" && n.name === name && n.loc.file === file,
+ );
+ if (instance === undefined) throw new Error(`instance ${name}@${file} not found`);
+ const lineage = traceLineage(graph, instance.id).candidates[0]?.value;
+ return lineage?.dataSources.map((d) => d.endpoint).sort() ?? [];
+}
+
+describe("prop-flow: the C1 headline case", () => {
+ const graph = scanReact({ root: path.join(fixtures, "c1-shared-datatable/app") });
+
+ it("attributes the users API to the users-page table only", () => {
+ expect(instanceEndpoints(graph, "DataTable", "pages/UsersPage.tsx")).toEqual(["/api/users"]);
+ });
+
+ it("attributes the invoices API to the invoices-page table only", () => {
+ expect(instanceEndpoints(graph, "DataTable", "pages/InvoicesPage.tsx")).toEqual([
+ "/api/invoices",
+ ]);
+ });
+
+ it("keeps the definition-level trace unmerged (perInstance breakdown)", () => {
+ const definition = graph.nodes.find((n) => n.kind === "component" && n.name === "DataTable");
+ if (definition === undefined) throw new Error("DataTable not found");
+ const lineage = traceLineage(graph, definition.id).candidates[0]?.value;
+ // The definition itself fetches nothing — the per-instance breakdown carries it.
+ expect(lineage?.dataSources).toEqual([]);
+ const breakdown = lineage?.perInstance?.map((p) => ({
+ at: p.instance.loc.file,
+ endpoints: p.dataSources.map((d) => d.endpoint),
+ }));
+ expect(breakdown).toContainEqual({ at: "pages/UsersPage.tsx", endpoints: ["/api/users"] });
+ expect(breakdown).toContainEqual({
+ at: "pages/InvoicesPage.tsx",
+ endpoints: ["/api/invoices"],
+ });
+ });
+});
+
+describe("prop-flow: origin variants", () => {
+ const graph = scanReact({ root: path.join(fixtures, "c1-prop-variants/app") });
+
+ it("traces a react-query result prop (through ?? derivation)", () => {
+ expect(instanceEndpoints(graph, "Table", "QueryPage.tsx")).toEqual(["/api/billing"]);
+ });
+
+ it("traces a custom-hook result prop to the hook's fetch", () => {
+ expect(instanceEndpoints(graph, "Table", "HookPage.tsx")).toEqual(["/api/members"]);
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 18f2fb7..f4fad36 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -168,7 +168,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
collectHocAliases(sourceFile, hocAliases);
}
- materializeInstances(
+ const instanceIds = materializeInstances(
pendingInstances,
nodes,
addEdge,
@@ -176,6 +176,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
options.designSystemPackages ?? [],
root,
);
+ resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers);
return {
version: 2,
@@ -855,7 +856,7 @@ function materializeInstances(
hocAliases: ReadonlyMap,
designSystemPackages: string[],
root: string,
-): void {
+): Array {
const definitionsByName = new Map();
for (const node of nodes.values()) {
if (node.kind === "component") definitionsByName.set(node.name, node.id);
@@ -966,6 +967,145 @@ function materializeInstances(
instance.parentInstanceId = parentId;
}
}
+
+ return idByIndex;
+}
+
+/**
+ * Prop-flow (TRACKER step 2.2 — failure mode C1, the headline case).
+ *
+ * For each instance, trace expression props back to the data that populates
+ * them in the parent scope and emit data-source --provides-data--> instance
+ * edges. This is what makes on the Users page carry
+ * /api/users while the identical component on the Invoices page carries
+ * /api/invoices.
+ *
+ * Origins handled (depth-capped):
+ * - useState pairs: `const [rows, setRows] = useState()` → every setRows call
+ * site → a data-source call in the same statement (`fetch(...).then(setRows)`)
+ * - direct results: `const { data } = useQuery(...)` / `const r = useApi("/x")`
+ * - project hooks: `const { users } = useUsers()` → the hook's fetches-from edges
+ * - simple derivations: `const rows = raw.items` → recurse on `raw`
+ */
+function resolvePropFlow(
+ pendingInstances: PendingInstance[],
+ instanceIds: Array,
+ nodes: Map,
+ edges: LineageEdge[],
+ addEdge: (edge: LineageEdge) => void,
+ baseUrls: string[],
+ wrappers: WrapperRegistry,
+): void {
+ const hooksByName = new Map();
+ for (const node of nodes.values()) {
+ if (node.kind === "hook") hooksByName.set(node.name, node.id);
+ }
+
+ const hookSources = (hookId: string): string[] =>
+ edges.flatMap((e) => (e.from === hookId && e.kind === "fetches-from" ? [e.to] : []));
+
+ /** Data-source node ids that populate `expr` in its scope. */
+ const originsOf = (expr: Node, file: string, depth: number): Set => {
+ const origins = new Set();
+ if (depth > 3) return origins;
+
+ const fromCall = (call: Node): void => {
+ if (!Node.isCallExpression(call)) return;
+ const callee = call.getExpression().getText();
+ const hookId = hooksByName.get(callee);
+ if (hookId !== undefined) {
+ for (const dsId of hookSources(hookId)) origins.add(dsId);
+ return;
+ }
+ const source = detectDataSource(call, callee, baseUrls, wrappers);
+ if (source !== null) {
+ origins.add(nodeId("data-source", file, `${source.sourceKind}:${source.endpoint}`));
+ }
+ };
+
+ if (!Node.isIdentifier(expr)) {
+ // Derived expressions: recurse on each identifier inside (raw.items → raw).
+ for (const identifier of expr.getDescendantsOfKind(SyntaxKind.Identifier)) {
+ for (const origin of originsOf(identifier, file, depth + 1)) origins.add(origin);
+ }
+ return origins;
+ }
+
+ for (const definition of expr.getDefinitionNodes()) {
+ let varDecl: Node | undefined = definition;
+ if (Node.isBindingElement(varDecl)) {
+ varDecl = varDecl.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
+ }
+ if (varDecl === undefined || !Node.isVariableDeclaration(varDecl)) continue;
+
+ const init = varDecl.getInitializer();
+ if (init !== undefined) {
+ // Direct result: const { data } = useQuery(...) / const r = useApi("/x")
+ // — including calls wrapped in await / as-casts.
+ fromCall(init);
+ if (origins.size === 0) {
+ for (const inner of init.getDescendantsOfKind(SyntaxKind.CallExpression)) fromCall(inner);
+ }
+ }
+
+ // useState pair: find the setter and every statement that calls it.
+ const binding = varDecl.getNameNode();
+ if (
+ Node.isArrayBindingPattern(binding) &&
+ init !== undefined &&
+ Node.isCallExpression(init) &&
+ init.getExpression().getText() === "useState"
+ ) {
+ const setterElement = binding.getElements()[1];
+ if (setterElement !== undefined && Node.isBindingElement(setterElement)) {
+ const setterName = setterElement.getName();
+ const scope = varDecl.getFirstAncestor(
+ (a) => Node.isArrowFunction(a) || Node.isFunctionExpression(a) || Node.isFunctionDeclaration(a) || Node.isClassDeclaration(a),
+ );
+ for (const ref of (scope ?? varDecl.getSourceFile()).getDescendantsOfKind(
+ SyntaxKind.Identifier,
+ )) {
+ if (ref.getText() !== setterName || ref === setterElement.getNameNode()) continue;
+ const statement = ref.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
+ if (statement === undefined) continue;
+ for (const call of statement.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ fromCall(call);
+ }
+ }
+ }
+ }
+
+ // Derivation: const rows = raw.items ?? [] → recurse on raw.
+ if (origins.size === 0 && init !== undefined && !Node.isCallExpression(init)) {
+ for (const identifier of init.getDescendantsOfKind(SyntaxKind.Identifier)) {
+ if (identifier.getText() === expr.getText()) continue;
+ for (const origin of originsOf(identifier, file, depth + 1)) origins.add(origin);
+ }
+ }
+ }
+ return origins;
+ };
+
+ for (const [index, pending] of pendingInstances.entries()) {
+ const instanceId = instanceIds[index];
+ if (instanceId === null || instanceId === undefined) continue;
+ for (const attr of pending.element.getAttributes()) {
+ if (!Node.isJsxAttribute(attr)) continue;
+ const init = attr.getInitializer();
+ if (init === undefined || !Node.isJsxExpression(init)) continue;
+ const expr = init.getExpression();
+ if (expr === undefined) continue;
+ for (const dsId of originsOf(expr, pending.file, 0)) {
+ if (!nodes.has(dsId)) continue;
+ addEdge({
+ from: dsId,
+ to: instanceId,
+ kind: "provides-data",
+ via: attr.getNameNode().getText(),
+ });
+ }
+ }
+ }
}
/** Rewrite `unresolved-hook:` placeholders to real hook node ids; drop misses. */