diff --git a/TRACKER.md b/TRACKER.md
index e4b51be..b0faab3 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 2 — Instance graph & cross-file data flow
-- **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)
+- **Next step:** 2.4 — Store adapter: writers ↔ readers
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.3
- **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
@@ -157,7 +157,7 @@ The heart of the project. C1 and B1 live here.
- Depth limit (default 5 hops) with `flags: ["depth-limited"]` when hit.
**Accept:** **c1-shared-datatable attribution assertions flip from expected-fail to green** — DataTable@Users → `/api/users`, DataTable@Invoices → `/api/invoices`, zero `forbidden` hits. Instance attribution accuracy = 1.0 on C1 fixtures.
-### [ ] 2.3 Handler resolution through props
+### [x] 2.3 Handler resolution through props
**Failure modes:** B1
**Build:** same machinery, callback direction:
- `onClick={props.onSave}` → resolve `onSave` at each parent instance → the passed function → its body (which Phase 3 mines for effects). Chain recorded as evidence (`edge-chain`).
diff --git a/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx b/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx
new file mode 100644
index 0000000..dc9113a
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx
@@ -0,0 +1,14 @@
+import { EditorPanel } from "./EditorPanel";
+
+export function DraftEditor() {
+ const persistDraft = () => {
+ fetch("/api/drafts", { method: "POST", body: "{}" });
+ };
+
+ return (
+
+ Draft editor
+
+
+ );
+}
diff --git a/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx b/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx
new file mode 100644
index 0000000..7f1e184
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx
@@ -0,0 +1,11 @@
+import { Toolbar } from "./Toolbar";
+
+/** Inline-arrow hop: the prop is wrapped, not passed directly. */
+export function EditorPanel({ onPersist }: { onPersist: () => void }) {
+ return (
+
+ Editor tools
+ onPersist()} />
+
+ );
+}
diff --git a/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx b/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx
new file mode 100644
index 0000000..6378e1b
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx
@@ -0,0 +1,17 @@
+export function OrphanButton({ onMystery }: { onMystery?: () => void }) {
+ return (
+
+ );
+}
+
+/** Renders OrphanButton without ever passing the handler — must flag, not guess. */
+export function OrphanHost() {
+ return (
+
+
Orphan host
+
+
+ );
+}
diff --git a/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx b/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx
new file mode 100644
index 0000000..7e6b183
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx
@@ -0,0 +1,7 @@
+export function SaveButton({ onSave }: { onSave: () => void }) {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx b/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx
new file mode 100644
index 0000000..4e79469
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx
@@ -0,0 +1,10 @@
+import { SaveButton } from "./SaveButton";
+
+/** Renamed hop: the prop arrives as onSaveDraft, is passed down as onSave. */
+export function Toolbar({ onSaveDraft }: { onSaveDraft: () => void }) {
+ return (
+
+
+
+ );
+}
diff --git a/eval/fixtures/b1-prop-drilled-handler/golden.json b/eval/fixtures/b1-prop-drilled-handler/golden.json
new file mode 100644
index 0000000..f6c752f
--- /dev/null
+++ b/eval/fixtures/b1-prop-drilled-handler/golden.json
@@ -0,0 +1,29 @@
+{
+ "failureMode": "B1",
+ "note": "Callback prop-drilling, 4 levels: SaveButton.onClick={onSave} <- Toolbar (renamed prop) <- EditorPanel (inline arrow wrap) <- DraftEditor (real handler: fetch POST /api/drafts). Per-file analysis stops at the prop boundary; chain resolution must ground the event in the fetch. OrphanButton's never-passed handler must flag unresolved, not guess.",
+ "expect": {
+ "components": [
+ { "name": "SaveButton", "instances": 1 },
+ { "name": "Toolbar", "instances": 1 },
+ { "name": "EditorPanel", "instances": 1 },
+ { "name": "DraftEditor", "instances": 0 },
+ { "name": "OrphanButton", "instances": 1 }
+ ],
+ "attributions": [
+ { "component": "SaveButton", "endpoints": ["/api/drafts"] },
+ { "component": "OrphanButton", "endpoints": [] }
+ ],
+ "forbidden": [
+ {
+ "component": "OrphanButton",
+ "endpoint": "/api/drafts",
+ "note": "poison: an unrelated chain's endpoint attributed to the orphan"
+ }
+ ],
+ "queries": [
+ { "terms": ["Save draft"], "status": "ok", "top": "SaveButton" },
+ { "terms": ["Draft editor"], "status": "ok", "top": "DraftEditor" },
+ { "terms": ["Mystery action"], "status": "ok", "top": "OrphanButton" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index 332b128..27a667b 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -7,3 +7,4 @@
{"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}
+{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
diff --git a/packages/parser-react/src/handlers.test.ts b/packages/parser-react/src/handlers.test.ts
new file mode 100644
index 0000000..d030cf3
--- /dev/null
+++ b/packages/parser-react/src/handlers.test.ts
@@ -0,0 +1,43 @@
+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 fixture = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures/b1-prop-drilled-handler/app",
+);
+
+const graph = scanReact({ root: fixture });
+
+describe("handler resolution through props (b1 fixture)", () => {
+ it("grounds a 4-level drilled handler in its fetch", () => {
+ const event = graph.nodes.find(
+ (n) => n.kind === "event" && n.handler === "onSave",
+ );
+ if (event === undefined) throw new Error("SaveButton onClick event not found");
+ const triggers = graph.edges.filter((e) => e.kind === "triggers" && e.from === event.id);
+ expect(triggers).toHaveLength(1);
+ const target = graph.nodes.find((n) => n.id === triggers[0]?.to);
+ expect(target?.kind === "data-source" ? `${target.method} ${target.endpoint}` : "missing").toBe(
+ "POST /api/drafts",
+ );
+ });
+
+ it("makes the endpoint reachable from SaveButton's lineage", () => {
+ const definition = graph.nodes.find((n) => n.kind === "component" && n.name === "SaveButton");
+ if (definition === undefined) throw new Error("SaveButton not found");
+ const lineage = traceLineage(graph, definition.id).candidates[0]?.value;
+ expect(lineage?.dataSources.map((d) => d.endpoint)).toEqual(["/api/drafts"]);
+ });
+
+ it("flags never-passed handlers as unresolved instead of guessing", () => {
+ const event = graph.nodes.find((n) => n.kind === "event" && n.handler === "onMystery");
+ if (event === undefined) throw new Error("OrphanButton onClick event not found");
+ expect(event.flags).toContain("unresolved-prop-handler");
+ expect(graph.edges.some((e) => e.kind === "triggers" && e.from === event.id)).toBe(false);
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index f4fad36..f080a6b 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -177,6 +177,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
root,
);
resolvePropFlow(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers);
+ resolveHandlerChains(pendingInstances, instanceIds, nodes, edges, addEdge, baseUrls, wrappers, root);
return {
version: 2,
@@ -1108,6 +1109,212 @@ function resolvePropFlow(
}
}
+/**
+ * Handler resolution through props (TRACKER step 2.3, failure mode B1).
+ *
+ *