Skip to content

Commit fb780e7

Browse files
Merge pull request #8 from officialCodeWork/build/phase-1/step-1.2-api-wrapper
feat(parser-react): API-client wrapper adapter — heuristic detection + config, chains to depth 3
2 parents 13241fc + 44e4398 commit fb780e7

12 files changed

Lines changed: 420 additions & 34 deletions

File tree

TRACKER.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 1 — Robust extraction
8-
- **Next step:** 1.2API-client wrapper adapter
9-
- **Done:** 0.1–0.4, 1.1
8+
- **Next step:** 1.3react-query / SWR queryFn following
9+
- **Done:** 0.1–0.4, 1.1, 1.2
1010
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)
1111

1212
## What CodeRadar is
@@ -97,7 +97,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
9797
- `DataSourceNode` gains `{ pattern: string, resolved: "full" | "partial" | "none", raw: string }`.
9898
**Accept:** fixtures `c2-endpoint-constants`, `c3-dynamic-endpoints` green; lineage precision holds ≥ 0.90 on all existing fixtures.
9999

100-
### [ ] 1.2 API-client wrapper adapter
100+
### [x] 1.2 API-client wrapper adapter
101101
**Failure modes:** C2 (wrapper half)
102102
**Build:**
103103
- 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.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { apiClient } from "./api/client";
2+
3+
export function CreateProjectButton() {
4+
const handleCreate = () => {
5+
apiClient.post("/projects", { name: "Untitled" });
6+
};
7+
8+
return <button onClick={handleCreate}>New project</button>;
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useApi } from "./hooks/useApi";
2+
3+
export function ProjectsPage() {
4+
const projects = useApi("/projects") as unknown as string[];
5+
6+
return (
7+
<main>
8+
<h1>Projects overview</h1>
9+
<ul>
10+
{projects.map((p) => (
11+
<li key={p}>{p}</li>
12+
))}
13+
</ul>
14+
</main>
15+
);
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const API_BASE = "/api";
2+
3+
async function request(path: string, init?: RequestInit) {
4+
const res = await fetch(`${API_BASE}${path}`, init);
5+
if (!res.ok) throw new Error(`request failed: ${res.status}`);
6+
return res.json();
7+
}
8+
9+
export const apiClient = {
10+
get(path: string) {
11+
return request(path);
12+
},
13+
post(path: string, body: unknown) {
14+
return request(path, { method: "POST", body: JSON.stringify(body) });
15+
},
16+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { apiClient } from "../api/client";
2+
3+
/** Thin data hook — the third wrapper layer: useApi → apiClient.get → request → fetch. */
4+
export function useApi(path: string) {
5+
return apiClient.get(path);
6+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"failureMode": "C2",
3+
"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.",
4+
"expect": {
5+
"components": [
6+
{ "name": "ProjectsPage", "instances": 0 },
7+
{ "name": "CreateProjectButton", "instances": 0 }
8+
],
9+
"attributions": [
10+
{ "component": "ProjectsPage", "endpoints": ["/api/projects"] },
11+
{ "component": "CreateProjectButton", "endpoints": ["/api/projects"] }
12+
],
13+
"forbidden": [
14+
{
15+
"component": "ProjectsPage",
16+
"endpoint": ":path",
17+
"note": "poison: wrapper-internal placeholder leaked to a consumer"
18+
},
19+
{
20+
"component": "ProjectsPage",
21+
"endpoint": "/api:path",
22+
"note": "poison: partially-composed wrapper template leaked to a consumer"
23+
},
24+
{
25+
"component": "ProjectsPage",
26+
"endpoint": "<dynamic>",
27+
"note": "wrapper call collapsed to dynamic means the adapter regressed"
28+
}
29+
],
30+
"queries": [
31+
{ "terms": ["Projects overview"], "status": "ok", "top": "ProjectsPage" },
32+
{ "terms": ["New project"], "status": "ok", "top": "CreateProjectButton" }
33+
]
34+
}
35+
}

eval/history.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
{"generatedAt":"2026-07-12T20:53:38.653Z","commitSha":"25e035d50ddddb72c9e8c02febc9785e7a64bb3c","pass":25,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.714,"matchAccuracy":1}
22
{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1}
3+
{"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1}

eval/thresholds.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"maxFail": 0,
33
"maxUnexpectedPass": 0,
4-
"minMatchAccuracy": 1.0,
4+
"minMatchAccuracy": 1,
55
"minLineagePrecision": 0.9,
6-
"minLineageRecall": 0.8
6+
"minLineageRecall": 0.85
77
}

packages/parser-react/src/endpoint.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
*/
1313

1414
import type { EndpointResolution } from "@coderadar/core";
15-
import { Node, SyntaxKind } from "ts-morph";
15+
import { type CallExpression, Node, SyntaxKind } from "ts-morph";
1616

1717
export interface ResolvedEndpoint {
1818
endpoint: string;
@@ -155,6 +155,34 @@ function resolveObjectLiteral(node: Node | undefined, depth: number): import("ts
155155
return null;
156156
}
157157

158+
/**
159+
* Resolve a URL expression inside a wrapper body, where the wrapper's own
160+
* parameters become :param placeholders. `request(path)` with body
161+
* `fetch(\`${API_BASE}${path}\`)` yields "/api:path". Null when the
162+
* expression has no statically-known shape at all.
163+
*/
164+
export function resolveUrlTemplate(node: Node, paramNames: ReadonlySet<string>): string | null {
165+
if (Node.isIdentifier(node) && paramNames.has(node.getText())) {
166+
return `:${node.getText()}`;
167+
}
168+
const full = resolveStringValue(node, 0);
169+
if (full !== null) return full;
170+
return resolvePattern(node);
171+
}
172+
173+
/** The statically-visible HTTP method of a fetch(url, { method: ... }) call. */
174+
export function fetchMethod(call: CallExpression): string {
175+
const optionsArg = call.getArguments()[1];
176+
if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) {
177+
const methodProp = optionsArg.getProperty("method");
178+
if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) {
179+
const value = resolveStringValue(methodProp.getInitializer(), 0);
180+
if (value !== null) return value.toUpperCase();
181+
}
182+
}
183+
return "GET";
184+
}
185+
158186
function stripBaseUrls(endpoint: string, baseUrls: string[]): string {
159187
for (const base of baseUrls) {
160188
if (base.length > 0 && endpoint.startsWith(base)) {

packages/parser-react/src/scan.ts

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
type VariableDeclaration,
2525
} from "ts-morph";
2626

27-
import { resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
27+
import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
28+
import { detectWrappers, type WrapperRegistry } from "./wrappers.js";
2829

2930
export interface ScanOptions {
3031
/** Directory to scan. */
@@ -37,6 +38,11 @@ export interface ScanOptions {
3738
* environment-independent.
3839
*/
3940
baseUrls?: string[];
41+
/**
42+
* Explicitly-declared API wrapper callees (e.g. ["http.get", "api.post"])
43+
* for clients the heuristic can't see. Heuristic detection runs regardless.
44+
*/
45+
apiWrappers?: string[];
4046
}
4147

4248
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -90,6 +96,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
9096
]);
9197
}
9298

99+
const wrappers = detectWrappers(project, options.apiWrappers ?? []);
93100
const nodes = new Map<string, LineageNode>();
94101
const edges: LineageEdge[] = [];
95102
const pendingInstances: PendingInstance[] = [];
@@ -125,7 +132,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
125132
nodes.set(id, { id, kind: "hook", name: decl.name, loc: decl.loc, exportName: decl.exportName });
126133
}
127134

128-
extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls);
135+
extractBodyFacts(decl, id, file, nodes, addEdge, baseUrls, wrappers);
129136
}
130137
}
131138

@@ -266,11 +273,17 @@ function extractBodyFacts(
266273
nodes: Map<string, LineageNode>,
267274
addEdge: (edge: LineageEdge) => void,
268275
baseUrls: string[],
276+
wrappers: WrapperRegistry,
269277
): void {
278+
// A wrapper's own body is plumbing: its URL is a parameter placeholder, so a
279+
// data source emitted here would attribute ":path" to every consumer. Call
280+
// sites get the real, substituted endpoint instead.
281+
const declIsWrapper = wrappers.has(decl.name);
282+
270283
for (const call of decl.fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
271284
const callee = call.getExpression().getText();
272285

273-
const dataSource = detectDataSource(call, callee, baseUrls);
286+
const dataSource = declIsWrapper ? null : detectDataSource(call, callee, baseUrls, wrappers);
274287
if (dataSource !== null) {
275288
const dsId = nodeId("data-source", file, `${dataSource.sourceKind}:${dataSource.endpoint}`);
276289
if (!nodes.has(dsId)) {
@@ -341,9 +354,26 @@ function detectDataSource(
341354
call: CallExpression,
342355
callee: string,
343356
baseUrls: string[],
357+
wrappers: WrapperRegistry,
344358
): ({ sourceKind: DataSourceKind; method: string | null } & ResolvedEndpoint) | null {
345359
const firstArg = call.getArguments()[0];
346360

361+
const wrapper = wrappers.get(callee);
362+
if (wrapper !== undefined) {
363+
const pathArg = call.getArguments()[wrapper.pathParamIndex];
364+
const resolved = resolveEndpoint(pathArg, baseUrls);
365+
const substitution =
366+
resolved.resolved === "none" ? `:${wrapper.paramName}` : resolved.endpoint;
367+
const endpoint = wrapper.template.replace(`:${wrapper.paramName}`, substitution);
368+
return {
369+
sourceKind: wrapper.sourceKind,
370+
method: wrapper.method,
371+
endpoint,
372+
raw: call.getText().slice(0, 120),
373+
resolved: endpoint.includes(":") ? "partial" : "full",
374+
};
375+
}
376+
347377
if (callee === "fetch") {
348378
return {
349379
sourceKind: "fetch",
@@ -384,31 +414,6 @@ function detectDataSource(
384414
return null;
385415
}
386416

387-
function fetchMethod(call: CallExpression): string {
388-
const optionsArg = call.getArguments()[1];
389-
if (optionsArg !== undefined && Node.isObjectLiteralExpression(optionsArg)) {
390-
const methodProp = optionsArg.getProperty("method");
391-
if (methodProp !== undefined && Node.isPropertyAssignment(methodProp)) {
392-
const value = methodProp.getInitializer();
393-
const literal = literalText(value);
394-
if (literal !== null) return literal.toUpperCase();
395-
}
396-
}
397-
return "GET";
398-
}
399-
400-
/** String literal or template text (with `${...}` placeholders preserved). */
401-
function literalText(node: Node | undefined): string | null {
402-
if (node === undefined) return null;
403-
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
404-
return node.getLiteralValue();
405-
}
406-
if (Node.isTemplateExpression(node)) {
407-
return node.getText().slice(1, -1); // keep ${...} placeholders, drop backticks
408-
}
409-
return null;
410-
}
411-
412417
function detectState(callee: string): "useState" | "useReducer" | "context" | "redux" | "zustand" | null {
413418
switch (callee) {
414419
case "useState":

0 commit comments

Comments
 (0)