diff --git a/TRACKER.md b/TRACKER.md
index c44f43f..3902af5 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 1 — Robust extraction
-- **Next step:** 1.3 — react-query / SWR queryFn following
-- **Done:** 0.1–0.4, 1.1, 1.2
+- **Next step:** 1.4 — i18n adapter
+- **Done:** 0.1–0.4, 1.1–1.3
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)
## What CodeRadar is
@@ -105,7 +105,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
- Wrapper chains up to depth 3 (`useApi → apiClient.get → fetch`).
**Accept:** fixture `c2-api-wrapper` (three-layer wrapper) green; wrapper detection has unit tests for both heuristic and config paths.
-### [ ] 1.3 react-query / SWR queryFn following
+### [x] 1.3 react-query / SWR queryFn following
**Failure modes:** C5
**Build:** when `useQuery`/`useMutation`/`useSWR`'s fn argument is a reference, resolve to its declaration (same file or import) and extract the endpoint from its body via 1.1/1.2. Data source records both the query key and the resolved endpoint; mutations get `method` from the inner call.
**Accept:** fixture `c5-queryfn-indirection` green (queryFn in a separate `api/users.ts`).
diff --git a/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx b/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx
new file mode 100644
index 0000000..fd41880
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx
@@ -0,0 +1,11 @@
+import { useMutation } from "@tanstack/react-query";
+
+import { createUser } from "./api/users";
+
+export function InviteButton() {
+ const mutation = useMutation({ mutationFn: createUser });
+
+ return (
+
+ );
+}
diff --git a/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx b/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx
new file mode 100644
index 0000000..3c7d5b7
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx
@@ -0,0 +1,15 @@
+import { useQuery } from "@tanstack/react-query";
+
+export function StatsCard() {
+ const { data } = useQuery({
+ queryKey: ["stats"],
+ queryFn: () => fetch("/api/stats").then((r) => r.json()),
+ });
+
+ return (
+
+
Weekly stats
+ {data?.total}
+
+ );
+}
diff --git a/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx b/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx
new file mode 100644
index 0000000..7d020de
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx
@@ -0,0 +1,15 @@
+import { useQuery } from "react-query";
+
+import { fetchTeam } from "./api/users";
+
+/** Legacy v3 positional form. */
+export function TeamPanel() {
+ const { data } = useQuery(["team"], fetchTeam);
+
+ return (
+
+ );
+}
diff --git a/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx b/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx
new file mode 100644
index 0000000..6c81c58
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { fetchUsers } from "./api/users";
+
+export function UsersDashboard() {
+ const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
+
+ return (
+
+ Active users
+ {data?.length ?? 0} online
+
+ );
+}
diff --git a/eval/fixtures/c5-queryfn-indirection/app/api/users.ts b/eval/fixtures/c5-queryfn-indirection/app/api/users.ts
new file mode 100644
index 0000000..6e8c723
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/app/api/users.ts
@@ -0,0 +1,17 @@
+export async function fetchUsers() {
+ const res = await fetch("/api/users");
+ return res.json();
+}
+
+export async function createUser(body: { name: string }) {
+ const res = await fetch("/api/users", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ return res.json();
+}
+
+export const fetchTeam = async () => {
+ const res = await fetch("/api/team");
+ return res.json();
+};
diff --git a/eval/fixtures/c5-queryfn-indirection/golden.json b/eval/fixtures/c5-queryfn-indirection/golden.json
new file mode 100644
index 0000000..2a6d136
--- /dev/null
+++ b/eval/fixtures/c5-queryfn-indirection/golden.json
@@ -0,0 +1,35 @@
+{
+ "failureMode": "C5",
+ "note": "react-query queryFn indirection: the hook call site contains no URL — the endpoint lives inside fetchUsers/createUser/fetchTeam declared in api/users.ts. Covers v4/v5 object form (queryFn/mutationFn), v3 positional form, and an inline arrow queryFn.",
+ "expect": {
+ "components": [
+ { "name": "UsersDashboard", "instances": 0 },
+ { "name": "InviteButton", "instances": 0 },
+ { "name": "TeamPanel", "instances": 0 },
+ { "name": "StatsCard", "instances": 0 }
+ ],
+ "attributions": [
+ { "component": "UsersDashboard", "endpoints": ["/api/users"] },
+ { "component": "InviteButton", "endpoints": ["/api/users"] },
+ { "component": "TeamPanel", "endpoints": ["/api/team"] },
+ { "component": "StatsCard", "endpoints": ["/api/stats"] }
+ ],
+ "forbidden": [
+ {
+ "component": "UsersDashboard",
+ "endpoint": "",
+ "note": "queryFn reference not followed — endpoint collapsed to dynamic"
+ },
+ {
+ "component": "TeamPanel",
+ "endpoint": "[\"team\"]",
+ "note": "poison: the query KEY reported as if it were the endpoint"
+ }
+ ],
+ "queries": [
+ { "terms": ["Active users"], "status": "ok", "top": "UsersDashboard" },
+ { "terms": ["Invite user"], "status": "ok", "top": "InviteButton" },
+ { "terms": ["Weekly stats"], "status": "ok", "top": "StatsCard" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index 2510e41..baf2ea4 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -1,3 +1,4 @@
{"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1}
{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1}
{"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1}
+{"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
diff --git a/eval/thresholds.json b/eval/thresholds.json
index a34d44b..b7ecceb 100644
--- a/eval/thresholds.json
+++ b/eval/thresholds.json
@@ -3,5 +3,5 @@
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1,
"minLineagePrecision": 0.9,
- "minLineageRecall": 0.85
+ "minLineageRecall": 0.88
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index af0ebff..8d6b6ec 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -116,6 +116,11 @@ export interface DataSourceNode extends BaseNode {
/** The endpoint expression exactly as written in source. */
raw: string;
resolved: EndpointResolution;
+ /**
+ * react-query/SWR cache key as written in source (e.g. `["users"]`) —
+ * the identity used for cache-sharing analysis (C7) alongside the endpoint.
+ */
+ queryKey?: string;
}
export type StateKind = "useState" | "useReducer" | "context" | "redux" | "zustand" | "unknown";
diff --git a/packages/parser-react/src/queryfn.test.ts b/packages/parser-react/src/queryfn.test.ts
new file mode 100644
index 0000000..377d010
--- /dev/null
+++ b/packages/parser-react/src/queryfn.test.ts
@@ -0,0 +1,43 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { describe, expect, it } from "vitest";
+
+import { scanReact } from "./scan.js";
+
+const fixture = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures/c5-queryfn-indirection/app",
+);
+
+const graph = scanReact({ root: fixture });
+const sources = graph.nodes.flatMap((n) => (n.kind === "data-source" ? [n] : []));
+
+describe("queryFn following (c5 fixture)", () => {
+ it("resolves v4/v5 object-form queryFn references to their endpoint", () => {
+ const users = sources.find((s) => s.endpoint === "/api/users" && s.method === "GET");
+ expect(users?.sourceKind).toBe("react-query");
+ expect(users?.queryKey).toBe(`["users"]`);
+ });
+
+ it("resolves mutationFn and picks up the inner POST method", () => {
+ const post = sources.find((s) => s.endpoint === "/api/users" && s.method === "POST");
+ expect(post?.sourceKind).toBe("react-query");
+ });
+
+ it("resolves the v3 positional form with an arrow-const fn", () => {
+ const team = sources.find((s) => s.endpoint === "/api/team");
+ expect(team?.sourceKind).toBe("react-query");
+ expect(team?.queryKey).toBe(`["team"]`);
+ });
+
+ it("resolves inline arrow queryFns", () => {
+ const stats = sources.find((s) => s.endpoint === "/api/stats");
+ expect(stats?.sourceKind).toBe("react-query");
+ });
+
+ it("never reports a query key as the endpoint", () => {
+ expect(sources.some((s) => s.endpoint.startsWith("["))).toBe(false);
+ expect(sources.some((s) => s.endpoint === "")).toBe(false);
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index 852c258..d085719 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -297,6 +297,7 @@ function extractBodyFacts(
endpoint: dataSource.endpoint,
raw: dataSource.raw,
resolved: dataSource.resolved,
+ ...(dataSource.queryKey !== undefined ? { queryKey: dataSource.queryKey } : {}),
});
}
addEdge({ from: ownerId, to: dsId, kind: "fetches-from" });
@@ -350,12 +351,18 @@ function extractBodyFacts(
}
}
+type DetectedSource = {
+ sourceKind: DataSourceKind;
+ method: string | null;
+ queryKey?: string;
+} & ResolvedEndpoint;
+
function detectDataSource(
call: CallExpression,
callee: string,
baseUrls: string[],
wrappers: WrapperRegistry,
-): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null {
+): DetectedSource | null {
const firstArg = call.getArguments()[0];
const wrapper = wrappers.get(callee);
@@ -393,8 +400,26 @@ function detectDataSource(
}
if (callee === "useQuery" || callee === "useMutation" || callee === "useInfiniteQuery") {
- // Endpoint lives inside the queryFn (followed in step 1.3); the query key
- // is the identity meanwhile.
+ let keyText: string | undefined;
+ let fnExpr: Node | undefined;
+ if (firstArg !== undefined && Node.isObjectLiteralExpression(firstArg)) {
+ // v4/v5 object form: useQuery({ queryKey, queryFn }) / useMutation({ mutationFn })
+ keyText = propertyInitializer(firstArg, "queryKey")?.getText() ??
+ propertyInitializer(firstArg, "mutationKey")?.getText();
+ fnExpr = propertyInitializer(firstArg, "queryFn") ?? propertyInitializer(firstArg, "mutationFn");
+ } else if (callee === "useMutation") {
+ // v3 positional form: useMutation(mutationFn, options?)
+ fnExpr = firstArg;
+ } else {
+ // v3 positional form: useQuery(key, queryFn, options?)
+ keyText = firstArg?.getText();
+ fnExpr = call.getArguments()[1];
+ }
+ const queryKey = keyText?.slice(0, 80);
+ const inner = fnExpr !== undefined ? endpointFromFunction(fnExpr, baseUrls, wrappers) : null;
+ if (inner !== null) {
+ return { ...inner, sourceKind: "react-query", queryKey };
+ }
const resolved = resolveEndpoint(firstArg, baseUrls);
return {
sourceKind: "react-query",
@@ -404,13 +429,79 @@ function detectDataSource(
resolved.resolved === "none"
? (firstArg?.getText().slice(0, 80) ?? "")
: resolved.endpoint,
+ queryKey,
};
}
if (callee === "useSWR") {
- return { sourceKind: "swr", method: "GET", ...resolveEndpoint(firstArg, baseUrls) };
+ // SWR convention: the key IS the URL (passed to the fetcher). When the key
+ // carries no shape, fall back to the fetcher body.
+ const key = resolveEndpoint(firstArg, baseUrls);
+ if (key.resolved !== "none") {
+ return { sourceKind: "swr", method: "GET", ...key, queryKey: firstArg?.getText().slice(0, 80) };
+ }
+ const fetcher = call.getArguments()[1];
+ const inner = fetcher !== undefined ? endpointFromFunction(fetcher, baseUrls, wrappers) : null;
+ if (inner !== null) return { ...inner, sourceKind: "swr" };
+ return { sourceKind: "swr", method: "GET", ...key };
+ }
+
+ return null;
+}
+
+function propertyInitializer(objectLiteral: Node, name: string): Node | undefined {
+ if (!Node.isObjectLiteralExpression(objectLiteral)) return undefined;
+ const property = objectLiteral.getProperty(name);
+ if (property !== undefined && Node.isPropertyAssignment(property)) {
+ return property.getInitializer();
+ }
+ if (property !== undefined && Node.isShorthandPropertyAssignment(property)) {
+ return property.getNameNode();
}
+ return undefined;
+}
+/**
+ * Follow a queryFn/mutationFn/fetcher expression to a function body (inline
+ * arrow, or a reference resolved via go-to-definition, possibly in another
+ * file) and extract the first data source inside it. react-query/swr results
+ * are skipped to avoid self-recursion.
+ */
+function endpointFromFunction(
+ expr: Node,
+ baseUrls: string[],
+ wrappers: WrapperRegistry,
+): DetectedSource | null {
+ const body = functionBodyOf(expr);
+ if (body === null) return null;
+ for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ const source = detectDataSource(call, call.getExpression().getText(), baseUrls, wrappers);
+ if (source !== null && source.sourceKind !== "react-query" && source.sourceKind !== "swr") {
+ return source;
+ }
+ }
+ return null;
+}
+
+function functionBodyOf(expr: Node): Node | null {
+ if (Node.isArrowFunction(expr) || Node.isFunctionExpression(expr)) {
+ return expr.getBody() ?? null;
+ }
+ if (Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr)) {
+ const identifier = Node.isPropertyAccessExpression(expr) ? expr.getNameNode() : expr;
+ if (!Node.isIdentifier(identifier)) return null;
+ for (const definition of identifier.getDefinitionNodes()) {
+ if (Node.isFunctionDeclaration(definition)) {
+ return definition.getBody() ?? null;
+ }
+ if (Node.isVariableDeclaration(definition)) {
+ const init = definition.getInitializer();
+ if (init !== undefined && (Node.isArrowFunction(init) || Node.isFunctionExpression(init))) {
+ return init.getBody() ?? null;
+ }
+ }
+ }
+ }
return null;
}
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 8a8a5e1..69f5480 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -320,6 +320,10 @@
},
"resolved": {
"$ref": "#/definitions/EndpointResolution"
+ },
+ "queryKey": {
+ "type": "string",
+ "description": "react-query/SWR cache key as written in source (e.g. `[\"users\"]`) — the identity used for cache-sharing analysis (C7) alongside the endpoint."
}
},
"required": [