From 44e439834950a42645eb659bb8c387cb5469d03e Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:22:22 +0530 Subject: [PATCH] =?UTF-8?q?feat(parser-react):=20API-client=20wrapper=20ad?= =?UTF-8?q?apter=20=E2=80=94=20heuristic=20detection=20+=20config,=20chain?= =?UTF-8?q?s=20to=20depth=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.2 (TRACKER). Failure mode C2 (wrapper half): - detectWrappers: any named callable (function, const fn, object-literal method) whose body reaches fetch/axios/another wrapper with one of its own params in the URL is classified as a wrapper. Templates compose through chains (useApi → apiClient.get → request → fetch), so a call site's argument substitutes all the way down with constants folded: useApi('/projects') → /api/projects. - Method inference: name suffix (apiClient.post → POST) beats inner method. - ScanOptions.apiWrappers declares wrappers the heuristic can't see. - Wrapper bodies are plumbing: their own internal fetch emits no data source, so ':path' placeholders never leak into consumer attribution (fixture has forbidden assertions for exactly that poison). - Thresholds ratcheted: minLineageRecall 0.85. Scorecard: 47 pass / 0 fail / 2 xfail; recall 0.857. 39 unit tests (6 new wrapper tests). Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../app/CreateProjectButton.tsx | 9 + .../c2-api-wrapper/app/ProjectsPage.tsx | 16 ++ .../fixtures/c2-api-wrapper/app/api/client.ts | 16 ++ .../c2-api-wrapper/app/hooks/useApi.ts | 6 + eval/fixtures/c2-api-wrapper/golden.json | 35 ++++ eval/history.jsonl | 1 + eval/thresholds.json | 4 +- packages/parser-react/src/endpoint.ts | 30 ++- packages/parser-react/src/scan.ts | 61 +++--- packages/parser-react/src/wrappers.test.ts | 81 ++++++++ packages/parser-react/src/wrappers.ts | 189 ++++++++++++++++++ 12 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 eval/fixtures/c2-api-wrapper/app/CreateProjectButton.tsx create mode 100644 eval/fixtures/c2-api-wrapper/app/ProjectsPage.tsx create mode 100644 eval/fixtures/c2-api-wrapper/app/api/client.ts create mode 100644 eval/fixtures/c2-api-wrapper/app/hooks/useApi.ts create mode 100644 eval/fixtures/c2-api-wrapper/golden.json create mode 100644 packages/parser-react/src/wrappers.test.ts create mode 100644 packages/parser-react/src/wrappers.ts 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; +}