diff --git a/TRACKER.md b/TRACKER.md index b0faab3..8f5ed38 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 2 — Instance graph & cross-file data flow -- **Next step:** 2.4 — Store adapter: writers ↔ readers -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.3 +- **Next step:** 2.5 — Portals, modals, toasts +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.4 - **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 @@ -165,7 +165,7 @@ The heart of the project. C1 and B1 live here. - Unresolvable after 4 levels → `handler: null, flags: ["unresolved-prop-handler"]` — visible, not silent. **Accept:** fixture `b1-prop-drilled-handler` (4 levels) ≥ 0.85 resolution; unresolved cases flagged in graph. -### [ ] 2.4 Store adapter — writers ↔ readers +### [x] 2.4 Store adapter — writers ↔ readers **Failure modes:** C6, B2 (redux/zustand half) **Build:** - Redux/RTK: slice detection (`createSlice`), `useSelector(s => s.users)` → StateNode per slice path; dispatch sites of thunks/actions that carry API results → `writes-state` edges from the data source to the slice. diff --git a/eval/fixtures/c6-store-decoupled/app/CartWidget.tsx b/eval/fixtures/c6-store-decoupled/app/CartWidget.tsx new file mode 100644 index 0000000..e4a0960 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/CartWidget.tsx @@ -0,0 +1,8 @@ +import { useCartStore } from "./store/cartStore"; + +/** Zustand reader — the fetch lives inside the store's own load action. */ +export function CartWidget() { + const items = useCartStore((s) => s.items); + + return {items.length} in cart; +} diff --git a/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx b/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx new file mode 100644 index 0000000..fd84991 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/LoginPage.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; + +import { fetchUsers } from "./store/usersSlice"; + +export function LoginPage() { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchUsers()); + }, [dispatch]); + + return ( +
+

Welcome back

+
+ ); +} diff --git a/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx b/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx new file mode 100644 index 0000000..ccf6299 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx @@ -0,0 +1,17 @@ +import { useSelector } from "react-redux"; + +/** No fetch of its own — the data arrived via the slice, populated at login. */ +export function UserDirectory() { + const users = useSelector((s: { users: { list: string[] } }) => s.users.list); + + return ( +
+

User directory

+ +
+ ); +} diff --git a/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts b/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts new file mode 100644 index 0000000..a2bdea2 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/store/cartStore.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; + +interface CartState { + items: string[]; + load: () => Promise; +} + +export const useCartStore = create((set) => ({ + items: [], + load: async () => { + const res = await fetch("/api/cart"); + set({ items: (await res.json()) as string[] }); + }, +})); diff --git a/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts b/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts new file mode 100644 index 0000000..2b2669f --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts @@ -0,0 +1,17 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; + +export const fetchUsers = createAsyncThunk("users/fetch", async () => { + const res = await fetch("/api/users"); + return res.json(); +}); + +export const usersSlice = createSlice({ + name: "users", + initialState: { list: [] as string[] }, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchUsers.fulfilled, (state, action) => { + state.list = action.payload as string[]; + }); + }, +}); diff --git a/eval/fixtures/c6-store-decoupled/golden.json b/eval/fixtures/c6-store-decoupled/golden.json new file mode 100644 index 0000000..240cd63 --- /dev/null +++ b/eval/fixtures/c6-store-decoupled/golden.json @@ -0,0 +1,32 @@ +{ + "failureMode": "C6", + "note": "Temporal decoupling via stores: UserDirectory renders useSelector(s => s.users.list) and contains NO fetch — the API was called at login via dispatch(fetchUsers()). Attribution must flow reader → slice → writer thunk → /api/users. Zustand variant: CartWidget reads a store whose own load action fetches /api/cart.", + "expect": { + "components": [ + { "name": "LoginPage", "instances": 0 }, + { "name": "UserDirectory", "instances": 0 }, + { "name": "CartWidget", "instances": 0 } + ], + "attributions": [ + { "component": "UserDirectory", "endpoints": ["/api/users"] }, + { "component": "LoginPage", "endpoints": ["/api/users"] }, + { "component": "CartWidget", "endpoints": ["/api/cart"] } + ], + "forbidden": [ + { + "component": "UserDirectory", + "endpoint": "/api/cart", + "note": "poison: unrelated store's API attributed to the redux reader" + }, + { + "component": "CartWidget", + "endpoint": "/api/users", + "note": "poison: redux slice's API attributed to the zustand reader" + } + ], + "queries": [ + { "terms": ["User directory"], "status": "ok", "top": "UserDirectory" }, + { "terms": ["Welcome back"], "status": "ok", "top": "LoginPage" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index 27a667b..0f541a0 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -8,3 +8,4 @@ {"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} +{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1} diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index d4ccb1c..b406037 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -221,6 +221,18 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult void, baseUrls: string[], wrappers: WrapperRegistry, + stores: StoreRegistry, ): void { // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a // data source emitted here would attribute ":path" to every consumer. Call @@ -393,6 +395,29 @@ function extractBodyFacts( for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) { const callee = call.getExpression().getText(); + // Store readers/dispatchers first — useSelector would otherwise fall + // through to the generic per-component state handling. + if (callee === "useSelector") { + const sliceId = selectorSliceId(call, stores); + if (sliceId !== undefined) { + addEdge({ from: ownerId, to: sliceId, kind: "reads-state" }); + continue; + } + } + const zustandId = stores.zustandHooks.get(callee); + if (zustandId !== undefined) { + addEdge({ from: ownerId, to: zustandId, kind: "reads-state" }); + continue; + } + const thunkSources = stores.thunkSources.get(callee); + if (thunkSources !== undefined) { + // dispatch(fetchUsers()) — this component initiates the fetch. + for (const dsId of thunkSources) { + addEdge({ from: ownerId, to: dsId, kind: "fetches-from" }); + } + continue; + } + const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers); if (dataSource !== null) { const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`); @@ -467,6 +492,16 @@ function extractBodyFacts( } } +/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */ +function selectorSliceId(call: CallExpression, stores: StoreRegistry): string | undefined { + const selector = call.getArguments()[0]; + if (selector === undefined || !Node.isArrowFunction(selector)) return undefined; + const param = selector.getParameters()[0]?.getName(); + if (param === undefined) return undefined; + const match = new RegExp(`\\b${param}\\.([A-Za-z_$][\\w$]*)`).exec(selector.getBody().getText()); + return match?.[1] !== undefined ? stores.reduxSlices.get(match[1]) : undefined; +} + type DetectedSource = { sourceKind: DataSourceKind; method: string | null; @@ -652,6 +687,142 @@ function stateVariableName(call: CallExpression): string | null { return null; } +/** Store registry (TRACKER step 2.4, failure mode C6). */ +interface StoreRegistry { + /** Redux slice name → its global StateNode id. */ + reduxSlices: Map; + /** Zustand hook name (useCartStore) → its global StateNode id. */ + zustandHooks: Map; + /** createAsyncThunk name → the data-source node ids its payload creator hits. */ + thunkSources: Map; +} + +/** + * Global stores decouple the reader from the fetch: a component renders + * `useSelector(s => s.users.list)` while the API call that POPULATED the + * slice happened at login, in another component. This pass finds the store + * shapes and wires data-source --writes-state--> slice, so lineage flows + * reader → slice → populating API. + */ +function detectStores( + project: Project, + root: string, + nodes: Map, + addEdge: (edge: LineageEdge) => void, + baseUrls: string[], + wrappers: WrapperRegistry, +): StoreRegistry { + const registry: StoreRegistry = { + reduxSlices: new Map(), + zustandHooks: new Map(), + thunkSources: new Map(), + }; + + const ensureDataSource = (call: Node, file: string): string | null => { + if (!Node.isCallExpression(call)) return null; + const detected = detectDataSource(call, call.getExpression().getText(), baseUrls, wrappers); + if (detected === null || detected.sourceKind === "react-query" || detected.sourceKind === "swr") { + return null; + } + 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, + }); + } + return dsId; + }; + + // Pass 1: slices, zustand stores, thunks. + for (const sourceFile of project.getSourceFiles()) { + const file = toPosix(path.relative(root, sourceFile.getFilePath())); + for (const variable of sourceFile.getVariableDeclarations()) { + const init = variable.getInitializer(); + if (init === undefined || !Node.isCallExpression(init)) continue; + const callee = init.getExpression().getText(); + + if (callee === "createSlice") { + const config = init.getArguments()[0]; + const nameInit = config !== undefined ? propertyInitializer(config, "name") : undefined; + const sliceName = + nameInit !== undefined && Node.isStringLiteral(nameInit) + ? nameInit.getLiteralValue() + : variable.getName(); + const stateId = nodeId("state", file, `slice:${sliceName}`); + if (!nodes.has(stateId)) { + nodes.set(stateId, { + id: stateId, + kind: "state", + name: sliceName, + loc: locOf(variable, file), + stateKind: "redux", + }); + } + registry.reduxSlices.set(sliceName, stateId); + } else if (callee === "createAsyncThunk") { + const payloadCreator = init.getArguments()[1]; + const sources: string[] = []; + if (payloadCreator !== undefined) { + for (const call of payloadCreator.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const dsId = ensureDataSource(call, file); + if (dsId !== null) sources.push(dsId); + } + } + registry.thunkSources.set(variable.getName(), sources); + } else if (callee === "create" && HOOK_NAME.test(variable.getName())) { + // Zustand: const useCartStore = create((set) => ({ ..., load: async () => { fetch...; set(...) } })) + const stateId = nodeId("state", file, `store:${variable.getName()}`); + if (!nodes.has(stateId)) { + nodes.set(stateId, { + id: stateId, + kind: "state", + name: variable.getName(), + loc: locOf(variable, file), + stateKind: "zustand", + }); + } + registry.zustandHooks.set(variable.getName(), stateId); + for (const call of init.getDescendantsOfKind(SyntaxKind.CallExpression)) { + const dsId = ensureDataSource(call, file); + if (dsId !== null) addEdge({ from: dsId, to: stateId, kind: "writes-state" }); + } + } + } + } + + // Pass 2: thunk → slice associations via extraReducers addCase(thunk.fulfilled). + for (const sourceFile of project.getSourceFiles()) { + for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) { + if (!["fulfilled", "rejected", "pending"].includes(access.getName())) continue; + const thunkName = access.getExpression().getText(); + const sources = registry.thunkSources.get(thunkName); + if (sources === undefined || sources.length === 0) continue; + const sliceCall = access.getFirstAncestor( + (a) => Node.isCallExpression(a) && a.getExpression().getText() === "createSlice", + ); + if (sliceCall === undefined || !Node.isCallExpression(sliceCall)) continue; + const config = sliceCall.getArguments()[0]; + const nameInit = config !== undefined ? propertyInitializer(config, "name") : undefined; + if (nameInit === undefined || !Node.isStringLiteral(nameInit)) continue; + const stateId = registry.reduxSlices.get(nameInit.getLiteralValue()); + if (stateId === undefined) continue; + for (const dsId of sources) { + addEdge({ from: dsId, to: stateId, kind: "writes-state" }); + } + } + } + + return registry; +} + const CLASS_COMPONENT_BASE = /(React\.)?(Pure)?Component/; /** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */ @@ -664,6 +835,7 @@ function scanClassComponents( wrappers: WrapperRegistry, localeTable: LocaleTable | null, pendingInstances: PendingInstance[], + stores: StoreRegistry, ): void { for (const cls of sourceFile.getClasses()) { const name = cls.getName(); @@ -705,7 +877,7 @@ function scanClassComponents( }); collectInstanceSites(render, id, file, pendingInstances); // Whole class body: lifecycle fetches (componentDidMount etc.) + render events. - extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers); + extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores); extractClassState(cls, name, id, file, nodes, addEdge); } } diff --git a/packages/parser-react/src/stores.test.ts b/packages/parser-react/src/stores.test.ts new file mode 100644 index 0000000..340be41 --- /dev/null +++ b/packages/parser-react/src/stores.test.ts @@ -0,0 +1,52 @@ +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/c6-store-decoupled/app", +); + +const graph = scanReact({ root: fixture }); + +function endpointsOf(componentName: string): string[] { + const definition = graph.nodes.find( + (n) => n.kind === "component" && n.name === componentName, + ); + if (definition === undefined) throw new Error(`${componentName} not found`); + const lineage = traceLineage(graph, definition.id).candidates[0]?.value; + return lineage?.dataSources.map((d) => d.endpoint).sort() ?? []; +} + +describe("store adapter (c6 fixture)", () => { + it("creates one global StateNode per redux slice and zustand store", () => { + const stateNodes = graph.nodes.filter((n) => n.kind === "state"); + const kinds = stateNodes.map((n) => (n.kind === "state" ? `${n.stateKind}:${n.name}` : "")); + expect(kinds.sort()).toEqual(["redux:users", "zustand:useCartStore"]); + }); + + it("wires thunk data sources to the slice via addCase(thunk.fulfilled)", () => { + const slice = graph.nodes.find((n) => n.kind === "state" && n.name === "users"); + const writers = graph.edges.filter((e) => e.kind === "writes-state" && e.to === slice?.id); + const endpoints = writers.map( + (e) => graph.nodes.find((n) => n.id === e.from && n.kind === "data-source")?.name, + ); + expect(endpoints).toEqual(["/api/users"]); + }); + + it("attributes the reader with NO fetch of its own to the populating API", () => { + expect(endpointsOf("UserDirectory")).toEqual(["/api/users"]); + }); + + it("attributes the dispatching component too", () => { + expect(endpointsOf("LoginPage")).toEqual(["/api/users"]); + }); + + it("keeps zustand and redux stores separate", () => { + expect(endpointsOf("CartWidget")).toEqual(["/api/cart"]); + }); +});