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": [