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). + * + *