Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 2 — Instance graph & cross-file data flow
- **Next step:** 2.3Handler resolution through props
- **Done:** 0.1–0.4, 1.1–1.6, 2.1, 2.2 (headline: per-instance attribution green)
- **Next step:** 2.4Store 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
Expand Down Expand Up @@ -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`).
Expand Down
14 changes: 14 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/app/DraftEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { EditorPanel } from "./EditorPanel";

export function DraftEditor() {
const persistDraft = () => {
fetch("/api/drafts", { method: "POST", body: "{}" });
};

return (
<main>
<h1>Draft editor</h1>
<EditorPanel onPersist={persistDraft} />
</main>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/app/EditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<h2>Editor tools</h2>
<Toolbar onSaveDraft={() => onPersist()} />
</section>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/app/OrphanButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function OrphanButton({ onMystery }: { onMystery?: () => void }) {
return (
<button type="button" onClick={onMystery}>
Mystery action
</button>
);
}

/** Renders OrphanButton without ever passing the handler — must flag, not guess. */
export function OrphanHost() {
return (
<div>
<h3>Orphan host</h3>
<OrphanButton />
</div>
);
}
7 changes: 7 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/app/SaveButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function SaveButton({ onSave }: { onSave: () => void }) {
return (
<button type="button" onClick={onSave}>
Save draft
</button>
);
}
10 changes: 10 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/app/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="toolbar">
<SaveButton onSave={onSaveDraft} />
</div>
);
}
29 changes: 29 additions & 0 deletions eval/fixtures/b1-prop-drilled-handler/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
43 changes: 43 additions & 0 deletions packages/parser-react/src/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
207 changes: 207 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1108,6 +1109,212 @@ function resolvePropFlow(
}
}

/**
* Handler resolution through props (TRACKER step 2.3, failure mode B1).
*
* <button onClick={onSave}> where onSave is a PROP means the real handler
* lives at a call site — possibly four components up:
*
* SaveButton onClick={onSave} ← Toolbar onSave={onSaveDraft}
* ← EditorPanel onSaveDraft={() => onPersist()} ← DraftEditor onPersist={persistDraft}
* → persistDraft body → fetch POST /api/drafts
*
* Resolved handler bodies are mined for data sources, emitted as
* event --triggers--> data-source edges (via: the instance id whose call
* chain produced them). Chains dead after MAX_HANDLER_DEPTH levels flag the
* event "unresolved-prop-handler" — visible, never silent.
*/
const MAX_HANDLER_DEPTH = 4;

function resolveHandlerChains(
pendingInstances: PendingInstance[],
instanceIds: Array<string | null>,
nodes: Map<string, LineageNode>,
edges: LineageEdge[],
addEdge: (edge: LineageEdge) => void,
baseUrls: string[],
wrappers: WrapperRegistry,
root: string,
): void {
const instancesByDefinition = new Map<string, Array<{ pending: PendingInstance; id: string }>>();
for (const [index, pending] of pendingInstances.entries()) {
const id = instanceIds[index];
if (id === null || id === undefined) continue;
const instance = nodes.get(id);
if (instance === undefined || instance.kind !== "instance") continue;
const list = instancesByDefinition.get(instance.definitionId);
if (list) list.push({ pending, id });
else instancesByDefinition.set(instance.definitionId, [{ pending, id }]);
}

const fileOf = (node: Node): string =>
toPosix(path.relative(root, node.getSourceFile().getFilePath()));

// Expression-bodied arrows (`() => onPersist()`) ARE the call — descendants
// alone would miss them.
const callsWithin = (body: Node): Node[] => [
...(Node.isCallExpression(body) ? [body] : []),
...body.getDescendantsOfKind(SyntaxKind.CallExpression),
];

/** Collect data-source ids from a handler body, creating nodes as needed. */
const minePropHandlerBody = (body: Node, sources: Set<string>): void => {
for (const call of callsWithin(body)) {
if (!Node.isCallExpression(call)) continue;
const detected = detectDataSource(call, call.getExpression().getText(), baseUrls, wrappers);
if (detected === null) continue;
const file = fileOf(call);
const dsId = nodeId("data-source", file, `${detected.sourceKind}:${detected.endpoint}`);
if (!nodes.has(dsId)) {
nodes.set(dsId, {
id: dsId,
kind: "data-source",
name: detected.endpoint,
loc: locOf(call, file),
sourceKind: detected.sourceKind,
method: detected.method,
endpoint: detected.endpoint,
raw: detected.raw,
resolved: detected.resolved,
});
}
sources.add(dsId);
}
};

/** The prop's property name + default initializer on a definition's params. */
const propBinding = (
definitionId: string,
localName: string,
): { propertyName: string; defaultInit: Node | undefined } | null => {
const definition = nodes.get(definitionId);
if (definition === undefined || definition.kind !== "component") return null;
if (!definition.props.includes(localName)) return null;
// Re-locate the AST: find the binding element for the local name.
const anyInstance = instancesByDefinition.get(definitionId)?.[0];
const sourceFile =
anyInstance?.pending.element.getSourceFile().getProject().getSourceFiles()
.find((f) => toPosix(path.relative(root, f.getFilePath())) === definition.loc.file) ??
undefined;
if (sourceFile === undefined) return { propertyName: localName, defaultInit: undefined };
for (const binding of sourceFile.getDescendantsOfKind(SyntaxKind.BindingElement)) {
if (binding.getName() !== localName) continue;
return {
propertyName: binding.getPropertyNameNode()?.getText() ?? localName,
defaultInit: binding.getInitializer(),
};
}
return { propertyName: localName, defaultInit: undefined };
};

/** Resolve a handler expression at a call site; returns whether anything grounded. */
const resolveExpr = (
expr: Node,
ownerDefinitionId: string,
depth: number,
sources: Set<string>,
): boolean => {
if (depth > MAX_HANDLER_DEPTH) return false;

if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
const body = expr.getBody();
if (body === undefined) return false;
minePropHandlerBody(body, sources);
let grounded = sources.size > 0;
// Arrow wrapping a prop call: () => onPersist()
for (const call of callsWithin(body)) {
if (!Node.isCallExpression(call)) continue;
const callee = call.getExpression();
const calleeName = Node.isPropertyAccessExpression(callee)
? callee.getExpression().getText().endsWith("props")
? callee.getName()
: null
: Node.isIdentifier(callee)
? callee.getText()
: null;
if (calleeName !== null && resolveChain(ownerDefinitionId, calleeName, depth + 1, sources)) {
grounded = true;
}
}
return grounded;
}

if (Node.isPropertyAccessExpression(expr) && expr.getExpression().getText().endsWith("props")) {
return resolveChain(ownerDefinitionId, expr.getName(), depth + 1, sources);
}

if (Node.isIdentifier(expr)) {
for (const definition of expr.getDefinitionNodes()) {
if (Node.isFunctionDeclaration(definition)) {
const body = definition.getBody();
if (body !== undefined) {
minePropHandlerBody(body, sources);
return true;
}
}
if (Node.isVariableDeclaration(definition)) {
const init = definition.getInitializer();
if (init !== undefined && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
return resolveExpr(init, ownerDefinitionId, depth, sources);
}
}
if (Node.isBindingElement(definition) || Node.isParameterDeclaration(definition)) {
// The handler is itself a prop of the owner — continue up the tree.
return resolveChain(ownerDefinitionId, expr.getText(), depth + 1, sources);
}
}
}
return false;
};

/** Follow a prop named `localName` on `definitionId` up through its call sites. */
const resolveChain = (
definitionId: string,
localName: string,
depth: number,
sources: Set<string>,
): boolean => {
if (depth > MAX_HANDLER_DEPTH) return false;
const binding = propBinding(definitionId, localName);
if (binding === null) return false;
let grounded = false;
for (const { pending } of instancesByDefinition.get(definitionId) ?? []) {
let expr: Node | undefined = binding.defaultInit;
for (const attr of pending.element.getAttributes()) {
if (!Node.isJsxAttribute(attr) || attr.getNameNode().getText() !== binding.propertyName) {
continue;
}
const init = attr.getInitializer();
expr = init !== undefined && Node.isJsxExpression(init) ? init.getExpression() : undefined;
break;
}
if (expr === undefined) continue;
if (resolveExpr(expr, pending.ownerId, depth, sources)) grounded = true;
}
return grounded;
};

// Events whose handler is a prop of their own component: resolve the chain.
for (const edge of [...edges]) {
if (edge.kind !== "handles") continue;
const event = nodes.get(edge.to);
const owner = nodes.get(edge.from);
if (event === undefined || event.kind !== "event" || event.handler === null) continue;
if (owner === undefined || owner.kind !== "component") continue;
const bareName = event.handler.replace(/^(this\.)?props\./, "");
if (!owner.props.includes(bareName)) continue;

const sources = new Set<string>();
const grounded = resolveChain(owner.id, bareName, 1, sources);
for (const dsId of sources) {
addEdge({ from: event.id, to: dsId, kind: "triggers" });
}
if (!grounded) {
event.flags = [...(event.flags ?? []), "unresolved-prop-handler"];
}
}
}

/** Rewrite `unresolved-hook:<name>` placeholders to real hook node ids; drop misses. */
export function resolveHookEdges(graph: LineageGraph): LineageGraph {
const hooksByName = new Map<string, string>();
Expand Down
Loading