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 ( + + ); +} 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. */