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:** 1 — Robust extraction
- **Next step:** 1.3react-query / SWR queryFn following
- **Done:** 0.1–0.4, 1.1, 1.2
- **Next step:** 1.4i18n 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
Expand Down Expand Up @@ -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`).
Expand Down
11 changes: 11 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/app/InviteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useMutation } from "@tanstack/react-query";

import { createUser } from "./api/users";

export function InviteButton() {
const mutation = useMutation({ mutationFn: createUser });

return (
<button onClick={() => mutation.mutate({ name: "New teammate" })}>Invite user</button>
);
}
15 changes: 15 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/app/StatsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h4>Weekly stats</h4>
<strong>{data?.total}</strong>
</div>
);
}
15 changes: 15 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/app/TeamPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside>
<h3>Team roster</h3>
<ul>{data?.map((m: string) => <li key={m}>{m}</li>)}</ul>
</aside>
);
}
14 changes: 14 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/app/UsersDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<h2>Active users</h2>
<p>{data?.length ?? 0} online</p>
</section>
);
}
17 changes: 17 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/app/api/users.ts
Original file line number Diff line number Diff line change
@@ -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();
};
35 changes: 35 additions & 0 deletions eval/fixtures/c5-queryfn-indirection/golden.json
Original file line number Diff line number Diff line change
@@ -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": "<dynamic>",
"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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
@@ -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}
2 changes: 1 addition & 1 deletion eval/thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1,
"minLineagePrecision": 0.9,
"minLineageRecall": 0.85
"minLineageRecall": 0.88
}
5 changes: 5 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
43 changes: 43 additions & 0 deletions packages/parser-react/src/queryfn.test.ts
Original file line number Diff line number Diff line change
@@ -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 === "<dynamic>")).toBe(false);
});
});
99 changes: 95 additions & 4 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -404,13 +429,79 @@ function detectDataSource(
resolved.resolved === "none"
? (firstArg?.getText().slice(0, 80) ?? "<dynamic>")
: 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;
}

Expand Down
4 changes: 4 additions & 0 deletions schemas/lineage-graph.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading