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
+
+ {users.map((u) => (
+ - {u}
+ ))}
+
+
+ );
+}
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"]);
+ });
+});