diff --git a/TRACKER.md b/TRACKER.md index feab603..1e45e60 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5) -- **Next step:** 6F.4 RTK Query extractor -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.3 +- **Next step:** 6F.5 object-config React Router recognition +- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.4 - **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP) ## What CodeRadar is @@ -429,7 +429,7 @@ usage). 100% of the fixture's component usages resolve. 2 new parser unit tests eval 283 pass / 0 fail / 7 xfail (all owned by 6F.4–6F.5) / 0 unexpected-pass, gate OK, metrics 1.000. -### [ ] 6F.4 RTK Query extractor +### [x] 6F.4 RTK Query extractor **Failure modes:** B2, C5, C6 **Build:** `createApi` / `injectEndpoints` / `builder.query|mutation` are unrecognized today — 0 data-source nodes across ~40 store files in the field run, so the entire server-cache @@ -440,6 +440,19 @@ sites → per-instance `fetches-from` edges, and model server-cache selectors **Accept:** field-patterns fixture: every endpoint under `store/api/**` emits a data-source node; `traceLineage` from a component using a generated hook reaches its endpoint; enable the 6F.2 checks. +**Done:** new `rtk-query` DataSourceKind in core (schema regenerated, drift gate green). In +`detectStores`, `createApi` / `api.injectEndpoints` declarations now extract endpoints: +`rtkBaseCall` resolves the (possibly imported) receiver back to its `createApi` through +chained `injectEndpoints` hops, `rtkBaseUrl` reads the `fetchBaseQuery` baseUrl, and every +`builder.query|mutation` becomes a DataSourceNode — string-form and object-form (`{ url, +method }`) query configs, template params normalized to `:param` (e.g. +`/api/invoices/:id/pay`, POST), baseUrl joined. Generated hooks (`useXQuery`, `useXMutation`, +`useLazyXQuery`) register in a new `rtkHooks` map, and the component body walk wires +`fetches-from` edges at call sites. Fixture: 3 data sources, both attribution xfails removed +and passing (UsersPage → /api/users, InvoicesPage → /api/invoices); the unused mutation stays +a consumer-less source. Server-cache `useSelector` reads remain future work (not needed by +the accept criteria). 4 new parser tests (125 total); eval 285 pass / 0 fail / 5 xfail (all +6F.5) / 0 unexpected-pass, gate OK, metrics 1.000. ### [ ] 6F.5 Object-config React Router recognition **Failure modes:** B4, B3 diff --git a/eval/fixtures/field-patterns/golden.json b/eval/fixtures/field-patterns/golden.json index 10a1935..fc41777 100644 --- a/eval/fixtures/field-patterns/golden.json +++ b/eval/fixtures/field-patterns/golden.json @@ -1,6 +1,6 @@ { "failureMode": "D2", - "note": "Field-pattern regression fixture (6F.2): mirrors the shapes the 2026-07-15 ih-frontend validation run used where every gate scored 1.000 but the field run failed — tsconfig-path-alias barrel imports (@ui), Loadable(lazy(() => import())) page elements, route arrays composed in a separate file and passed to createBrowserRouter as an identifier, an RTK Query store (createApi/injectEndpoints/builder.query|mutation), and tests that render through a custom providers wrapper. Checks assert TARGET behavior; the ones the current scanner cannot satisfy are marked expectedFail with the step that must enable them (6F.4 RTK Query, 6F.5 object-config routes; the 6F.3 alias/lazy marks were removed when that step landed). xfail semantics remove stale marks automatically: once a step lands, its checks flip to unexpected-pass and gate until the marks are deleted. Verified working already in this shape: relative multi-hop barrel with rename (InvoicesPage's Grid), and covered-by detection through the custom render wrapper.", + "note": "Field-pattern regression fixture (6F.2): mirrors the shapes the 2026-07-15 ih-frontend validation run used where every gate scored 1.000 but the field run failed — tsconfig-path-alias barrel imports (@ui), Loadable(lazy(() => import())) page elements, route arrays composed in a separate file and passed to createBrowserRouter as an identifier, an RTK Query store (createApi/injectEndpoints/builder.query|mutation), and tests that render through a custom providers wrapper. Checks assert TARGET behavior; the ones the current scanner cannot satisfy are marked expectedFail with the step that must enable them (6F.5 object-config routes; the 6F.3 alias/lazy and 6F.4 RTK Query marks were removed when those steps landed). xfail semantics remove stale marks automatically: once a step lands, its checks flip to unexpected-pass and gate until the marks are deleted. Verified working already in this shape: relative multi-hop barrel with rename (InvoicesPage's Grid), and covered-by detection through the custom render wrapper.", "expect": { "components": [ { "name": "HomePage", "instances": 0 }, @@ -54,16 +54,8 @@ } ], "attributions": [ - { - "component": "UsersPage", - "endpoints": ["/api/users"], - "expectedFail": "enabled by 6F.4 — createApi/injectEndpoints/builder.query are unrecognized; 0 data-source nodes today" - }, - { - "component": "InvoicesPage", - "endpoints": ["/api/invoices"], - "expectedFail": "enabled by 6F.4 — injectEndpoints with an object-form query ({ url }) in a second api slice file" - } + { "component": "UsersPage", "endpoints": ["/api/users"] }, + { "component": "InvoicesPage", "endpoints": ["/api/invoices"] } ], "blast": [ { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f04a3e8..2b24e43 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -158,6 +158,7 @@ export type DataSourceKind = | "fetch" | "axios" | "react-query" + | "rtk-query" | "swr" | "graphql" | "websocket" diff --git a/packages/parser-react/src/rtk.test.ts b/packages/parser-react/src/rtk.test.ts new file mode 100644 index 0000000..048e0c3 --- /dev/null +++ b/packages/parser-react/src/rtk.test.ts @@ -0,0 +1,56 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { DataSourceNode } from "@coderadar/core"; +import { describe, expect, it } from "vitest"; + +import { scanReact } from "./scan.js"; + +const fixtures = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../eval/fixtures", +); + +const graph = scanReact({ root: path.join(fixtures, "field-patterns/app") }); + +function source(endpoint: string): DataSourceNode | undefined { + return graph.nodes.find( + (n): n is DataSourceNode => n.kind === "data-source" && n.endpoint === endpoint, + ); +} + +describe("RTK Query extraction (TRACKER 6F.4, B2/C5)", () => { + it("emits a data source per injectEndpoints endpoint, joining the base's baseUrl", () => { + const users = source("/api/users"); + expect(users?.sourceKind).toBe("rtk-query"); + expect(users?.method).toBe("GET"); + expect(users?.resolved).toBe("full"); + }); + + it("handles object-form query configs and template params in a second slice file", () => { + expect(source("/api/invoices")?.method).toBe("GET"); + const pay = source("/api/invoices/:id/pay"); + expect(pay?.method).toBe("POST"); + expect(pay?.resolved).toBe("partial"); + }); + + it("links generated-hook call sites with fetches-from edges", () => { + const edges = graph.edges.filter((e) => e.kind === "fetches-from"); + expect(edges).toContainEqual({ + from: "component:pages/UsersPage.tsx#UsersPage", + to: "data-source:store/api/usersApi.ts#rtk-query:/api/users", + kind: "fetches-from", + }); + expect(edges).toContainEqual({ + from: "component:pages/InvoicesPage.tsx#InvoicesPage", + to: "data-source:store/api/invoicesApi.ts#rtk-query:/api/invoices", + kind: "fetches-from", + }); + }); + + it("keeps the unused mutation as a source without consumers", () => { + const pay = source("/api/invoices/:id/pay"); + expect(pay).toBeDefined(); + expect(graph.edges.some((e) => e.to === pay?.id && e.kind === "fetches-from")).toBe(false); + }); +}); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index bb60e6e..c73c7a4 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -700,6 +700,12 @@ function extractBodyFacts( addEdge({ from: ownerId, to: zustandId, kind: "reads-state" }); continue; } + const rtkSource = stores.rtkHooks.get(callee); + if (rtkSource !== undefined) { + // const { data } = useGetUsersQuery() — this component consumes the endpoint. + addEdge({ from: ownerId, to: rtkSource, kind: "fetches-from" }); + continue; + } const thunkSources = stores.thunkSources.get(callee); if (thunkSources !== undefined) { // dispatch(fetchUsers()) — this component initiates the fetch. @@ -1026,6 +1032,11 @@ interface StoreRegistry { actionSlices: Map; /** createAsyncThunk name → the slice StateNode its extraReducers populate. */ thunkSlices: Map; + /** + * RTK Query generated hook name (useGetUsersQuery, usePayInvoiceMutation, + * useLazy…Query) → the data-source node id its endpoint resolves to (6F.4). + */ + rtkHooks: Map; } /** @@ -1049,6 +1060,7 @@ function detectStores( thunkSources: new Map(), actionSlices: new Map(), thunkSlices: new Map(), + rtkHooks: new Map(), }; const ensureDataSource = (call: Node, file: string): string | null => { @@ -1141,6 +1153,10 @@ function detectStores( const dsId = ensureDataSource(call, file); if (dsId !== null) addEdge({ from: dsId, to: stateId, kind: "writes-state" }); } + } else if (callee === "createApi" || callee.endsWith(".injectEndpoints")) { + // RTK Query API slices (TRACKER 6F.4, failure modes B2/C5): the field + // run had ~40 injectEndpoints files and 0 data-source nodes. + rtkEndpoints(init, file, registry, nodes); } } } @@ -1172,6 +1188,127 @@ function detectStores( return registry; } +/** + * RTK Query extraction (TRACKER 6F.4, failure modes B2/C5). `createApi` + * declares the base (a `fetchBaseQuery` baseUrl) and optionally endpoints; + * `api.injectEndpoints` adds endpoints to an imported base from any file. + * Every `builder.query`/`builder.mutation` becomes a data source whose + * endpoint joins the base's baseUrl, and its generated hooks + * (`useGetUsersQuery`, `usePayInvoiceMutation`, `useLazy…Query`) register so + * component call sites wire `fetches-from` edges. + */ +function rtkEndpoints( + init: CallExpression, + file: string, + registry: StoreRegistry, + nodes: Map, +): void { + const base = rtkBaseCall(init, 0); + if (base === null) return; + const baseUrl = rtkBaseUrl(base); + + const config = init.getArguments()[0]; + const endpointsFn = config !== undefined ? propertyInitializer(config, "endpoints") : undefined; + const endpointsObject = returnedExpression(endpointsFn); + if (endpointsObject === undefined || !Node.isObjectLiteralExpression(endpointsObject)) return; + + for (const property of endpointsObject.getProperties()) { + if (!Node.isPropertyAssignment(property)) continue; + const endpointCall = property.getInitializer(); + if (endpointCall === undefined || !Node.isCallExpression(endpointCall)) continue; + const builderExpr = endpointCall.getExpression(); + if (!Node.isPropertyAccessExpression(builderExpr)) continue; + const opKind = builderExpr.getName(); + if (opKind !== "query" && opKind !== "mutation") continue; + + const endpointConfig = endpointCall.getArguments()[0]; + const queryFn = + endpointConfig !== undefined ? propertyInitializer(endpointConfig, "query") : undefined; + const returned = returnedExpression(queryFn); + let urlNode: Node | undefined = returned; + let method = opKind === "mutation" ? "POST" : "GET"; + if (returned !== undefined && Node.isObjectLiteralExpression(returned)) { + urlNode = propertyInitializer(returned, "url"); + const methodValue = resolveStringValue(propertyInitializer(returned, "method"), 0); + if (methodValue !== null) method = methodValue.toUpperCase(); + } + const resolved = resolveEndpoint(urlNode, []); + const endpoint = + resolved.resolved === "none" ? resolved.endpoint : joinBaseUrl(baseUrl, resolved.endpoint); + + const dsId = nodeId("data-source", file, `rtk-query:${endpoint}`); + if (!nodes.has(dsId)) { + nodes.set(dsId, { + id: dsId, + kind: "data-source", + name: endpoint, + loc: locOf(property, file), + sourceKind: "rtk-query", + method, + endpoint, + raw: resolved.raw, + resolved: resolved.resolved, + }); + } + const pascal = property.getName().charAt(0).toUpperCase() + property.getName().slice(1); + registry.rtkHooks.set(`use${pascal}${opKind === "query" ? "Query" : "Mutation"}`, dsId); + if (opKind === "query") registry.rtkHooks.set(`useLazy${pascal}Query`, dsId); + } +} + +/** + * Resolve `createApi(...)` for an api expression: the call itself, or — + * for `api.injectEndpoints(...)` — the (possibly imported) receiver's + * declaration, following chained injectEndpoints up to a small hop budget. + */ +function rtkBaseCall(init: CallExpression, hop: number): CallExpression | null { + if (hop > 3) return null; + const expr = init.getExpression(); + if (expr.getText() === "createApi") return init; + if (!Node.isPropertyAccessExpression(expr) || expr.getName() !== "injectEndpoints") return null; + const receiver = expr.getExpression(); + if (!Node.isIdentifier(receiver)) return null; + for (const declaration of receiver.getDefinitionNodes()) { + if (!Node.isVariableDeclaration(declaration)) continue; + const declInit = declaration.getInitializer(); + if (declInit === undefined || !Node.isCallExpression(declInit)) continue; + const found = rtkBaseCall(declInit, hop + 1); + if (found !== null) return found; + } + return null; +} + +/** The baseUrl configured on a createApi's fetchBaseQuery, or "". */ +function rtkBaseUrl(createApiCall: CallExpression): string { + const config = createApiCall.getArguments()[0]; + const baseQuery = config !== undefined ? propertyInitializer(config, "baseQuery") : undefined; + if (baseQuery === undefined || !Node.isCallExpression(baseQuery)) return ""; + const baseConfig = baseQuery.getArguments()[0]; + const url = baseConfig !== undefined ? propertyInitializer(baseConfig, "baseUrl") : undefined; + return resolveStringValue(url, 0) ?? ""; +} + +/** The expression an arrow/function initializer returns (block and paren aware). */ +function returnedExpression(fn: Node | undefined): Node | undefined { + if (fn === undefined || (!Node.isArrowFunction(fn) && !Node.isFunctionExpression(fn))) { + return undefined; + } + const body = fn.getBody(); + const expression = Node.isBlock(body) + ? body.getStatements().find(Node.isReturnStatement)?.getExpression() + : body; + let unwrapped = expression; + while (unwrapped !== undefined && Node.isParenthesizedExpression(unwrapped)) { + unwrapped = unwrapped.getExpression(); + } + return unwrapped; +} + +function joinBaseUrl(base: string, endpoint: string): string { + if (base === "") return endpoint; + return `${base.replace(/\/+$/, "")}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`; +} + const CLASS_COMPONENT_BASE = /(React\.)?(Pure)?Component/; /** Legacy class components: render() is the body, this.state is state, lifecycle fetches count. */ diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 28b8f9c..38cac10 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -456,6 +456,7 @@ "fetch", "axios", "react-query", + "rtk-query", "swr", "graphql", "websocket",