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.4Store adapter: writers ↔ readers
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.3
- **Next step:** 2.5Portals, 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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions eval/fixtures/c6-store-decoupled/app/CartWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 <span title="Cart items">{items.length} in cart</span>;
}
18 changes: 18 additions & 0 deletions eval/fixtures/c6-store-decoupled/app/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Welcome back</h1>
</main>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/c6-store-decoupled/app/UserDirectory.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<h2>User directory</h2>
<ul>
{users.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</section>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/c6-store-decoupled/app/store/cartStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { create } from "zustand";

interface CartState {
items: string[];
load: () => Promise<void>;
}

export const useCartStore = create<CartState>((set) => ({
items: [],
load: async () => {
const res = await fetch("/api/cart");
set({ items: (await res.json()) as string[] });
},
}));
17 changes: 17 additions & 0 deletions eval/fixtures/c6-store-decoupled/app/store/usersSlice.ts
Original file line number Diff line number Diff line change
@@ -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[];
});
},
});
32 changes: 32 additions & 0 deletions eval/fixtures/c6-store-decoupled/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
12 changes: 12 additions & 0 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,18 @@ export function traceLineage(graph: LineageGraph, id: string): QueryResult<Linea
break;
case "state":
lineage.state.push(node);
// Temporal decoupling (C6): the API that POPULATED this state may
// have been called by a different component on a different page.
// writes-state edges point data-source → state; follow them back.
for (const writer of graph.edges) {
if (writer.kind !== "writes-state" || writer.to !== node.id) continue;
edgesWalked += 1;
const source = byId.get(writer.from);
if (source !== undefined && source.kind === "data-source" && !seen.has(source.id)) {
seen.add(source.id);
lineage.dataSources.push(source);
}
}
break;
case "event":
lineage.events.push(node);
Expand Down
178 changes: 175 additions & 3 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
edges.push(edge);
}
};
const stores = detectStores(project, root, nodes, addEdge, baseUrls, wrappers);

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

extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers);
extractBodyFacts(decl.name, decl.fn, id, file, nodes, addEdge, baseUrls, wrappers, stores);
}

scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances);
scanClassComponents(sourceFile, file, nodes, addEdge, baseUrls, wrappers, localeTable, pendingInstances, stores);
collectHocAliases(sourceFile, hocAliases);
}

Expand Down Expand Up @@ -384,6 +385,7 @@ function extractBodyFacts(
addEdge: (edge: LineageEdge) => 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
Expand All @@ -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}`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string>;
/** Zustand hook name (useCartStore) → its global StateNode id. */
zustandHooks: Map<string, string>;
/** createAsyncThunk name → the data-source node ids its payload creator hits. */
thunkSources: Map<string, string[]>;
}

/**
* 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<string, LineageNode>,
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. */
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading