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.2API-client wrapper adapter
- **Done:** 0.1–0.4, 1.1
- **Next step:** 1.3react-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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { apiClient } from "./api/client";

export function CreateProjectButton() {
const handleCreate = () => {
apiClient.post("/projects", { name: "Untitled" });
};

return <button onClick={handleCreate}>New project</button>;
}
16 changes: 16 additions & 0 deletions eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useApi } from "./hooks/useApi";

export function ProjectsPage() {
const projects = useApi("/projects") as unknown as string[];

return (
<main>
<h1>Projects overview</h1>
<ul>
{projects.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</main>
);
}
16 changes: 16 additions & 0 deletions eval/fixtures/c2-api-wrapper/app/api/client.ts
Original file line number Diff line number Diff line change
@@ -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) });
},
};
6 changes: 6 additions & 0 deletions eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts
Original file line number Diff line number Diff line change
@@ -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);
}
35 changes: 35 additions & 0 deletions eval/fixtures/c2-api-wrapper/golden.json
Original file line number Diff line number Diff line change
@@ -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": "<dynamic>",
"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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
@@ -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}
4 changes: 2 additions & 2 deletions eval/thresholds.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"maxFail": 0,
"maxUnexpectedPass": 0,
"minMatchAccuracy": 1.0,
"minMatchAccuracy": 1,
"minLineagePrecision": 0.9,
"minLineageRecall": 0.8
"minLineageRecall": 0.85
}
30 changes: 29 additions & 1 deletion packages/parser-react/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>): 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)) {
Expand Down
61 changes: 33 additions & 28 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -90,6 +96,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
]);
}

const wrappers = detectWrappers(project, options.apiWrappers ?? []);
const nodes = new Map<string, LineageNode>();
const edges: LineageEdge[] = [];
const pendingInstances: PendingInstance[] = [];
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -266,11 +273,17 @@ function extractBodyFacts(
nodes: Map<string, LineageNode>,
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)) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand Down
81 changes: 81 additions & 0 deletions packages/parser-react/src/wrappers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading