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
19 changes: 16 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 3 additions & 11 deletions eval/fixtures/field-patterns/golden.json
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -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": [
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export type DataSourceKind =
| "fetch"
| "axios"
| "react-query"
| "rtk-query"
| "swr"
| "graphql"
| "websocket"
Expand Down
56 changes: 56 additions & 0 deletions packages/parser-react/src/rtk.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
137 changes: 137 additions & 0 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1026,6 +1032,11 @@ interface StoreRegistry {
actionSlices: Map<string, string>;
/** createAsyncThunk name → the slice StateNode its extraReducers populate. */
thunkSlices: Map<string, string>;
/**
* RTK Query generated hook name (useGetUsersQuery, usePayInvoiceMutation,
* useLazy…Query) → the data-source node id its endpoint resolves to (6F.4).
*/
rtkHooks: Map<string, string>;
}

/**
Expand All @@ -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 => {
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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<string, LineageNode>,
): 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. */
Expand Down
1 change: 1 addition & 0 deletions schemas/lineage-graph.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@
"fetch",
"axios",
"react-query",
"rtk-query",
"swr",
"graphql",
"websocket",
Expand Down
Loading