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.2Prop-flow: data attribution per instance
- **Done:** 0.1–0.4, 1.1–1.6, 2.1
- **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)
- **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 @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions eval/fixtures/c1-prop-variants/app/HookPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useMembers } from "./hooks/useMembers";
import { Table } from "./Table";

export function HookPage() {
const { members } = useMembers();

return (
<main>
<h1>Member rows</h1>
<Table rows={members} />
</main>
);
}
15 changes: 15 additions & 0 deletions eval/fixtures/c1-prop-variants/app/QueryPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Billing rows</h1>
<Table rows={data ?? []} />
</main>
);
}
10 changes: 10 additions & 0 deletions eval/fixtures/c1-prop-variants/app/Table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function Table({ rows }: { rows: string[] }) {
if (rows.length === 0) return <p>Nothing to show</p>;
return (
<ul>
{rows.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
);
}
4 changes: 4 additions & 0 deletions eval/fixtures/c1-prop-variants/app/api/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export async function fetchBilling() {
const res = await fetch("/api/billing");
return res.json();
}
11 changes: 11 additions & 0 deletions eval/fixtures/c1-prop-variants/app/hooks/useMembers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useState } from "react";

export function useMembers() {
const [members, setMembers] = useState<string[]>([]);
useEffect(() => {
fetch("/api/members")
.then((res) => res.json())
.then(setMembers);
}, []);
return { members };
}
37 changes: 37 additions & 0 deletions eval/fixtures/c1-prop-variants/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
6 changes: 2 additions & 4 deletions eval/fixtures/c1-shared-datatable/golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
4 changes: 2 additions & 2 deletions eval/thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"maxFail": 0,
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1,
"minLineagePrecision": 0.9,
"minLineageRecall": 0.88
"minLineagePrecision": 0.95,
"minLineageRecall": 0.95
}
41 changes: 41 additions & 0 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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[];
}

/**
Expand Down Expand Up @@ -165,6 +177,35 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult<Linea
const queue: string[] = [startId, component.id];
let edgesWalked = 0;

// Data flowing INTO the traced instance through props (provides-data points
// data-source → instance, so it is invisible to an outgoing-edge walk).
if (instance !== null) {
for (const edge of graph.edges) {
if (edge.kind !== "provides-data" || edge.to !== instance.id) continue;
edgesWalked += 1;
const source = byId.get(edge.from);
if (source !== undefined && source.kind === "data-source" && !seen.has(source.id)) {
seen.add(source.id);
lineage.dataSources.push(source);
}
}
}

// Definition-level trace: report what each call site receives, per instance.
if (instance === null) {
const perInstance: InstanceAttribution[] = [];
for (const node of graph.nodes) {
if (node.kind !== "instance" || node.definitionId !== component.id) continue;
const incoming = graph.edges.flatMap((edge) => {
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;
Expand Down
64 changes: 64 additions & 0 deletions packages/parser-react/src/propflow.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof scanReact>, 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"]);
});
});
Loading
Loading