diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 403beaf0d..0bafb7a7e 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -9,18 +9,26 @@ import { import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** - * Kotlin HTTP plugin (Spring providers). + * Kotlin HTTP plugin (Spring providers + consumers). * - * Mirrors the Java plugin for Spring `@RequestMapping` class prefixes - * and `@(Get|Post|...)Mapping` method annotations on Kotlin Spring - * Boot controllers. Both positional shorthand (`@GetMapping("/x")`) - * and named annotation arguments (`@GetMapping(value = "/x")` and + * **Providers** (#1849) — Spring `@RequestMapping` class prefixes and + * `@(Get|Post|...)Mapping` method annotations on Kotlin Spring Boot + * controllers. Both positional shorthand (`@GetMapping("/x")`) and + * named annotation arguments (`@GetMapping(value = "/x")` and * `@GetMapping(path = "/x")`) are supported. * - * Consumer detection (RestTemplate / WebClient / OkHttp) is intentionally - * out of scope for this plugin — Kotlin call-site ASTs are sufficiently - * different from Java's `method_invocation` shape that they warrant a - * separate, focused follow-up. + * **Consumers** (this PR) — three call-site patterns common in Kotlin + * Spring projects: + * + * 1. `restTemplate.getForObject("/x", ...)` and friends + * 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop) + * 3. `Request.Builder().url("/x")` (OkHttp) + * + * The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is + * intentionally deferred to a follow-up: it requires walk-up logic + * to recover the verb from a sibling `call_expression`, and we can + * land 80% of real-world Kotlin Spring consumer coverage with the + * three simpler patterns above. * * tree-sitter-kotlin (fwcd) AST shapes used here: * class_declaration @@ -34,6 +42,20 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * string_literal * type_identifier ← class name * + * Consumer call shape (Kotlin chains everything via `navigation_expression`): + * call_expression ← outer `.uri("/x")` or `.url("/x")` + * navigation_expression + * call_expression ← inner `.get()` / `Request.Builder()` / `restTemplate.x` + * navigation_expression + * simple_identifier ← receiver: `webClient` / `Request` / `restTemplate` + * navigation_suffix ← `.method` / `.Builder` / `.getForObject` + * call_suffix (value_arguments) + * navigation_suffix ← `.uri` / `.url` + * call_suffix + * value_arguments + * value_argument + * string_literal ← the path + * * tree-sitter-kotlin is an optional npm dependency — when its native * binding is unavailable the plugin gracefully exports `null` and * `http-patterns/index.ts` skips registration for `.kt`/`.kts` files. @@ -57,6 +79,36 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { PatchMapping: 'PATCH', }; +/** + * RestTemplate method-name → HTTP verb. Mirrors the Java plugin's + * `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the + * same contract IDs from .java and .kt sources. + */ +const REST_TEMPLATE_TO_HTTP: Record = { + getForObject: 'GET', + getForEntity: 'GET', + postForObject: 'POST', + postForEntity: 'POST', + put: 'PUT', + delete: 'DELETE', + patchForObject: 'PATCH', +}; + +/** + * WebClient short-form verb → HTTP verb. The reactive WebClient API + * exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as + * one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)` + * carries the path. We capture both pieces in a single query (see + * `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here. + */ +const WEB_CLIENT_SHORT_TO_HTTP: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + /** * Build the plugin only if the Kotlin grammar is available. Compiling * the queries against a null grammar would throw at module load time @@ -157,6 +209,130 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } satisfies LanguagePatterns>); + // ─── Consumer: Spring RestTemplate ──────────────────────────────────── + // Kotlin call-site shape mirrors the Java plugin's + // `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's + // `navigation_expression` instead of Java's `method_invocation`: + // + // restTemplate.getForObject("/x", User::class.java) + // + // becomes + // + // call_expression + // navigation_expression + // simple_identifier "restTemplate" + // navigation_suffix → simple_identifier "getForObject" + // call_suffix + // value_arguments + // value_argument . string_literal "/x" ← captured + // value_argument User::class.java + // + // The receiver name is constrained to `restTemplate` (#eq? @obj), + // matching the Java plugin's heuristic. This means a non-conventional + // field name (e.g. `userServiceTemplate`) will not be picked up; + // that's the same trade-off already accepted on the Java side. + const REST_TEMPLATE_PATTERNS = compilePatterns({ + name: 'kotlin-rest-template', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "restTemplate") + (navigation_suffix (simple_identifier) @method)) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: Spring WebClient (short form) ────────────────────────── + // Reactive WebClient exposes one-liner verb helpers: + // + // webClient.get().uri("/x").retrieve().awaitBody() + // webClient.post().uri("/x")... + // + // The chain `webClient.get().uri("/x")` parses as two nested + // `call_expression` nodes — the OUTER call is `.uri("/x")` and the + // INNER call is `webClient.get()`. We anchor on the outer call and + // require: + // - inner receiver is `webClient` + // - inner suffix is one of the HTTP verbs (#match?) + // - outer suffix is exactly `uri` + // - outer call's first value_argument is a string literal + // + // The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain + // uses an extra navigation hop and an enum field access — it's + // intentionally out of scope here (see file header). + const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({ + name: 'kotlin-web-client-short', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "webClient") + (navigation_suffix + (simple_identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$"))) + (call_suffix (value_arguments))) + (navigation_suffix (simple_identifier) @uri (#eq? @uri "uri"))) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: OkHttp Request.Builder().url("/x") ───────────────────── + // Kotlin parses `Request.Builder()` as a `call_expression` whose + // callee is a `navigation_expression` (Request → .Builder), NOT as + // Java's `object_creation_expression`. The chain `.url("/x")` then + // wraps that in another `call_expression`. The query mirrors Java's + // `OK_HTTP_PATTERNS` (java.ts) but adapts the node types. + // + // Receiver `Request` is constrained by name (#eq? @cls); a project + // that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`) + // would not be picked up — this matches the Java plugin's heuristic. + // + // **Known limitation — verb defaults to GET.** OkHttp encodes the + // verb on a *sibling* call further down the builder chain (e.g. + // `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself. + // This query intentionally does not walk the chain to recover the + // verb — it emits `method: 'GET'` for every match, mirroring + // `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()` + // call becomes `http::GET::/x`, not `http::POST::/x`. This is the + // same trade-off Java has accepted; pinned by an anti-overreach + // test in `http-route-extractor.test.ts` so a future verb-walk + // implementation has to update this comment in lockstep. + const OK_HTTP_PATTERNS = compilePatterns({ + name: 'kotlin-okhttp', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @cls (#eq? @cls "Request") + (navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder"))) + (call_suffix (value_arguments))) + (navigation_suffix (simple_identifier) @method (#eq? @method "url"))) + (call_suffix + (value_arguments . (value_argument . (string_literal) @path)))) + `, + }, + ], + } satisfies LanguagePatterns>); + /** * Find the nearest enclosing class_declaration ancestor for a node, or * null if the node is top-level. Mirrors the Java plugin's helper. @@ -223,6 +399,60 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { }); } + // ─── Consumers: RestTemplate ──────────────────────────────────── + for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) { + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-rest-template', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: WebClient short form (.get()/.post()/etc → .uri) ─ + for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── + for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'okhttp', + method: 'GET', + path, + name: null, + confidence: 0.7, + }); + } + return out; }, }; diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index bc4911546..3c20101db 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -21,6 +21,7 @@ import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/as import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; +import { synthesizeDependsReferences } from './depends-references.js'; import { computePythonArityMetadata } from './arity-metadata.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; @@ -98,6 +99,7 @@ export function emitPythonScopeCaptures( if (fnNode !== null) { const synth = synthesizeReceiverTypeBinding(fnNode); if (synth !== null) out.push(synth); + for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef); } continue; } diff --git a/gitnexus/src/core/ingestion/languages/python/depends-references.ts b/gitnexus/src/core/ingestion/languages/python/depends-references.ts new file mode 100644 index 000000000..333c4f7e2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/python/depends-references.ts @@ -0,0 +1,72 @@ +/** + * Synthesize `@reference.call.free` captures for FastAPI `Depends(callable)` + * parameter defaults. + * + * `Depends(get_db)` passes `get_db` as a callable that the DI framework + * calls on every request. The route handler is functionally a caller of + * the dependency — impact analysis needs that edge. + * + * Tree-sitter can't express "the first argument of a call named Depends + * inside a parameter default" in a single static query, so we synthesize + * reference captures in code, mirroring the receiver-binding pattern. + */ + +import type { CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Inspect a `function_definition` node's parameters for `Depends(callable)` + * defaults. Returns one `@reference.call.free` CaptureMatch per dependency. + */ +export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly CaptureMatch[] { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return []; + + const results: CaptureMatch[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null) continue; + + if (param.type !== 'typed_default_parameter' && param.type !== 'default_parameter') { + continue; + } + + const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default'); + if (defaultValue === null) continue; + + const callNode = defaultValue.type === 'call' ? defaultValue : null; + if (callNode === null) continue; + + const fnIdent = callNode.childForFieldName('function'); + if (fnIdent === null || fnIdent.type !== 'identifier' || fnIdent.text !== 'Depends') continue; + + const args = callNode.childForFieldName('arguments'); + if (args === null || args.namedChildCount === 0) continue; + + const firstArg = args.namedChild(0); + if (firstArg === null) continue; + + if (firstArg.type === 'identifier') { + results.push({ + '@reference.call.free': nodeToCapture('@reference.call.free', firstArg), + '@reference.name': nodeToCapture('@reference.name', firstArg), + }); + continue; + } + + if (firstArg.type === 'attribute') { + const attrName = firstArg.childForFieldName('attribute'); + const obj = firstArg.childForFieldName('object'); + if (attrName !== null && obj !== null) { + results.push({ + '@reference.call.member': nodeToCapture('@reference.call.member', attrName), + '@reference.name': nodeToCapture('@reference.name', attrName), + '@reference.receiver': nodeToCapture('@reference.receiver', obj), + }); + } + } + } + + return results; +} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6465a9782..39d16461a 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -53,6 +53,7 @@ import type { FileConstructorBindings, FileScopeBindings, ExtractedORMQuery, + FetchWrapperDef, } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, @@ -69,6 +70,7 @@ export interface WorkerExtractedData { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; + fetchWrapperDefs: FetchWrapperDef[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; @@ -110,6 +112,7 @@ export const mergeChunkResults = ( const allHeritage: ExtractedHeritage[] = []; const allRoutes: ExtractedRoute[] = []; const allFetchCalls: ExtractedFetchCall[] = []; + const allFetchWrapperDefs: FetchWrapperDef[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; @@ -147,6 +150,7 @@ export const mergeChunkResults = ( for (const item of result.heritage) allHeritage.push(item); for (const item of result.routes) allRoutes.push(item); for (const item of result.fetchCalls) allFetchCalls.push(item); + for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item); for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); for (const item of result.toolDefs) allToolDefs.push(item); if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item); @@ -163,6 +167,7 @@ export const mergeChunkResults = ( heritage: allHeritage, routes: allRoutes, fetchCalls: allFetchCalls, + fetchWrapperDefs: allFetchWrapperDefs, decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, ormQueries: allORMQueries, @@ -203,6 +208,7 @@ const processParsingWithWorkers = async ( heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 4d06b8d58..a37d040e6 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -61,6 +61,7 @@ import type { ExtractedRoute, ExtractedToolDef, FileConstructorBindings, + FetchWrapperDef, } from '../workers/parse-worker.js'; import type { ExtractedHeritage } from '../model/heritage-map.js'; import type { KnowledgeGraph } from '../../graph/types.js'; @@ -141,6 +142,7 @@ export async function runChunkedParseAndResolve( ): Promise<{ exportedTypeMap: ExportedTypeMap; allFetchCalls: ExtractedFetchCall[]; + allFetchWrapperDefs: FetchWrapperDef[]; allExtractedRoutes: ExtractedRoute[]; allDecoratorRoutes: ExtractedDecoratorRoute[]; allToolDefs: ExtractedToolDef[]; @@ -352,6 +354,7 @@ export async function runChunkedParseAndResolve( // it, and later wildcard chunks re-run it themselves. let hasSynthesized = false; const allFetchCalls: ExtractedFetchCall[] = []; + const allFetchWrapperDefs: FetchWrapperDef[] = []; const allExtractedRoutes: ExtractedRoute[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; @@ -663,6 +666,9 @@ export async function runChunkedParseAndResolve( if (chunkWorkerData.fetchCalls?.length) { for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item); } + if (chunkWorkerData.fetchWrapperDefs?.length) { + for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item); + } if (chunkWorkerData.routes?.length) { for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item); } @@ -1082,6 +1088,7 @@ export async function runChunkedParseAndResolve( return { exportedTypeMap, allFetchCalls, + allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, allToolDefs, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index a3fa81be7..280731e68 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -27,6 +27,7 @@ import type { ExtractedDecoratorRoute, ExtractedToolDef, ExtractedORMQuery, + FetchWrapperDef, } from '../workers/parse-worker.js'; import type { createResolutionContext } from '../model/resolution-context.js'; import { runChunkedParseAndResolve } from './parse-impl.js'; @@ -45,6 +46,7 @@ export interface ParseOutput { */ readonly exportedTypeMap: ReadonlyMap>; readonly allFetchCalls: readonly ExtractedFetchCall[]; + readonly allFetchWrapperDefs: readonly FetchWrapperDef[]; readonly allExtractedRoutes: readonly ExtractedRoute[]; readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[]; readonly allToolDefs: readonly ExtractedToolDef[]; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index a1ee73eea..a87b9576d 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -131,6 +131,10 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string | return joined.replace(/\/+/g, '/') || '/'; } +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export const routesPhase: PipelinePhase = { name: 'routes', deps: ['parse'], @@ -142,6 +146,7 @@ export const routesPhase: PipelinePhase = { const { allPaths, allFetchCalls: parseFetchCalls, + allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, } = getPhaseOutput(deps, 'parse'); @@ -357,6 +362,35 @@ export const routesPhase: PipelinePhase = { } } + // ── Cross-file fetch wrapper consumer extraction ── + // When the parse phase discovered functions that internally call fetch(), + // scan JS/TS consumer files for calls to those wrapper functions with + // URL-like string arguments and add them to allFetchCalls so + // processNextjsFetchRoutes can create FETCHES edges. + if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) { + const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName)); + const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p)); + if (jsFiles.length > 0 && wrapperNames.size > 0) { + const jsContents = await readFileContents(ctx.repoPath, jsFiles); + for (const [filePath, content] of jsContents) { + for (const name of wrapperNames) { + const regex = new RegExp( + `\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`, + 'g', + ); + let match; + while ((match = regex.exec(content)) !== null) { + allFetchCalls.push({ + filePath, + fetchURL: match[1], + lineNumber: content.substring(0, match.index).split('\n').length, + }); + } + } + } + } + } + if (routeRegistry.size > 0 && allFetchCalls.length > 0) { const routeURLToFile = new Map(); for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index f67823c8c..f27fcc8ce 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -241,6 +241,12 @@ export const TYPESCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc. +(call_expression + function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$") + arguments: (arguments + (string (string_fragment) @route.url))) @route.fetch + ; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'}) (call_expression function: (member_expression @@ -434,6 +440,12 @@ export const JAVASCRIPT_QUERIES = ` [(string (string_fragment) @route.url) (template_string) @route.template_url])) @route.fetch +; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc. +(call_expression + function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$") + arguments: (arguments + (string (string_fragment) @route.url))) @route.fetch + ; axios.get/post, $.get/post/ajax (call_expression function: (member_expression diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 69e28b91f..f092d4a1a 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -198,6 +198,11 @@ export interface ExtractedFetchCall { lineNumber: number; } +export interface FetchWrapperDef { + filePath: string; + functionName: string; +} + export interface ExtractedDecoratorRoute { filePath: string; routePath: string; @@ -268,6 +273,7 @@ export interface ParseWorkerResult { heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; + fetchWrapperDefs: FetchWrapperDef[]; decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; @@ -732,6 +738,7 @@ const processBatch = ( heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -842,6 +849,23 @@ const EXPRESS_ROUTE_METHODS = new Set([ 'route', ]); +/** + * Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function. + * Returns `true` if found within `maxDepth` levels of nesting — keeps the check + * lightweight so it doesn't slow down parse-worker on large function bodies. + */ +const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean => { + if (depth > maxDepth) return false; + if (node.type === 'call_expression') { + const fn = node.childForFieldName('function'); + if (fn?.type === 'identifier' && fn.text === 'fetch') return true; + } + for (let i = 0; i < node.childCount; i++) { + if (checkForFetchCall(node.child(i)!, depth + 1, maxDepth)) return true; + } + return false; +}; + // HTTP client methods that are ONLY used by clients, not Express route registration. // Methods like get/post/put/delete/patch overlap with Express — those are captured by // the express_route handler as route definitions, not consumers. The fetch() global @@ -1944,6 +1968,21 @@ const processFileGroup = ( : '', }); } + + // ── Fetch wrapper detection: record functions that call fetch() internally ── + if ( + nodeLabel === 'Function' && + definitionNode && + nameNode && + (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) + ) { + if (checkForFetchCall(definitionNode)) { + result.fetchWrapperDefs.push({ + filePath: file.path, + functionName: nameNode.text, + }); + } + } } // Extract framework routes via provider detection (e.g., Laravel routes.php) @@ -1985,6 +2024,7 @@ let accumulated: ParseWorkerResult = { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -2013,6 +2053,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { appendAll(target.heritage, src.heritage); appendAll(target.routes, src.routes); appendAll(target.fetchCalls, src.fetchCalls); + appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs); appendAll(target.decoratorRoutes, src.decoratorRoutes); appendAll(target.toolDefs, src.toolDefs); appendAll(target.ormQueries, src.ormQueries); @@ -2104,6 +2145,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 680ca1ab2..4139970c6 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -44,7 +44,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j * On version mismatch, `loadParseCache` returns an empty cache and the * next save overwrites the on-disk file with the new version baked in. */ -const SCHEMA_BUMP = 1; +const SCHEMA_BUMP = 2; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py new file mode 100644 index 000000000..6232ec756 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py @@ -0,0 +1,12 @@ +from fastapi import Depends, APIRouter +from app.dependencies import get_current_user_record, get_db, User, Session + +router = APIRouter() + + +@router.get("/calls") +async def list_calls( + user: User = Depends(get_current_user_record), + db: Session = Depends(get_db), +): + return [] diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py new file mode 100644 index 000000000..3f3813d82 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py @@ -0,0 +1,14 @@ +from fastapi import Depends, APIRouter +from app.dependencies import get_current_user_record, get_db, User, Session + +router = APIRouter() + + +@router.get("/users") +async def get_user(user: User = Depends(get_current_user_record)): + return user + + +@router.post("/users") +async def create_user(db=Depends(get_db)): + return {} diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py new file mode 100644 index 000000000..0afba9edf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py @@ -0,0 +1,22 @@ +from typing import Optional + + +class Session: + pass + + +class User: + id: int + username: str + + +async def get_db() -> Session: + db = Session() + try: + yield db + finally: + pass + + +async def get_current_user_record(db: Session) -> User: + return User() diff --git a/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py new file mode 100644 index 000000000..bbed6d889 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py @@ -0,0 +1,4 @@ +class CallRecord: + id: int + caller: str + callee: str diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts new file mode 100644 index 000000000..6acf5e471 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const grants = [{ id: 1, name: 'Research Grant' }]; + return NextResponse.json(grants); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts new file mode 100644 index 000000000..253184d83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const users = [{ id: 1, username: 'admin' }]; + return NextResponse.json(users); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts new file mode 100644 index 000000000..0932cc094 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts @@ -0,0 +1,5 @@ +const API_BASE = process.env.API_BASE || ''; + +export async function apiFetch(path: string, opts?: RequestInit) { + return fetch(`${API_BASE}${path}`, opts); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx new file mode 100644 index 000000000..d10f682c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx @@ -0,0 +1,9 @@ +import { apiFetch } from '../lib/api-client'; + +export default function GrantsList() { + const loadGrants = async () => { + const res = await apiFetch('/api/grants'); + return res.json(); + }; + return null; +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx new file mode 100644 index 000000000..c3ab4b15a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx @@ -0,0 +1,9 @@ +import { apiFetch } from '../lib/api-client'; + +export default function UserList() { + const loadUsers = async () => { + const res = await apiFetch('/api/users'); + return res.json(); + }; + return null; +} diff --git a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts index 6b1998a2b..1c5e59816 100644 --- a/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts +++ b/gitnexus/test/integration/parse-impl-quarantine-cache-skip.test.ts @@ -129,6 +129,7 @@ const accumulated = { heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/test/integration/resolvers/fastapi-depends.test.ts b/gitnexus/test/integration/resolvers/fastapi-depends.test.ts new file mode 100644 index 000000000..93394528e --- /dev/null +++ b/gitnexus/test/integration/resolvers/fastapi-depends.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('FastAPI Depends() CALLS edge extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'fastapi-depends'), () => {}); + }, 60000); + + it('emits CALLS edges from route handlers to get_current_user_record via Depends()', () => { + const edges = getRelationships(result, 'CALLS'); + const dependsEdges = edges.filter((e) => e.target === 'get_current_user_record'); + expect(dependsEdges.length).toBe(2); + const sources = dependsEdges.map((e) => e.source).sort(); + expect(sources).toContain('list_calls'); + expect(sources).toContain('get_user'); + }); + + it('emits CALLS edges from route handlers to get_db via Depends()', () => { + const edges = getRelationships(result, 'CALLS'); + const dependsEdges = edges.filter((e) => e.target === 'get_db'); + expect(dependsEdges.length).toBe(2); + const sources = dependsEdges.map((e) => e.source).sort(); + expect(sources).toContain('list_calls'); + expect(sources).toContain('create_user'); + }); + + it('traces typed default parameter: user: User = Depends(get_current_user_record)', () => { + const edges = getRelationships(result, 'CALLS'); + const edge = edges.find( + (e) => e.target === 'get_current_user_record' && e.sourceFilePath.includes('calls.py'), + ); + expect(edge).toBeDefined(); + }); + + it('traces untyped default parameter: db=Depends(get_db)', () => { + const edges = getRelationships(result, 'CALLS'); + const edge = edges.find((e) => e.target === 'get_db' && e.sourceFilePath.includes('users.py')); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts new file mode 100644 index 000000000..12d3a7b69 --- /dev/null +++ b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +describe('Fetch wrapper consumer FETCHES edge extraction', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'fetch-wrapper-consumers'), () => {}); + }, 60000); + + it('creates Route nodes for API endpoints', () => { + const routes = getNodesByLabel(result, 'Route'); + expect(routes).toContain('/api/grants'); + expect(routes).toContain('/api/users'); + }); + + it('creates FETCHES edge from GrantsList via apiFetch wrapper', () => { + const edges = getRelationships(result, 'FETCHES'); + const grantsEdge = edges.find( + (e) => e.sourceFilePath.includes('GrantsList') && e.target === '/api/grants', + ); + expect(grantsEdge).toBeDefined(); + }); + + it('creates FETCHES edge from UserList via apiFetch wrapper', () => { + const edges = getRelationships(result, 'FETCHES'); + const usersEdge = edges.find( + (e) => e.sourceFilePath.includes('UserList') && e.target === '/api/users', + ); + expect(usersEdge).toBeDefined(); + }); + + it('produces the correct total number of FETCHES edges', () => { + const edges = getRelationships(result, 'FETCHES'); + expect(edges.length).toBe(2); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 19e12cb8a..f76215879 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1397,6 +1397,259 @@ class ApiClient { ).toBeDefined(); }); + // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ── + // Same shape as the Java consumer test above, but parsed by the + // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three + // consumer flavors covered here (long-form WebClient + // `webClient.method(HttpMethod.X).uri(...)` is intentionally + // deferred to a follow-up — see kotlin.ts file header). + // + // tree-sitter-kotlin is an optionalDependency. If the binding is + // unavailable, `getPluginForFile` returns undefined for `.kt` and + // we skip the suite (matches the gating on the Provider tests). + const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined; + const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip; + + itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => { + const dir = path.join(tmpDir, 'kotlin-rest-template'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ApiClient.kt'), + `package com.example +import org.springframework.web.client.RestTemplate + +class ApiClient(private val restTemplate: RestTemplate) { + fun run() { + restTemplate.getForObject("/api/users/1", User::class.java) + restTemplate.getForEntity("/api/users/2", User::class.java) + restTemplate.postForObject("/api/users", body, User::class.java) + restTemplate.postForEntity("/api/users", body, User::class.java) + restTemplate.put("/api/users/3", body) + restTemplate.delete("/api/users/4") + restTemplate.patchForObject("/api/users/5", body, User::class.java) + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeDefined(); + + // Framework label must be the same `spring-rest-template` used + // by the Java plugin so polyglot repos coalesce on a single key. + const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template'); + expect(restConsumers.length).toBeGreaterThanOrEqual(5); + }); + + itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-short'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.kt'), + `package com.example +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.reactive.function.client.awaitBodilessEntity + +class OrderClient(private val webClient: WebClient) { + suspend fun run() { + val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody() + val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody() + val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody() + val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity() + val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'), + ).toBeDefined(); + + const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client'); + expect(wcConsumers.length).toBeGreaterThanOrEqual(5); + }); + + itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => { + const dir = path.join(tmpDir, 'kotlin-okhttp'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkClient.kt'), + `package com.example +import okhttp3.OkHttpClient +import okhttp3.Request + +class OkClient(private val client: OkHttpClient) { + fun fetch() { + val req = Request.Builder().url("/api/items").build() + val resp = client.newCall(req).execute() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items'); + expect(okConsumer).toBeDefined(); + expect(okConsumer!.meta.framework).toBe('okhttp'); + }); + + itKotlinConsumer( + 'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)', + async () => { + // Anti-overreach / known-limitation pin: OkHttp encodes the + // HTTP verb on a sibling call (`.post(body)` / `.delete()` / + // ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS` + // intentionally does not walk the chain to recover the verb — + // it emits `method: 'GET'` for every match, mirroring the Java + // plugin's `OK_HTTP_PATTERNS` (java.ts). + // + // This test pins the accepted behavior so a future verb-walk + // implementation must update kotlin.ts's known-limitation + // comment in lockstep. Concretely: + // - `Request.Builder().url("/api/users").post(body).build()` + // → ONE consumer: `http::GET::/api/users` (heuristic-default) + // → NO `http::POST::/api/users` consumer + // + // Test signal: + // - if this becomes correct (POST detected) without updating + // the kotlin.ts comment + java.ts behavior together, this + // test goes red and the reviewer must reconcile both sides. + const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkPostClient.kt'), + `package com.example +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody + +class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) { + fun create() { + val req = Request.Builder().url("/api/users").post(body).build() + client.newCall(req).execute() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('OkPostClient.kt'), + ); + + // Heuristic-default GET: exactly one consumer is emitted for + // the .url("/x") capture, with method=GET regardless of the + // sibling .post(body) call. + expect(fromThisFile).toHaveLength(1); + expect(fromThisFile[0].contractId).toBe('http::GET::/api/users'); + expect(fromThisFile[0].meta.method).toBe('GET'); + + // Anti-overreach: no second contract with POST should appear. + // If a future verb-walk lands and this assertion needs to flip + // (i.e. POST is now detected), bump kotlin.ts's known-limitation + // comment and java.ts in the same PR. + expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'does NOT match Kotlin WebClient long form (deferred to follow-up)', + async () => { + // Anti-overreach: confirm the short-form query does NOT + // accidentally fire on the long-form chain + // `webClient.method(HttpMethod.GET).uri(...)`. The long form + // is intentionally unsupported in this PR; if a future change + // to the short-form query starts capturing it we want a loud + // signal here. Long-form support will arrive in a follow-up + // with a dedicated query + verb walk-up helper. + const dir = path.join(tmpDir, 'kotlin-web-client-long'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LegacyClient.kt'), + `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody + +class LegacyClient(private val webClient: WebClient) { + suspend fun run() { + val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // No consumer should be emitted from this file by the + // current short-form query. Documented as a known limitation. + const fromLegacy = consumers.filter((c) => + c.symbolRef.filePath.endsWith('LegacyClient.kt'), + ); + expect(fromLegacy).toHaveLength(0); + }, + ); + + itKotlinConsumer( + 'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver', + async () => { + // Anti-regression: the RestTemplate receiver constraint + // (#eq? @obj "restTemplate") must hold. A field with a + // different conventional name (e.g. `cacheClient`) calling + // `.getForObject("/x", ...)` should NOT produce a route. + const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'CacheClient.kt'), + `package com.example + +class CacheClient(private val cacheClient: SomeCache) { + fun run() { + cacheClient.getForObject("/cache/key", String::class.java) + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/cache/key')).toBeUndefined(); + const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt')); + expect(fromCache).toHaveLength(0); + }, + ); + it('extracts Go stdlib and resty calls', async () => { const dir = path.join(tmpDir, 'go-consumer'); fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 17ec9c2e1..b67c27fb9 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -23,6 +23,7 @@ const minimalResult = (overrides: Partial = {}): ParseWorkerR heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], diff --git a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts index 44242b02d..789cc251d 100644 --- a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts +++ b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts @@ -39,6 +39,7 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult => heritage: [], routes: [], fetchCalls: [], + fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], @@ -73,7 +74,7 @@ fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned'); parentPort.postMessage({ type: 'ready' }); const accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], - routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], + routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], fileScopeBindings: [], parsedFiles: [], skippedLanguages: {}, fileCount: 0, }; parentPort.on('message', (msg) => {