Skip to content

Commit 55aa975

Browse files
DeepCodeWorkclaude
andcommitted
feat: store adapter — redux/zustand writers ↔ readers (C6)
Step 2.4 (TRACKER). Failure mode C6, temporal decoupling: - detectStores: createSlice → one global StateNode per slice (stateKind redux); createAsyncThunk payload creators mined for data sources; extraReducers addCase(thunk.fulfilled/rejected/pending) associates thunk sources to their slice via data-source --writes-state--> slice edges. Zustand: create() stores named use* become StateNodes with their internal fetches wired as writers. - Readers: useSelector((s) => s.users.list) parses the slice path and links reads-state to the GLOBAL slice node (falling back to the old per-component node when no slice matches); zustand hook calls link to their store node; dispatch(fetchUsers()) gives the dispatching component a fetches-from edge. - traceLineage: visiting a StateNode now follows incoming writes-state edges back to the populating data sources — reader → slice → login-time API. - Fixture c6-store-decoupled: UserDirectory (NO fetch of its own) → /api/users; CartWidget → /api/cart; cross-store poison assertions. - Fixed a declaration-order bug (detectStores called before addEdge existed). - Scorecard: 129 pass / 0 fail; precision/recall 1.000. 85 unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c01687 commit 55aa975

11 files changed

Lines changed: 349 additions & 6 deletions

File tree

TRACKER.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 2 — Instance graph & cross-file data flow
8-
- **Next step:** 2.4Store adapter: writers ↔ readers
9-
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.3
8+
- **Next step:** 2.5Portals, modals, toasts
9+
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.4
1010
- **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)
1111

1212
## What CodeRadar is
@@ -165,7 +165,7 @@ The heart of the project. C1 and B1 live here.
165165
- Unresolvable after 4 levels → `handler: null, flags: ["unresolved-prop-handler"]` — visible, not silent.
166166
**Accept:** fixture `b1-prop-drilled-handler` (4 levels) ≥ 0.85 resolution; unresolved cases flagged in graph.
167167

168-
### [ ] 2.4 Store adapter — writers ↔ readers
168+
### [x] 2.4 Store adapter — writers ↔ readers
169169
**Failure modes:** C6, B2 (redux/zustand half)
170170
**Build:**
171171
- 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.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useCartStore } from "./store/cartStore";
2+
3+
/** Zustand reader — the fetch lives inside the store's own load action. */
4+
export function CartWidget() {
5+
const items = useCartStore((s) => s.items);
6+
7+
return <span title="Cart items">{items.length} in cart</span>;
8+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect } from "react";
2+
import { useDispatch } from "react-redux";
3+
4+
import { fetchUsers } from "./store/usersSlice";
5+
6+
export function LoginPage() {
7+
const dispatch = useDispatch();
8+
9+
useEffect(() => {
10+
dispatch(fetchUsers());
11+
}, [dispatch]);
12+
13+
return (
14+
<main>
15+
<h1>Welcome back</h1>
16+
</main>
17+
);
18+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useSelector } from "react-redux";
2+
3+
/** No fetch of its own — the data arrived via the slice, populated at login. */
4+
export function UserDirectory() {
5+
const users = useSelector((s: { users: { list: string[] } }) => s.users.list);
6+
7+
return (
8+
<section>
9+
<h2>User directory</h2>
10+
<ul>
11+
{users.map((u) => (
12+
<li key={u}>{u}</li>
13+
))}
14+
</ul>
15+
</section>
16+
);
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { create } from "zustand";
2+
3+
interface CartState {
4+
items: string[];
5+
load: () => Promise<void>;
6+
}
7+
8+
export const useCartStore = create<CartState>((set) => ({
9+
items: [],
10+
load: async () => {
11+
const res = await fetch("/api/cart");
12+
set({ items: (await res.json()) as string[] });
13+
},
14+
}));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2+
3+
export const fetchUsers = createAsyncThunk("users/fetch", async () => {
4+
const res = await fetch("/api/users");
5+
return res.json();
6+
});
7+
8+
export const usersSlice = createSlice({
9+
name: "users",
10+
initialState: { list: [] as string[] },
11+
reducers: {},
12+
extraReducers: (builder) => {
13+
builder.addCase(fetchUsers.fulfilled, (state, action) => {
14+
state.list = action.payload as string[];
15+
});
16+
},
17+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"failureMode": "C6",
3+
"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.",
4+
"expect": {
5+
"components": [
6+
{ "name": "LoginPage", "instances": 0 },
7+
{ "name": "UserDirectory", "instances": 0 },
8+
{ "name": "CartWidget", "instances": 0 }
9+
],
10+
"attributions": [
11+
{ "component": "UserDirectory", "endpoints": ["/api/users"] },
12+
{ "component": "LoginPage", "endpoints": ["/api/users"] },
13+
{ "component": "CartWidget", "endpoints": ["/api/cart"] }
14+
],
15+
"forbidden": [
16+
{
17+
"component": "UserDirectory",
18+
"endpoint": "/api/cart",
19+
"note": "poison: unrelated store's API attributed to the redux reader"
20+
},
21+
{
22+
"component": "CartWidget",
23+
"endpoint": "/api/users",
24+
"note": "poison: redux slice's API attributed to the zustand reader"
25+
}
26+
],
27+
"queries": [
28+
{ "terms": ["User directory"], "status": "ok", "top": "UserDirectory" },
29+
{ "terms": ["Welcome back"], "status": "ok", "top": "LoginPage" }
30+
]
31+
}
32+
}

eval/history.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
{"generatedAt":"2026-07-13T11:10:56.348Z","commitSha":"b628c816231c4896f030ebc68a6621d0963d183c","pass":99,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
99
{"generatedAt":"2026-07-13T11:16:56.457Z","commitSha":"ce7a3bcbf95481114cd60ef62f8e840874ef7453","pass":108,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
1010
{"generatedAt":"2026-07-13T11:25:04.854Z","commitSha":"cec11f7aab21ef6ec2d6dd0bb247e2cd29981ed9","pass":119,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}
11+
{"generatedAt":"2026-07-14T16:11:46.552Z","commitSha":"4c01687263ff4ed2d656970a2532754636714604","pass":129,"fail":0,"xfail":0,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":1,"matchAccuracy":1}

packages/core/src/query.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult<Linea
221221
break;
222222
case "state":
223223
lineage.state.push(node);
224+
// Temporal decoupling (C6): the API that POPULATED this state may
225+
// have been called by a different component on a different page.
226+
// writes-state edges point data-source → state; follow them back.
227+
for (const writer of graph.edges) {
228+
if (writer.kind !== "writes-state" || writer.to !== node.id) continue;
229+
edgesWalked += 1;
230+
const source = byId.get(writer.from);
231+
if (source !== undefined && source.kind === "data-source" && !seen.has(source.id)) {
232+
seen.add(source.id);
233+
lineage.dataSources.push(source);
234+
}
235+
}
224236
break;
225237
case "event":
226238
lineage.events.push(node);

packages/parser-react/src/scan.ts

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
131131
edges.push(edge);
132132
}
133133
};
134+
const stores = detectStores(project, root, nodes, addEdge, baseUrls, wrappers);
134135

135136
for (const sourceFile of project.getSourceFiles()) {
136137
const file = toPosix(path.relative(root, sourceFile.getFilePath()));
@@ -161,10 +162,10 @@ export function scanReact(options: ScanOptions): LineageGraph {
161162
nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName });
162163
}
163164

164-
extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers);
165+
extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores);
165166
}
166167

167-
scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances);
168+
scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores);
168169
collectHocAliases(sourceFile, hocAliases);
169170
}
170171

@@ -384,6 +385,7 @@ function extractBodyFacts(
384385
addEdge: (edge: LineageEdge) => void,
385386
baseUrls: string[],
386387
wrappers: WrapperRegistry,
388+
stores: StoreRegistry,
387389
): void {
388390
// A wrapper's own body is plumbing: its URL is a parameter placeholder, so a
389391
// data source emitted here would attribute ":path" to every consumer. Call
@@ -393,6 +395,29 @@ function extractBodyFacts(
393395
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
394396
const callee = call.getExpression().getText();
395397

398+
// Store readers/dispatchers first — useSelector would otherwise fall
399+
// through to the generic per-component state handling.
400+
if (callee === "useSelector") {
401+
const sliceId = selectorSliceId(call, stores);
402+
if (sliceId !== undefined) {
403+
addEdge({ from: ownerId, to: sliceId, kind: "reads-state" });
404+
continue;
405+
}
406+
}
407+
const zustandId = stores.zustandHooks.get(callee);
408+
if (zustandId !== undefined) {
409+
addEdge({ from: ownerId, to: zustandId, kind: "reads-state" });
410+
continue;
411+
}
412+
const thunkSources = stores.thunkSources.get(callee);
413+
if (thunkSources !== undefined) {
414+
// dispatch(fetchUsers()) — this component initiates the fetch.
415+
for (const dsId of thunkSources) {
416+
addEdge({ from: ownerId, to: dsId, kind: "fetches-from" });
417+
}
418+
continue;
419+
}
420+
396421
const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers);
397422
if (dataSource !== null) {
398423
const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`);
@@ -467,6 +492,16 @@ function extractBodyFacts(
467492
}
468493
}
469494

495+
/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */
496+
function selectorSliceId(call: CallExpression, stores: StoreRegistry): string | undefined {
497+
const selector = call.getArguments()[0];
498+
if (selector === undefined || !Node.isArrowFunction(selector)) return undefined;
499+
const param = selector.getParameters()[0]?.getName();
500+
if (param === undefined) return undefined;
501+
const match = new RegExp(`\\b${param}\\.([A-Za-z_$][\\w$]*)`).exec(selector.getBody().getText());
502+
return match?.[1] !== undefined ? stores.reduxSlices.get(match[1]) : undefined;
503+
}
504+
470505
type DetectedSource = {
471506
sourceKind: DataSourceKind;
472507
method: string | null;
@@ -652,6 +687,142 @@ function stateVariableName(call: CallExpression): string | null {
652687
return null;
653688
}
654689

690+
/** Store registry (TRACKER step 2.4, failure mode C6). */
691+
interface StoreRegistry {
692+
/** Redux slice name → its global StateNode id. */
693+
reduxSlices: Map<string, string>;
694+
/** Zustand hook name (useCartStore) → its global StateNode id. */
695+
zustandHooks: Map<string, string>;
696+
/** createAsyncThunk name → the data-source node ids its payload creator hits. */
697+
thunkSources: Map<string, string[]>;
698+
}
699+
700+
/**
701+
* Global stores decouple the reader from the fetch: a component renders
702+
* `useSelector(s => s.users.list)` while the API call that POPULATED the
703+
* slice happened at login, in another component. This pass finds the store
704+
* shapes and wires data-source --writes-state--> slice, so lineage flows
705+
* reader → slice → populating API.
706+
*/
707+
function detectStores(
708+
project: Project,
709+
root: string,
710+
nodes: Map<string, LineageNode>,
711+
addEdge: (edge: LineageEdge) => void,
712+
baseUrls: string[],
713+
wrappers: WrapperRegistry,
714+
): StoreRegistry {
715+
const registry: StoreRegistry = {
716+
reduxSlices: new Map(),
717+
zustandHooks: new Map(),
718+
thunkSources: new Map(),
719+
};
720+
721+
const ensureDataSource = (call: Node, file: string): string | null => {
722+
if (!Node.isCallExpression(call)) return null;
723+
const detected = detectDataSource(call, call.getExpression().getText(), baseUrls, wrappers);
724+
if (detected === null || detected.sourceKind === "react-query" || detected.sourceKind === "swr") {
725+
return null;
726+
}
727+
const dsId = nodeId("data-source", file, `${detected.sourceKind}:${detected.endpoint}`);
728+
if (!nodes.has(dsId)) {
729+
nodes.set(dsId, {
730+
id: dsId,
731+
kind: "data-source",
732+
name: detected.endpoint,
733+
loc: locOf(call, file),
734+
sourceKind: detected.sourceKind,
735+
method: detected.method,
736+
endpoint: detected.endpoint,
737+
raw: detected.raw,
738+
resolved: detected.resolved,
739+
});
740+
}
741+
return dsId;
742+
};
743+
744+
// Pass 1: slices, zustand stores, thunks.
745+
for (const sourceFile of project.getSourceFiles()) {
746+
const file = toPosix(path.relative(root, sourceFile.getFilePath()));
747+
for (const variable of sourceFile.getVariableDeclarations()) {
748+
const init = variable.getInitializer();
749+
if (init === undefined || !Node.isCallExpression(init)) continue;
750+
const callee = init.getExpression().getText();
751+
752+
if (callee === "createSlice") {
753+
const config = init.getArguments()[0];
754+
const nameInit = config !== undefined ? propertyInitializer(config, "name") : undefined;
755+
const sliceName =
756+
nameInit !== undefined && Node.isStringLiteral(nameInit)
757+
? nameInit.getLiteralValue()
758+
: variable.getName();
759+
const stateId = nodeId("state", file, `slice:${sliceName}`);
760+
if (!nodes.has(stateId)) {
761+
nodes.set(stateId, {
762+
id: stateId,
763+
kind: "state",
764+
name: sliceName,
765+
loc: locOf(variable, file),
766+
stateKind: "redux",
767+
});
768+
}
769+
registry.reduxSlices.set(sliceName, stateId);
770+
} else if (callee === "createAsyncThunk") {
771+
const payloadCreator = init.getArguments()[1];
772+
const sources: string[] = [];
773+
if (payloadCreator !== undefined) {
774+
for (const call of payloadCreator.getDescendantsOfKind(SyntaxKind.CallExpression)) {
775+
const dsId = ensureDataSource(call, file);
776+
if (dsId !== null) sources.push(dsId);
777+
}
778+
}
779+
registry.thunkSources.set(variable.getName(), sources);
780+
} else if (callee === "create" && HOOK_NAME.test(variable.getName())) {
781+
// Zustand: const useCartStore = create((set) => ({ ..., load: async () => { fetch...; set(...) } }))
782+
const stateId = nodeId("state", file, `store:${variable.getName()}`);
783+
if (!nodes.has(stateId)) {
784+
nodes.set(stateId, {
785+
id: stateId,
786+
kind: "state",
787+
name: variable.getName(),
788+
loc: locOf(variable, file),
789+
stateKind: "zustand",
790+
});
791+
}
792+
registry.zustandHooks.set(variable.getName(), stateId);
793+
for (const call of init.getDescendantsOfKind(SyntaxKind.CallExpression)) {
794+
const dsId = ensureDataSource(call, file);
795+
if (dsId !== null) addEdge({ from: dsId, to: stateId, kind: "writes-state" });
796+
}
797+
}
798+
}
799+
}
800+
801+
// Pass 2: thunk → slice associations via extraReducers addCase(thunk.fulfilled).
802+
for (const sourceFile of project.getSourceFiles()) {
803+
for (const access of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
804+
if (!["fulfilled", "rejected", "pending"].includes(access.getName())) continue;
805+
const thunkName = access.getExpression().getText();
806+
const sources = registry.thunkSources.get(thunkName);
807+
if (sources === undefined || sources.length === 0) continue;
808+
const sliceCall = access.getFirstAncestor(
809+
(a) => Node.isCallExpression(a) && a.getExpression().getText() === "createSlice",
810+
);
811+
if (sliceCall === undefined || !Node.isCallExpression(sliceCall)) continue;
812+
const config = sliceCall.getArguments()[0];
813+
const nameInit = config !== undefined ? propertyInitializer(config, "name") : undefined;
814+
if (nameInit === undefined || !Node.isStringLiteral(nameInit)) continue;
815+
const stateId = registry.reduxSlices.get(nameInit.getLiteralValue());
816+
if (stateId === undefined) continue;
817+
for (const dsId of sources) {
818+
addEdge({ from: dsId, to: stateId, kind: "writes-state" });
819+
}
820+
}
821+
}
822+
823+
return registry;
824+
}
825+
655826
const CLASS_COMPONENT_BASE = /(React\.)?(Pure)?Component/;
656827

657828
/** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */
@@ -664,6 +835,7 @@ function scanClassComponents(
664835
wrappers: WrapperRegistry,
665836
localeTable: LocaleTable | null,
666837
pendingInstances: PendingInstance[],
838+
stores: StoreRegistry,
667839
): void {
668840
for (const cls of sourceFile.getClasses()) {
669841
const name = cls.getName();
@@ -705,7 +877,7 @@ function scanClassComponents(
705877
});
706878
collectInstanceSites(render, id, file, pendingInstances);
707879
// Whole class body: lifecycle fetches (componentDidMount etc.) + render events.
708-
extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers);
880+
extractBodyFacts(name, cls, id, file, nodes, addEdge, baseUrls, wrappers, stores);
709881
extractClassState(cls, name, id, file, nodes, addEdge);
710882
}
711883
}

0 commit comments

Comments
 (0)