diff --git a/TRACKER.md b/TRACKER.md
index 0899814..c44f43f 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 1 — Robust extraction
-- **Next step:** 1.2 — API-client wrapper adapter
-- **Done:** 0.1–0.4, 1.1
+- **Next step:** 1.3 — react-query / SWR queryFn following
+- **Done:** 0.1–0.4, 1.1, 1.2
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)
## What CodeRadar is
@@ -97,7 +97,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
- `DataSourceNode` gains `{ pattern: string, resolved: "full" | "partial" | "none", raw: string }`.
**Accept:** fixtures `c2-endpoint-constants`, `c3-dynamic-endpoints` green; lineage precision holds ≥ 0.90 on all existing fixtures.
-### [ ] 1.2 API-client wrapper adapter
+### [x] 1.2 API-client wrapper adapter
**Failure modes:** C2 (wrapper half)
**Build:**
- Detection heuristic: a function/method whose body reaches `fetch`/`axios` and takes a path-like parameter → classified as an API wrapper; its call sites become data sources with the path argument resolved per 1.1.
diff --git a/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx b/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx
new file mode 100644
index 0000000..26312bb
--- /dev/null
+++ b/eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx
@@ -0,0 +1,9 @@
+import { apiClient } from "./api/client";
+
+export function CreateProjectButton() {
+ const handleCreate = () => {
+ apiClient.post("/projects", { name: "Untitled" });
+ };
+
+ return ;
+}
diff --git a/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx b/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx
new file mode 100644
index 0000000..9a8e54f
--- /dev/null
+++ b/eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx
@@ -0,0 +1,16 @@
+import { useApi } from "./hooks/useApi";
+
+export function ProjectsPage() {
+ const projects = useApi("/projects") as unknown as string[];
+
+ return (
+
+ Projects overview
+
+ {projects.map((p) => (
+ - {p}
+ ))}
+
+
+ );
+}
diff --git a/eval/fixtures/c2-api-wrapper/app/api/client.ts b/eval/fixtures/c2-api-wrapper/app/api/client.ts
new file mode 100644
index 0000000..242ac1d
--- /dev/null
+++ b/eval/fixtures/c2-api-wrapper/app/api/client.ts
@@ -0,0 +1,16 @@
+const API_BASE = "/api";
+
+async function request(path: string, init?: RequestInit) {
+ const res = await fetch(`${API_BASE}${path}`, init);
+ if (!res.ok) throw new Error(`request failed: ${res.status}`);
+ return res.json();
+}
+
+export const apiClient = {
+ get(path: string) {
+ return request(path);
+ },
+ post(path: string, body: unknown) {
+ return request(path, { method: "POST", body: JSON.stringify(body) });
+ },
+};
diff --git a/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts b/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts
new file mode 100644
index 0000000..84934e3
--- /dev/null
+++ b/eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts
@@ -0,0 +1,6 @@
+import { apiClient } from "../api/client";
+
+/** Thin data hook — the third wrapper layer: useApi → apiClient.get → request → fetch. */
+export function useApi(path: string) {
+ return apiClient.get(path);
+}
diff --git a/eval/fixtures/c2-api-wrapper/golden.json b/eval/fixtures/c2-api-wrapper/golden.json
new file mode 100644
index 0000000..7bfe953
--- /dev/null
+++ b/eval/fixtures/c2-api-wrapper/golden.json
@@ -0,0 +1,35 @@
+{
+ "failureMode": "C2",
+ "note": "Three wrapper layers: useApi -> apiClient.get -> request -> fetch(`${API_BASE}${path}`). Call sites must resolve through the whole chain with the base folded in: useApi('/projects') → /api/projects. The wrapper bodies themselves must NOT emit placeholder (:path) data sources.",
+ "expect": {
+ "components": [
+ { "name": "ProjectsPage", "instances": 0 },
+ { "name": "CreateProjectButton", "instances": 0 }
+ ],
+ "attributions": [
+ { "component": "ProjectsPage", "endpoints": ["/api/projects"] },
+ { "component": "CreateProjectButton", "endpoints": ["/api/projects"] }
+ ],
+ "forbidden": [
+ {
+ "component": "ProjectsPage",
+ "endpoint": ":path",
+ "note": "poison: wrapper-internal placeholder leaked to a consumer"
+ },
+ {
+ "component": "ProjectsPage",
+ "endpoint": "/api:path",
+ "note": "poison: partially-composed wrapper template leaked to a consumer"
+ },
+ {
+ "component": "ProjectsPage",
+ "endpoint": "",
+ "note": "wrapper call collapsed to dynamic means the adapter regressed"
+ }
+ ],
+ "queries": [
+ { "terms": ["Projects overview"], "status": "ok", "top": "ProjectsPage" },
+ { "terms": ["New project"], "status": "ok", "top": "CreateProjectButton" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index 0840169..2510e41 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -1,2 +1,3 @@
{"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}
diff --git a/eval/thresholds.json b/eval/thresholds.json
index 01e8e4c..a34d44b 100644
--- a/eval/thresholds.json
+++ b/eval/thresholds.json
@@ -1,7 +1,7 @@
{
"maxFail": 0,
"maxUnexpectedPass": 0,
- "minMatchAccuracy": 1.0,
+ "minMatchAccuracy": 1,
"minLineagePrecision": 0.9,
- "minLineageRecall": 0.8
+ "minLineageRecall": 0.85
}
diff --git a/packages/parser-react/src/endpoint.ts b/packages/parser-react/src/endpoint.ts
index 314cc0a..1b860fc 100644
--- a/packages/parser-react/src/endpoint.ts
+++ b/packages/parser-react/src/endpoint.ts
@@ -12,7 +12,7 @@
*/
import type { EndpointResolution } from "@coderadar/core";
-import { Node, SyntaxKind } from "ts-morph";
+import { type CallExpression, Node, SyntaxKind } from "ts-morph";
export interface ResolvedEndpoint {
endpoint: string;
@@ -155,6 +155,34 @@ function resolveObjectLiteral(node: Node | undefined, depth: number): import("ts
return null;
}
+/**
+ * Resolve a URL expression inside a wrapper body, where the wrapper's own
+ * parameters become :param placeholders. `request(path)` with body
+ * `fetch(\`${API_BASE}${path}\`)` yields "/api:path". Null when the
+ * expression has no statically-known shape at all.
+ */
+export function resolveUrlTemplate(node: Node, paramNames: ReadonlySet): string | null {
+ if (Node.isIdentifier(node) && paramNames.has(node.getText())) {
+ return `:${node.getText()}`;
+ }
+ const full = resolveStringValue(node, 0);
+ if (full !== null) return full;
+ return resolvePattern(node);
+}
+
+/** The statically-visible HTTP method of a fetch(url, { method: ... }) call. */
+export function fetchMethod(call: CallExpression): string {
+ const optionsArg = call.getArguments()[1];
+ if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) {
+ const methodProp = optionsArg.getProperty("method");
+ if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) {
+ const value = resolveStringValue(methodProp.getInitializer(), 0);
+ if (value !== null) return value.toUpperCase();
+ }
+ }
+ return "GET";
+}
+
function stripBaseUrls(endpoint: string, baseUrls: string[]): string {
for (const base of baseUrls) {
if (base.length > 0 && endpoint.startsWith(base)) {
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index dd8bf2b..852c258 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -24,7 +24,8 @@ import {
type VariableDeclaration,
} from "ts-morph";
-import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
+import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
+import { detectWrappers, type WrapperRegistry } from "./wrappers.js";
export interface ScanOptions {
/** Directory to scan. */
@@ -37,6 +38,11 @@ export interface ScanOptions {
* environment-independent.
*/
baseUrls?: string[];
+ /**
+ * Explicitly-declared API wrapper callees (e.g. ["http.get", "api.post"])
+ * for clients the heuristic can't see. Heuristic detection runs regardless.
+ */
+ apiWrappers?: string[];
}
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -90,6 +96,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
]);
}
+ const wrappers = detectWrappers(project, options.apiWrappers ?? []);
const nodes = new Map();
const edges: LineageEdge[] = [];
const pendingInstances: PendingInstance[] = [];
@@ -125,7 +132,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName });
}
- extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls);
+ extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls, wrappers);
}
}
@@ -266,11 +273,17 @@ function extractBodyFacts(
nodes: Map,
addEdge: (edge: LineageEdge) => void,
baseUrls: string[],
+ wrappers: WrapperRegistry,
): void {
+ // A wrapper's own body is plumbing: its URL is a parameter placeholder, so a
+ // data source emitted here would attribute ":path" to every consumer. Call
+ // sites get the real, substituted endpoint instead.
+ const declIsWrapper = wrappers.has(decl.name);
+
for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpression().getText();
- const dataSource = detectDataSource(call, callee, baseUrls);
+ const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers);
if (dataSource !== null) {
const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`);
if (!nodes.has(dsId)) {
@@ -341,9 +354,26 @@ function detectDataSource(
call: CallExpression,
callee: string,
baseUrls: string[],
+ wrappers: WrapperRegistry,
): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null {
const firstArg = call.getArguments()[0];
+ const wrapper = wrappers.get(callee);
+ if (wrapper !== undefined) {
+ const pathArg = call.getArguments()[wrapper.pathParamIndex];
+ const resolved = resolveEndpoint(pathArg, baseUrls);
+ const substitution =
+ resolved.resolved === "none" ? `:${wrapper.paramName}` : resolved.endpoint;
+ const endpoint = wrapper.template.replace(`:${wrapper.paramName}`, substitution);
+ return {
+ sourceKind: wrapper.sourceKind,
+ method: wrapper.method,
+ endpoint,
+ raw: call.getText().slice(0, 120),
+ resolved: endpoint.includes(":") ? "partial" : "full",
+ };
+ }
+
if (callee === "fetch") {
return {
sourceKind: "fetch",
@@ -384,31 +414,6 @@ function detectDataSource(
return null;
}
-function fetchMethod(call: CallExpression): string {
- const optionsArg = call.getArguments()[1];
- if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) {
- const methodProp = optionsArg.getProperty("method");
- if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) {
- const value = methodProp.getInitializer();
- const literal = literalText(value);
- if (literal !== null) return literal.toUpperCase();
- }
- }
- return "GET";
-}
-
-/** String literal or template text (with `${...}` placeholders preserved). */
-function literalText(node: Node | undefined): string | null {
- if (node === undefined) return null;
- if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
- return node.getLiteralValue();
- }
- if (Node.isTemplateExpression(node)) {
- return node.getText().slice(1, -1); // keep ${...} placeholders, drop backticks
- }
- return null;
-}
-
function detectState(callee: string): "useState" | "useReducer" | "context" | "redux" | "zustand" | null {
switch (callee) {
case "useState":
diff --git a/packages/parser-react/src/wrappers.test.ts b/packages/parser-react/src/wrappers.test.ts
new file mode 100644
index 0000000..642a646
--- /dev/null
+++ b/packages/parser-react/src/wrappers.test.ts
@@ -0,0 +1,81 @@
+import { Project } from "ts-morph";
+import { describe, expect, it } from "vitest";
+
+import { detectWrappers } from "./wrappers.js";
+
+function projectWith(code: string): Project {
+ const project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } });
+ project.createSourceFile("client.ts", code);
+ return project;
+}
+
+describe("detectWrappers", () => {
+ it("detects a plain function wrapper over fetch", () => {
+ const registry = detectWrappers(
+ projectWith(`function apiGet(path: string) { return fetch(path); }`),
+ [],
+ );
+ expect(registry.get("apiGet")).toMatchObject({
+ paramName: "path",
+ pathParamIndex: 0,
+ template: ":path",
+ method: "GET",
+ sourceKind: "fetch",
+ });
+ });
+
+ it("captures a URL prefix from the wrapper body", () => {
+ const registry = detectWrappers(
+ projectWith(
+ `const BASE = "/api";
+ function request(path: string) { return fetch(\`\${BASE}\${path}\`); }`,
+ ),
+ [],
+ );
+ expect(registry.get("request")?.template).toBe("/api:path");
+ });
+
+ it("detects object-literal methods and infers method from the name suffix", () => {
+ const registry = detectWrappers(
+ projectWith(
+ `function request(path: string, init?: RequestInit) { return fetch(path, init); }
+ const apiClient = {
+ get(path: string) { return request(path); },
+ post(path: string, body: unknown) { return request(path, { method: "POST" }); },
+ };`,
+ ),
+ [],
+ );
+ expect(registry.get("apiClient.get")?.method).toBe("GET");
+ expect(registry.get("apiClient.post")?.method).toBe("POST");
+ });
+
+ it("composes templates through a three-layer chain", () => {
+ const registry = detectWrappers(
+ projectWith(
+ `const BASE = "/api";
+ function request(path: string) { return fetch(\`\${BASE}\${path}\`); }
+ const apiClient = { get(p: string) { return request(p); } };
+ function useApi(route: string) { return apiClient.get(route); }`,
+ ),
+ [],
+ );
+ expect(registry.get("request")?.template).toBe("/api:path");
+ expect(registry.get("apiClient.get")?.template).toBe("/api:p");
+ expect(registry.get("useApi")?.template).toBe("/api:route");
+ });
+
+ it("registers configured wrappers without needing their source", () => {
+ const registry = detectWrappers(projectWith(`export {};`), ["http.post", "sdk.request"]);
+ expect(registry.get("http.post")).toMatchObject({ template: ":path", method: "POST" });
+ expect(registry.get("sdk.request")).toMatchObject({ template: ":path", method: null });
+ });
+
+ it("ignores functions that never reach a data source", () => {
+ const registry = detectWrappers(
+ projectWith(`function formatPath(path: string) { return path.trim(); }`),
+ [],
+ );
+ expect(registry.has("formatPath")).toBe(false);
+ });
+});
diff --git a/packages/parser-react/src/wrappers.ts b/packages/parser-react/src/wrappers.ts
new file mode 100644
index 0000000..40d6aac
--- /dev/null
+++ b/packages/parser-react/src/wrappers.ts
@@ -0,0 +1,189 @@
+/**
+ * API-client wrapper detection (TRACKER step 1.2, failure mode C2).
+ *
+ * Real codebases route every request through wrapper layers:
+ *
+ * useApi("/projects") → apiClient.get(path) → request(path) → fetch(`${API_BASE}${path}`)
+ *
+ * A wrapper is any named callable whose body reaches fetch/axios (or another
+ * wrapper) with one of its own parameters inside the URL. Detection composes
+ * templates through the chain, so a call site's argument substitutes all the
+ * way down: apiClient.get("/projects") → "/api/projects".
+ *
+ * Wrappers can also be declared explicitly via ScanOptions.apiWrappers
+ * (e.g. ["http.get", "api.post"]) for clients the heuristic can't see
+ * (imported from node_modules, class-based, etc.).
+ */
+
+import type { DataSourceKind } from "@coderadar/core";
+import { Node, type Project, SyntaxKind } from "ts-morph";
+
+import { fetchMethod, resolveUrlTemplate } from "./endpoint.js";
+
+export interface WrapperInfo {
+ /** How call sites reference it: "request", "apiClient.get", "useApi". */
+ callee: string;
+ /** Name and position of the path parameter. */
+ paramName: string;
+ pathParamIndex: number;
+ /**
+ * Endpoint pattern with ":" marking where the call-site argument
+ * lands, e.g. "/api:path". Composed through wrapper chains.
+ */
+ template: string;
+ method: string | null;
+ sourceKind: DataSourceKind;
+}
+
+export type WrapperRegistry = ReadonlyMap;
+
+/** How many wrapper layers detection follows (useApi → apiClient.get → request). */
+const MAX_CHAIN_ROUNDS = 3;
+
+const METHOD_SUFFIX = /(get|post|put|patch|delete|head|options)$/i;
+
+interface Callable {
+ callee: string;
+ body: Node;
+ paramNames: string[];
+}
+
+export function detectWrappers(project: Project, configured: string[]): WrapperRegistry {
+ const registry = new Map();
+
+ for (const callee of configured) {
+ registry.set(callee, {
+ callee,
+ paramName: "path",
+ pathParamIndex: 0,
+ template: ":path",
+ method: suffixMethod(callee),
+ sourceKind: "unknown",
+ });
+ }
+
+ const callables = collectCallables(project);
+ for (let round = 0; round < MAX_CHAIN_ROUNDS; round += 1) {
+ let changed = false;
+ for (const callable of callables) {
+ if (registry.has(callable.callee)) continue;
+ const info = classifyWrapper(callable, registry);
+ if (info !== null) {
+ registry.set(callable.callee, info);
+ changed = true;
+ }
+ }
+ if (!changed) break;
+ }
+ return registry;
+}
+
+/** Every named callable a call site could reference: functions, const fns, object methods. */
+function collectCallables(project: Project): Callable[] {
+ const callables: Callable[] = [];
+ for (const sourceFile of project.getSourceFiles()) {
+ for (const fn of sourceFile.getFunctions()) {
+ const name = fn.getName();
+ const body = fn.getBody();
+ if (name === undefined || body === undefined) continue;
+ callables.push({ callee: name, body, paramNames: fn.getParameters().map((p) => p.getName()) });
+ }
+ for (const variable of sourceFile.getVariableDeclarations()) {
+ const init = variable.getInitializer();
+ if (init === undefined) continue;
+ if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
+ callables.push({
+ callee: variable.getName(),
+ body: init.getBody() ?? init,
+ paramNames: init.getParameters().map((p) => p.getName()),
+ });
+ } else if (Node.isObjectLiteralExpression(init)) {
+ for (const property of init.getProperties()) {
+ if (Node.isMethodDeclaration(property)) {
+ const body = property.getBody();
+ if (body === undefined) continue;
+ callables.push({
+ callee: `${variable.getName()}.${property.getName()}`,
+ body,
+ paramNames: property.getParameters().map((p) => p.getName()),
+ });
+ } else if (Node.isPropertyAssignment(property)) {
+ const value = property.getInitializer();
+ if (value !== undefined && (Node.isArrowFunction(value) || Node.isFunctionExpression(value))) {
+ callables.push({
+ callee: `${variable.getName()}.${property.getName()}`,
+ body: value.getBody() ?? value,
+ paramNames: value.getParameters().map((p) => p.getName()),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ return callables;
+}
+
+function classifyWrapper(callable: Callable, registry: WrapperRegistry): WrapperInfo | null {
+ if (callable.paramNames.length === 0) return null;
+ const paramSet = new Set(callable.paramNames);
+
+ for (const call of callable.body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ const callee = call.getExpression().getText();
+
+ let urlIndex: number;
+ let innerTemplate: string | null = null;
+ let innerParam: string | null = null;
+ let innerMethod: string | null;
+ let sourceKind: DataSourceKind;
+
+ const axiosMatch = /^axios(?:\.(\w+))?$/.exec(callee);
+ const inner = registry.get(callee);
+ if (callee === "fetch") {
+ urlIndex = 0;
+ innerMethod = fetchMethod(call);
+ sourceKind = "fetch";
+ } else if (axiosMatch !== null) {
+ urlIndex = 0;
+ innerMethod = axiosMatch[1] !== undefined ? axiosMatch[1].toUpperCase() : null;
+ sourceKind = "axios";
+ } else if (inner !== undefined) {
+ urlIndex = inner.pathParamIndex;
+ innerTemplate = inner.template;
+ innerParam = inner.paramName;
+ innerMethod = inner.method;
+ sourceKind = inner.sourceKind;
+ } else {
+ continue;
+ }
+
+ const urlArg = call.getArguments()[urlIndex];
+ if (urlArg === undefined) continue;
+ const pattern = resolveUrlTemplate(urlArg, paramSet);
+ if (pattern === null) continue;
+ const usedParam = callable.paramNames.find((p) => pattern.includes(`:${p}`));
+ if (usedParam === undefined) continue;
+
+ const template =
+ innerTemplate !== null && innerParam !== null
+ ? innerTemplate.replace(`:${innerParam}`, pattern)
+ : pattern;
+
+ return {
+ callee: callable.callee,
+ paramName: usedParam,
+ pathParamIndex: callable.paramNames.indexOf(usedParam),
+ template,
+ method: suffixMethod(callable.callee) ?? innerMethod,
+ sourceKind,
+ };
+ }
+ return null;
+}
+
+/** "apiClient.post" → "POST"; "request" → null. */
+function suffixMethod(callee: string): string | null {
+ const lastSegment = callee.split(".").pop() ?? callee;
+ const match = METHOD_SUFFIX.exec(lastSegment);
+ return match?.[1] !== undefined ? match[1].toUpperCase() : null;
+}