From 7b38b8aae2433e33ec5b350d797133ac17bbb0ab Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Fri, 29 May 2026 13:05:40 +0800 Subject: [PATCH 1/4] feat(java): add HTTP consumer contract extraction (#1872) --- .../group/extractors/http-patterns/java.ts | 260 ++++++++++++++++- .../unit/group/http-route-extractor.test.ts | 276 +++++++++++++++++- 2 files changed, 519 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index b3ced5920..030b6c9d1 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -11,8 +11,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** * Java HTTP plugin. Handles: * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations - * - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)` + * - Spring `RestTemplate.getForObject/...`, `exchange(...)` + * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)` * - OkHttp `new Request.Builder().url("...")` + * - OpenFeign interfaces with Spring MVC method annotations + * - Java / Apache HttpClient literal request construction * * The plugin runs two pattern bundles: one to collect class-level * `@RequestMapping` prefixes keyed by the enclosing class node, and a @@ -73,6 +76,53 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// ─── Consumer: OpenFeign interface-level prefixes ─────────────────── +// Feign's `name`/`value` attributes identify a service, not an HTTP path, +// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign +// interface is also common and does carry a path prefix. +const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ + name: 'java-feign-interface-prefix', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "FeignClient") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "path") + value: (string_literal) @prefix))))) @interface + `, + }, + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @interface + `, + }, + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @interface + `, + }, + ], +} satisfies LanguagePatterns>); + // ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── // Same dual-pattern approach: positional vs named argument. The named // pattern restricts the annotation member name to `path`/`value` to @@ -116,6 +166,8 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ // RestTemplate.put → PUT // RestTemplate.delete → DELETE // RestTemplate.patchForObject → PATCH +// Source-scan only: receiver must be named exactly `restTemplate`. +// Fields, `this.restTemplate`, aliases, and other injection names are deferred. const REST_TEMPLATE_TO_HTTP: Record = { getForObject: 'GET', getForEntity: 'GET', @@ -146,22 +198,48 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns); -// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─ -const WEB_CLIENT_PATTERNS = compilePatterns({ - name: 'java-web-client', +const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({ + name: 'java-rest-template-exchange', + language: Java, + patterns: [ + { + meta: { framework: 'spring-rest-template' }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "restTemplate") + name: (identifier) @method (#eq? @method "exchange") + arguments: (argument_list + . (string_literal) @path + (field_access + object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + field: (identifier) @http_method))) + `, + }, + ], +} satisfies LanguagePatterns); + +const WEB_CLIENT_SHORT_TO_HTTP: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({ + name: 'java-web-client-short-form', language: Java, patterns: [ { meta: {}, query: ` (method_invocation - object: (identifier) @obj (#eq? @obj "webClient") - name: (identifier) @method (#eq? @method "method") - arguments: (argument_list - (field_access - object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") - field: (identifier) @http_method) - (string_literal) @path)) + object: (method_invocation + object: (identifier) @obj (#eq? @obj "webClient") + name: (identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$") + arguments: (argument_list)) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list . (string_literal) @path)) `, }, ], @@ -188,6 +266,54 @@ const OK_HTTP_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'java-http-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (method_invocation + object: (method_invocation + object: (identifier) @builderCls (#eq? @builderCls "HttpRequest") + name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder") + arguments: (argument_list)) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list + (method_invocation + object: (identifier) @uriCls (#eq? @uriCls "URI") + name: (identifier) @create (#eq? @create "create") + arguments: (argument_list . (string_literal) @path)))) + name: (identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE)$")) + `, + }, + ], +} satisfies LanguagePatterns>); + +const APACHE_HTTP_CLIENT_TO_HTTP: Record = { + HttpGet: 'GET', + HttpPost: 'POST', + HttpPut: 'PUT', + HttpDelete: 'DELETE', + HttpPatch: 'PATCH', +}; + +const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'java-apache-http-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (object_creation_expression + type: (type_identifier) @type (#match? @type "^Http(Get|Post|Put|Delete|Patch)$") + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + /** * Find the nearest enclosing class_declaration ancestor for a node, or * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks @@ -202,6 +328,29 @@ function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { return null; } +function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'interface_declaration') return cur; + cur = cur.parent; + } + return null; +} + +function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean { + for (const child of node.namedChildren) { + if (child.type !== 'modifiers') continue; + for (const modifier of child.namedChildren) { + if (modifier.type !== 'annotation') continue; + const nameNode = modifier.childForFieldName('name'); + if (!nameNode) continue; + const simpleName = nameNode.text.split('.').pop(); + if (nameNode.text === annotationName || simpleName === annotationName) return true; + } + } + return false; +} + /** * Join a class-level prefix and a method-level path into a single URL * path. Mirrors the semantics of the original regex implementation: @@ -231,6 +380,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (prefix !== null) prefixByClassId.set(classNode.id, prefix); } + const feignPrefixByInterfaceId = new Map(); + for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const interfaceNode = match.captures.interface; + if (!prefixNode || !interfaceNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id)) + feignPrefixByInterfaceId.set(interfaceNode.id, prefix); + } + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; @@ -241,6 +400,20 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (!httpMethod) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; + const enclosingInterface = findEnclosingInterface(methodNode); + if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { + const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'consumer', + framework: 'openfeign', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.7, + }); + continue; + } const enclosingClass = findEnclosingClass(methodNode); const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; const fullPath = joinPath(prefix, rawPath); @@ -273,8 +446,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } - // ─── Consumers: WebClient.method(HttpMethod.X, "path") ────────── - for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) { + for (const match of runCompiledPatterns(REST_TEMPLATE_EXCHANGE_PATTERNS, tree)) { const httpMethodNode = match.captures.http_method; const pathNode = match.captures.path; if (!httpMethodNode || !pathNode) continue; @@ -282,7 +454,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (path === null) continue; out.push({ role: 'consumer', - framework: 'spring-web-client', + framework: 'spring-rest-template', method: httpMethodNode.text.toUpperCase(), path, name: null, @@ -290,6 +462,28 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: WebClient.get().uri("path") short form ───────── + // Source-scan only: receiver must be named exactly `webClient`. + // The real long-form chain `webClient.method(HttpMethod.X).uri("/x")` + // needs multi-hop chain analysis and is intentionally deferred. + for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_FORM_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; @@ -306,6 +500,44 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: Java HttpClient request builder ───────────────── + // Java's builder exposes GET/POST/PUT/DELETE helpers. PATCH uses + // `.method("PATCH", body)`, which is intentionally deferred. + for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'java-http-client', + method: httpMethodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.65, + }); + } + + // ─── Consumers: Apache HttpClient request constructors ────────── + for (const match of runCompiledPatterns(APACHE_HTTP_CLIENT_PATTERNS, tree)) { + const typeNode = match.captures.type; + const pathNode = match.captures.path; + if (!typeNode || !pathNode) continue; + const httpMethod = APACHE_HTTP_CLIENT_TO_HTTP[typeNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'apache-http-client', + method: httpMethod, + path, + name: null, + confidence: 0.65, + }); + } + return out; }, }; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8fc6690da..8f4873f2e 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1364,7 +1364,7 @@ shadowed_module_client.get("/module-level-rebind-fp") ).toBeUndefined(); }); - it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { + it('extracts Java Spring RestTemplate, WebClient and OkHttp literal calls', async () => { const dir = path.join(tmpDir, 'java-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -1378,7 +1378,8 @@ import okhttp3.Request; class ApiClient { void run(RestTemplate restTemplate, WebClient webClient) { restTemplate.getForObject("/api/users/{id}", String.class, 42); - webClient.method(HttpMethod.PATCH, "/api/users/42"); + restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class); + webClient.post().uri("/api/users"); new Request.Builder().url("/api/orders/42").build(); } } @@ -1390,11 +1391,280 @@ class ApiClient { expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); expect( - consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}/details'), ).toBeDefined(); expect( consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/users/{param}/details' && + c.meta.framework === 'spring-rest-template' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/users' && + c.meta.framework === 'spring-web-client' && + c.confidence === 0.7, + ), + ).toBeDefined(); + }); + + it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => { + const dir = path.join(tmpDir, 'java-web-client-long-form'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongFormClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class LongFormClient { + void run(WebClient webClient) { + webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeUndefined(); + }); + + it('extracts OpenFeign clients as consumers, not providers', async () => { + const dir = path.join(tmpDir, 'java-openfeign-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "order-service", url = "\${order.service.url}", path = "/api") +interface OrderClient { + @GetMapping("/orders/{id}") + OrderDto getOrder(@PathVariable("id") String id); + + @PostMapping(path = "/orders") + OrderDto createOrder(OrderDto body); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/orders' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('OrderClient.java')), + ).toBeUndefined(); + }); + + it('extracts OpenFeign clients without an interface path prefix', async () => { + const dir = path.join(tmpDir, 'java-openfeign-no-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HealthClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "health-service") +interface HealthClient { + @GetMapping("/health") + String health(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/health' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('HealthClient.java')), + ).toBeUndefined(); + }); + + it('does not treat @FeignClient text in an interface body as a Feign annotation', async () => { + const dir = path.join(tmpDir, 'java-non-feign-interface-text'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NotFeignClient.java'), + ` +import org.springframework.web.bind.annotation.GetMapping; + +interface NotFeignClient { + String MARKER = "@FeignClient"; + + @GetMapping("/not-feign") + String call(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined(); + }); + + it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => { + const dir = path.join(tmpDir, 'java-openfeign-request-mapping-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@FeignClient(name = "inventory-service") +@RequestMapping(path = "/api") +interface InventoryClient { + @GetMapping("/inventory/{id}") + InventoryDto getInventory(String id); +} +`, + ); + + 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/inventory/{param}' && + c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + + it('prefers @FeignClient(path=...) over @RequestMapping prefixes on OpenFeign clients', async () => { + const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'PrecedenceClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@FeignClient(name = "order-service", path = "/feign-path") +@RequestMapping("/rm-path") +interface PrecedenceClient { + @GetMapping("/orders") + OrderDto getOrders(); +} +`, + ); + + 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::/feign-path/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); + }); + + it('extracts Java and Apache HttpClient literal request construction', async () => { + const dir = path.join(tmpDir, 'java-http-client-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HttpClients.java'), + ` +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpPatch; + +class HttpClients { + void run(HttpClient client) throws Exception { + HttpRequest get = HttpRequest.newBuilder() + .uri(URI.create("/api/users/1")) + .GET() + .build(); + HttpRequest post = HttpRequest.newBuilder() + .uri(URI.create("/api/users")) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + + new HttpGet("/api/orders/2"); + new HttpPost("/api/orders"); + new HttpPut("/api/orders/3"); + new HttpDelete("/api/orders/4"); + new HttpPatch("/api/orders/5"); + } +} +`, + ); + + 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' && + c.meta.framework === 'java-http-client' && + c.confidence === 0.65, + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/orders' && + c.meta.framework === 'apache-http-client' && + c.confidence === 0.65, + ), + ).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(); }); // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ── From d71fd1688b4dd093392064d18ee9ad52eaafc3be Mon Sep 17 00:00:00 2001 From: evolution Date: Fri, 29 May 2026 13:46:02 +0800 Subject: [PATCH 2/4] feat(go): add builtInNames set to Go language provider (#1886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(go): add builtInNames set to Go language provider Add GO_BUILT_INS (15 functions, 18 types, 3 values) to the Go LanguageProvider for parity with the other 13 language providers. The set is converted to an isBuiltInName predicate by defineLanguage() and consumed by the type-env return-type lookup to short-circuit lookups for Go built-in symbols. * feat(go): add Go 1.18+ and 1.21 predeclared identifiers to builtInNames Add `clear`, `min`, `max` (Go 1.21 builtins), `any`, `comparable` (Go 1.18 type aliases), and `iota` (predeclared constant) to GO_BUILT_INS for complete coverage of the Go specification. --------- Co-authored-by: Gergő Magyar --- gitnexus/src/core/ingestion/languages/go.ts | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 5c9aef039..b1f8f8ce5 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -39,6 +39,56 @@ import { interpretGoTypeBinding, } from './go/index.js'; +const GO_BUILT_INS: ReadonlySet = new Set([ + // built-in functions + 'make', + 'new', + 'len', + 'cap', + 'append', + 'copy', + 'delete', + 'close', + 'panic', + 'recover', + 'print', + 'println', + 'complex', + 'real', + 'imag', + 'clear', + 'min', + 'max', + // built-in types + 'error', + 'bool', + 'string', + 'int', + 'int8', + 'int16', + 'int32', + 'int64', + 'uint', + 'uint8', + 'uint16', + 'uint32', + 'uint64', + 'uintptr', + 'float32', + 'float64', + 'complex64', + 'complex128', + 'byte', + 'rune', + 'any', + 'comparable', + // built-in values + 'true', + 'false', + 'nil', + 'iota', +]); + export const goProvider = defineLanguage({ id: SupportedLanguages.Go, extensions: ['.go'], @@ -92,6 +142,7 @@ export const goProvider = defineLanguage({ variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), heritageExtractor: createHeritageExtractor(goHeritageConfig), + builtInNames: GO_BUILT_INS, // ── RFC #909 Ring 3: scope-based resolution hooks ────────── emitScopeCaptures: emitGoScopeCaptures, From 7dae4fcc41d4a3be11ad2d22effc08d9e5c616ba Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Fri, 29 May 2026 14:35:01 +0800 Subject: [PATCH 3/4] fix(group): attribute Spring interface routes to controllers (#1743) * fix(group): attribute Spring interface routes to controllers * test(group): normalize Spring route fixture paths --------- Co-authored-by: gfwangjie --- .../group/extractors/http-patterns/index.ts | 8 +- .../group/extractors/http-patterns/java.ts | 302 +++++++++++++++--- .../group/extractors/http-patterns/types.ts | 16 + .../group/extractors/http-route-extractor.ts | 86 ++++- .../unit/group/http-route-extractor.test.ts | 179 ++++++++++- 5 files changed, 532 insertions(+), 59 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index 4cc758218..e5c03b68c 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -8,7 +8,13 @@ import { PYTHON_HTTP_PLUGIN } from './python.js'; import { PHP_HTTP_PLUGIN } from './php.js'; import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js'; -export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js'; +export type { + HttpDetection, + HttpFileDetections, + HttpLanguagePlugin, + HttpRole, + HttpScanInput, +} from './types.js'; /** * File-extension → HTTP language plugin registry. The top-level diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 030b6c9d1..48da46765 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -6,7 +6,12 @@ import { unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; -import type { HttpDetection, HttpLanguagePlugin } from './types.js'; +import type { + HttpDetection, + HttpFileDetections, + HttpLanguagePlugin, + HttpScanInput, +} from './types.js'; /** * Java HTTP plugin. Handles: @@ -46,31 +51,85 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { // route prefixes — e.g. `produces = "application/json"` would corrupt // every method route under that controller). The sibling // `topic-patterns/java.ts` uses the same `key:` constraint approach. -const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ - name: 'java-spring-class-prefix', +interface SpringRouteBinding { + method: string; + path: string; +} + +interface SpringMethodInfo { + name: string; + routes: SpringRouteBinding[]; +} + +interface SpringTypeInfo { + filePath: string; + kind: 'class' | 'interface'; + name: string; + classPrefix: string; + implementedInterfaces: string[]; + isController: boolean; + methods: SpringMethodInfo[]; +} + +// ─── Provider: Spring class/interface-level @RequestMapping prefix ─── +const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({ + name: 'java-spring-type-prefix', language: Java, patterns: [ { meta: {}, query: ` - (class_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @class + [ + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @type + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @type + ] `, }, { meta: {}, query: ` - (class_declaration - (modifiers - (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list - (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") - value: (string_literal) @prefix))))) @class + [ + (class_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @type + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @type + ] + `, + }, + ], +} satisfies LanguagePatterns>); + +const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ + name: 'java-spring-type-declaration', + language: Java, + patterns: [ + { + meta: {}, + query: ` + [ + (class_declaration name: (identifier) @type_name) @type + (interface_declaration name: (identifier) @type_name) @type + ] `, }, ], @@ -315,9 +374,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({ } satisfies LanguagePatterns>); /** - * Find the nearest enclosing class_declaration ancestor for a node, or - * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks - * one level at a time. + * Find the nearest enclosing class/interface declaration ancestor for + * a node, or null if the node is top-level. Tree-sitter's + * SyntaxNode.parent walks one level at a time. */ function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { let cur: Parser.SyntaxNode | null = node.parent; @@ -337,20 +396,6 @@ function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | nu return null; } -function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean { - for (const child of node.namedChildren) { - if (child.type !== 'modifiers') continue; - for (const modifier of child.namedChildren) { - if (modifier.type !== 'annotation') continue; - const nameNode = modifier.childForFieldName('name'); - if (!nameNode) continue; - const simpleName = nameNode.text.split('.').pop(); - if (nameNode.text === annotationName || simpleName === annotationName) return true; - } - } - return false; -} - /** * Join a class-level prefix and a method-level path into a single URL * path. Mirrors the semantics of the original regex implementation: @@ -364,6 +409,184 @@ function joinPath(prefix: string, methodPath: string): string { return `/${cleanPrefix}/${cleanSub}`; } +function getNodeName(node: Parser.SyntaxNode): string | null { + return node.childForFieldName('name')?.text ?? null; +} + +function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean { + const modifiers = node.namedChildren.find((child) => child.type === 'modifiers'); + if (!modifiers) return false; + const allowed = new Set(typeof names === 'string' ? [names] : names); + const stack = [...modifiers.namedChildren]; + while (stack.length > 0) { + const cur = stack.pop()!; + const annotationName = cur.childForFieldName('name')?.text ?? ''; + const simpleName = annotationName.split('.').pop() ?? annotationName; + if ( + (cur.type === 'annotation' || cur.type === 'marker_annotation') && + (allowed.has(annotationName) || allowed.has(simpleName)) + ) { + return true; + } + stack.push(...cur.namedChildren); + } + return false; +} + +function collectTypePrefixes(tree: Parser.Tree): Map { + const prefixByTypeId = new Map(); + for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const typeNode = match.captures.type; + if (!prefixNode || !typeNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix); + } + return prefixByTypeId; +} + +function collectMethodRoutes(tree: Parser.Tree): Map { + const routesByMethodId = new Map(); + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const routes = routesByMethodId.get(methodNode.id) ?? []; + routes.push({ method: httpMethod, path: rawPath }); + routesByMethodId.set(methodNode.id, routes); + } + return routesByMethodId; +} + +function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode): void => { + for (const child of node.namedChildren) { + if (child.type === 'method_declaration') { + out.push(child); + continue; + } + if ( + child !== typeNode && + (child.type === 'class_declaration' || child.type === 'interface_declaration') + ) { + continue; + } + visit(child); + } + }; + visit(typeNode); + return out; +} + +function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { + const interfacesNode = typeNode.childForFieldName('interfaces'); + if (!interfacesNode) return []; + const out: string[] = []; + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') { + out.push(node.text.split('.').pop() ?? node.text); + return; + } + for (const child of node.namedChildren) visit(child); + }; + visit(interfacesNode); + return out; +} + +function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] { + const prefixByTypeId = collectTypePrefixes(tree); + const routesByMethodId = collectMethodRoutes(tree); + const out: SpringTypeInfo[] = []; + + for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) { + const typeNode = match.captures.type; + const typeNameNode = match.captures.type_name; + if (!typeNode || !typeNameNode) continue; + const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class'; + const methods = collectDirectMethods(typeNode) + .map((methodNode) => ({ + name: getNodeName(methodNode), + routes: routesByMethodId.get(methodNode.id) ?? [], + })) + .filter((method): method is SpringMethodInfo => method.name !== null); + + out.push({ + filePath, + kind, + name: typeNameNode.text, + classPrefix: prefixByTypeId.get(typeNode.id) ?? '', + implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [], + isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']), + methods, + }); + } + + return out; +} + +function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[] { + const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree)); + const interfaceRoutes = new Map | null>(); + + for (const type of types) { + if (type.kind !== 'interface') continue; + if (interfaceRoutes.has(type.name)) { + interfaceRoutes.set(type.name, null); + continue; + } + const methodMap = new Map(); + for (const method of type.methods) { + const routes = method.routes.map((route) => ({ + method: route.method, + path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path, + })); + if (routes.length > 0) methodMap.set(method.name, routes); + } + interfaceRoutes.set(type.name, methodMap); + } + + const detectionsByFile = new Map(); + for (const type of types) { + if (type.kind !== 'class' || !type.isController) continue; + for (const method of type.methods) { + if (method.routes.length > 0) continue; + const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => { + const routeMap = interfaceRoutes.get(interfaceName); + if (!routeMap) return []; + const routes = routeMap.get(method.name) ?? []; + return routes.map((route) => ({ + method: route.method, + path: joinPath(type.classPrefix, route.path), + })); + }); + + for (const route of inheritedRoutes) { + const detections = detectionsByFile.get(type.filePath) ?? []; + detections.push({ + role: 'provider', + framework: 'spring', + method: route.method, + path: route.path, + name: method.name, + confidence: 0.8, + }); + detectionsByFile.set(type.filePath, detections); + } + } + } + + return [...detectionsByFile.entries()].map(([filePath, detections]) => ({ + filePath, + detections, + })); +} + export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'java-http', language: Java, @@ -371,14 +594,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const out: HttpDetection[] = []; // ─── Providers: Spring class prefix + method annotations ──────── - const prefixByClassId = new Map(); - for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) { - const prefixNode = match.captures.prefix; - const classNode = match.captures.class; - if (!prefixNode || !classNode) continue; - const prefix = unquoteLiteral(prefixNode.text); - if (prefix !== null) prefixByClassId.set(classNode.id, prefix); - } + const prefixByTypeId = collectTypePrefixes(tree); const feignPrefixByInterfaceId = new Map(); for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { @@ -415,7 +631,8 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { continue; } const enclosingClass = findEnclosingClass(methodNode); - const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; + if (!enclosingClass) continue; + const prefix = prefixByTypeId.get(enclosingClass.id) ?? ''; const fullPath = joinPath(prefix, rawPath); out.push({ role: 'provider', @@ -540,4 +757,5 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { return out; }, + scanProject: scanSpringProject, }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index e1c85369d..fb4ab09cb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -40,6 +40,16 @@ export interface HttpDetection { confidence: number; } +export interface HttpScanInput { + filePath: string; + tree: Parser.Tree; +} + +export interface HttpFileDetections { + filePath: string; + detections: HttpDetection[]; +} + /** * One language-scoped HTTP plugin. The plugin owns the tree-sitter * grammar and the `scan` function that translates a parsed tree into @@ -95,4 +105,10 @@ export interface HttpLanguagePlugin { * single-file plugins can keep their unary `scan(tree)` shape. */ scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[]; + /** + * Optional project-level scan hook for language rules that require + * multiple files, such as Java controllers inheriting Spring mappings + * from annotated interfaces. + */ + scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[]; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 37237242f..e14b9b701 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -6,7 +6,13 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; -import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js'; +import { + getPluginForFile, + HTTP_SCAN_GLOB, + type HttpDetection, + type HttpLanguagePlugin, + type HttpScanInput, +} from './http-patterns/index.js'; /** * Language-agnostic orchestrator for HTTP route (provider + consumer) @@ -160,6 +166,12 @@ export class HttpRouteExtractor implements ContractExtractor { // both graph-assisted enrichment and source-scan emission. const parser = new Parser(); const cachedDetections = new Map(); + const cachedInputs = new Map< + string, + { plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null + >(); + const projectDetections = new Map(); + let projectScanComplete = false; // Per-plugin cross-file context (e.g. Python's FastAPI router → // include_router(prefix=...) map). Built lazily on first @@ -189,32 +201,50 @@ export class HttpRouteExtractor implements ContractExtractor { } }; - const getDetections = async (rel: string): Promise => { - const cached = cachedDetections.get(rel); - if (cached) return cached; + const getScanInput = async ( + rel: string, + ): Promise<{ + plugin: HttpLanguagePlugin; + input: HttpScanInput; + repoContext: unknown; + } | null> => { + if (cachedInputs.has(rel)) return cachedInputs.get(rel) ?? null; const plugin = getPluginForFile(rel); if (!plugin) { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } const repoContext = await ensureRepoContext(plugin); const content = readSafe(repoPath, rel); if (!content) { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } try { parser.setLanguage(plugin.language); const tree = parseSourceSafe(parser, content); - const detections = plugin.scan(tree, repoContext, rel); - cachedDetections.set(rel, detections); - return detections; + const input = { filePath: rel, tree }; + const item = { plugin, input, repoContext }; + cachedInputs.set(rel, item); + return item; } catch { - cachedDetections.set(rel, []); - return []; + cachedInputs.set(rel, null); + return null; } }; + const getDetections = async (rel: string): Promise => { + const cached = cachedDetections.get(rel); + if (cached) return cached; + const scanInput = await getScanInput(rel); + const ownDetections = scanInput + ? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel) + : []; + const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])]; + cachedDetections.set(rel, detections); + return detections; + }; + // Glob the source-scan file list at most once per extract() — // both provider and consumer fallback paths share the same list. let scannedFiles: string[] | null = null; @@ -224,20 +254,46 @@ export class HttpRouteExtractor implements ContractExtractor { return scannedFiles; }; + const collectProjectDetections = async (files: string[]): Promise => { + if (projectScanComplete) return; + projectScanComplete = true; + const byPlugin = new Map(); + for (const rel of files) { + const scanInput = await getScanInput(rel); + if (!scanInput?.plugin.scanProject) continue; + const items = byPlugin.get(scanInput.plugin) ?? []; + items.push(scanInput.input); + byPlugin.set(scanInput.plugin, items); + } + + for (const [plugin, inputs] of byPlugin) { + const results = plugin.scanProject?.(inputs) ?? []; + for (const result of results) { + const existing = projectDetections.get(result.filePath) ?? []; + projectDetections.set(result.filePath, [...existing, ...result.detections]); + } + } + + cachedDetections.clear(); + }; + + const files = await getScannedFiles(); + await collectProjectDetections(files); + const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : []; // Source scan always runs to capture routes in languages/files not covered // by graph edges; the glob and per-file parse results are cached above. const providers = this.mergeGraphAndSourceContracts( graphProviders, - await this.extractProvidersSourceScan(await getScannedFiles(), getDetections), + await this.extractProvidersSourceScan(files, getDetections), ); const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : []; const consumers = this.mergeGraphAndSourceContracts( graphConsumers, - await this.extractConsumersSourceScan(await getScannedFiles(), getDetections), + await this.extractConsumersSourceScan(files, getDetections), ); return [...providers, ...consumers]; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8f4873f2e..297501a83 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -41,6 +41,8 @@ describe('HttpRouteExtractor', () => { }); }); + const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/'); + describe('provider extraction — graph-first (Strategy A)', () => { it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => { const dir = path.join(tmpDir, 'graph-first'); @@ -832,6 +834,181 @@ class UserController { }, ); + it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => { + const dir = path.join(tmpDir, 'spring-interface-only'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/rest/DepartmentApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/departments") +public interface DepartmentApi { + @GetMapping("") + Object list(); + + @GetMapping("/{name}") + Object getByName(@PathVariable String name); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(0); + }); + + it('inherits Spring interface route mappings when controller methods omit annotations', async () => { + const dir = path.join(tmpDir, 'spring-interface-inherited-methods'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/rest/StatusApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/status") +public interface StatusApi { + @GetMapping("") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/StatusController.java'), + ` +package com.example.controller; +import com.example.rest.StatusApi; +import org.springframework.web.bind.annotation.*; + +@RestController +public class StatusController implements StatusApi { + @Override + public Object getStatus() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status'); + expect(statusRoute).toBeDefined(); + expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe( + 'src/controller/StatusController.java', + ); + expect(statusRoute!.symbolName).toBe('getStatus'); + expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength( + 0, + ); + }); + + it('combines controller class mapping with inherited interface method mapping', async () => { + const dir = path.join(tmpDir, 'spring-interface-controller-prefix'); + fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/rest/UserApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +public interface UserApi { + @GetMapping("/users") + Object listUsers(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example.controller; +import com.example.rest.UserApi; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +public class UserController implements UserApi { + @Override + public Object listUsers() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const usersRoute = providers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(usersRoute).toBeDefined(); + expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe( + 'src/controller/UserController.java', + ); + }); + + it('skips ambiguous inherited routes when interfaces share a simple name', async () => { + const dir = path.join(tmpDir, 'spring-interface-simple-name-collision'); + fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + + fs.writeFileSync( + path.join(dir, 'src/a/StatusApi.java'), + ` +package com.example.a; +import org.springframework.web.bind.annotation.*; + +public interface StatusApi { + @GetMapping("/a/status") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/b/StatusApi.java'), + ` +package com.example.b; +import org.springframework.web.bind.annotation.*; + +public interface StatusApi { + @GetMapping("/b/status") + Object getStatus(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/StatusController.java'), + ` +package com.example.controller; +import com.example.a.StatusApi; +import org.springframework.web.bind.annotation.*; + +@RestController +public class StatusController implements StatusApi { + @Override + public Object getStatus() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined(); + expect( + providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')), + ).toHaveLength(0); + }); + it('extracts Express router.get patterns', async () => { const dir = path.join(tmpDir, 'express'); fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); @@ -1537,7 +1714,7 @@ interface NotFeignClient { const providers = contracts.filter((c) => c.role === 'provider'); expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); - expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); }); it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => { From 2f15c1ece166beca7873a951746905910ece52a2 Mon Sep 17 00:00:00 2001 From: henry201605 <31428013+henry201605@users.noreply.github.com> Date: Fri, 29 May 2026 16:27:54 +0800 Subject: [PATCH 4/4] feat(group): add Kotlin Spring WebClient long-form HTTP consumer extraction (#1884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): add Kotlin Spring WebClient long-form HTTP consumer extraction Follow-up to #1855. Extends `kotlin.ts` with the long-form WebClient fluent chain that #1855 explicitly deferred: webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody() This pattern remains common in Kotlin Spring 4 → 5 migrations and in codebases that prefer the fluent verb-as-enum style. The short form (`webClient.get().uri("/x")`) was already supported in #1855. Approach: - Single deeper tree-sitter query (`WEB_CLIENT_LONG_PATTERNS`) that matches the full chain structurally — both `.method(HttpMethod.X)` and `.uri("...")` in one pattern. Verb is captured as the `simple_identifier` of the `HttpMethod.X` field access. - Verb is whitelisted to GET/POST/PUT/DELETE/PATCH (consistent with the short-form's `WEB_CLIENT_SHORT_TO_HTTP` map). - Receiver constraint `(#eq? @obj "webClient")` mirrors the short form and Java plugin heuristic. Out of scope (intentional): - Variable-bound verbs: `val verb = HttpMethod.PATCH; webClient.method(verb)...` Source-scan can't follow the binding without graph context. Pinned by an anti-overreach test. - HEAD/OPTIONS/TRACE: not in `WEB_CLIENT_SHORT_TO_HTTP` either — keeps polyglot symmetry with java.ts and the short form. Tests: 4 new cases under `consumer extraction — fetch patterns`, gated by tree-sitter-kotlin grammar availability. positive (3) - long form GET - long form POST / PUT / DELETE / PATCH (4 verbs in 1 fixture) - no double-emit pin (long-form chain produces exactly one consumer, not one from each query) anti-regression (1) - variable-bound verb does NOT match (graph-aware concern) The previous `'does NOT match Kotlin WebClient long form (deferred to follow-up)'` test from #1855 is replaced by these — the deferred state is now resolved. Reverse-validated: temporarily disabling the long-form emit makes exactly the 3 positive tests fail; the variable-bound-verb anti- regression test continues to pass (it pins behavior independent of the emit being on or off). Local validation: - test/unit/group/http-route-extractor.test.ts: 66/66 ✅ - test/unit/group: 546/546 ✅ - npx prettier --check (changed files): clean ✅ * test(group): address Claude review findings F1 and F2 on PR #1884 Two minor follow-ups from the production-readiness review: F1 — Stale block comment at the top of the Kotlin consumer suite (was: "Three consumer flavors covered here ... long-form deferred to a follow-up"). Updated to "Four consumer flavors" and removed the deferred sentence — the deferral is resolved by this PR. The kotlin.ts file header was already updated; this brings the test file comment in sync. Per DoD §2.3 (no stale comments). F2 — Replaced `expect(wcConsumers.length).toBeGreaterThanOrEqual(4)` with `expect(wcConsumers).toHaveLength(4)` in the multi-verb test. The fixture is fully deterministic — exactly 4 long-form calls, no other consumer types — so an exact count assertion is the right shape per DoD §2.7 ("use toBe / toEqual for exact expectations"). Added a comment explaining what the assertion catches that the existing per-verb toBeDefined() checks would miss (accidental 5th consumer from a duplicate query firing or a regressed receiver constraint). F3 (HEAD/OPTIONS/TRACE negative test) is intentionally not added in this PR — same precedent as #1855 where HEAD/OPTIONS/TRACE on the short form are also implicitly excluded without a pinning test. Happy to add one in a separate PR if maintainers want explicit pinning across both forms. F4 (CI on pre-merge SHA) is the maintainer's call — the merge from main is theirs to re-trigger CI on. The merge brings only Java consumer changes (PR #1872) and Go provider changes (PR #1886), both in entirely separate files from this PR's Kotlin work. Local validation: - test/unit/group/http-route-extractor.test.ts: 73/73 ✅ (66 from this PR pre-merge + 7 from PR #1872 merged via main) - npx prettier --check (changed files): clean ✅ * refactor(group): hoist Kotlin WebClient long-form verb regex to module scope Address @magyargergo's review request on PR #1884: > Can you please extract the regexp from the for loop? 🙏 (kotlin.ts:510) Compiles the verb whitelist `^(GET|POST|PUT|DELETE|PATCH)$` once at module load instead of every iteration of the long-form scan loop. Mirrors the placement and JSDoc style of the sibling `WEB_CLIENT_SHORT_TO_HTTP` constant. Behavior is unchanged — same verb whitelist, same exclusion of HEAD/OPTIONS/TRACE for symmetry with the short form. The 4 itKotlinConsumer long-form tests added in this PR continue to pass, and the variable-bound-verb anti-overreach test continues to pin the deliberate non-match. Local validation: - test/unit/group/http-route-extractor.test.ts: 77/77 ✅ - test/unit/group: 557/557 ✅ - npx prettier --check (changed file): clean ✅ --------- Co-authored-by: henry Co-authored-by: Gergő Magyar --- .../group/extractors/http-patterns/kotlin.ts | 117 +++++++++++-- .../unit/group/http-route-extractor.test.ts | 158 +++++++++++++++--- 2 files changed, 242 insertions(+), 33 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 0bafb7a7e..0e56b554b 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -17,18 +17,22 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * named annotation arguments (`@GetMapping(value = "/x")` and * `@GetMapping(path = "/x")`) are supported. * - * **Consumers** (this PR) — three call-site patterns common in Kotlin + * **Consumers** — four 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) + * 1. `restTemplate.getForObject("/x", ...)` and friends (#1855) + * 2. `webClient.get().uri("/x")` — short form (#1855) + * 3. `Request.Builder().url("/x")` — OkHttp (#1855) + * 4. `webClient.method(HttpMethod.X).uri("/y")` — long form (this PR) * - * 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. + * The long form puts the verb on a sibling `call_expression` two hops + * away from the path. Rather than introducing imperative walk-up logic, + * we use a single deeper tree-sitter query that matches the full chain + * structurally — see `WEB_CLIENT_LONG_PATTERNS` below. The verb is + * captured directly as the `simple_identifier` of `HttpMethod.X`, so + * variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`) + * are intentionally NOT picked up — those need a graph-aware resolver + * and are out of scope for source-scan. * * tree-sitter-kotlin (fwcd) AST shapes used here: * class_declaration @@ -109,6 +113,16 @@ const WEB_CLIENT_SHORT_TO_HTTP: Record = { patch: 'PATCH', }; +/** + * Allowed HTTP verbs for the WebClient long-form path + * `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module + * load (instead of inside the scan loop) per maintainer feedback on + * PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above — + * keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry + * with the short form and the Java plugin. + */ +const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/; + /** * Build the plugin only if the Kotlin grammar is available. Compiling * the queries against a null grammar would throw at module load time @@ -265,8 +279,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { // - 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). + // uses an extra navigation hop and an enum field access — handled + // by `WEB_CLIENT_LONG_PATTERNS` below, separately so each query is + // straightforward to reason about. const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({ name: 'kotlin-web-client-short', language, @@ -290,6 +305,59 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } satisfies LanguagePatterns>); + // ─── Consumer: Spring WebClient (long form) ─────────────────────────── + // The fluent long form passes the verb as a `HttpMethod.X` enum field + // access through `.method(...)`, then carries the path on a separate + // `.uri(...)` hop further down the chain: + // + // webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody() + // + // Compared to the short form there are two extra structural hops: + // - the inner `.method(...)` `call_expression` has a `value_argument` + // whose payload is itself a `navigation_expression` (HttpMethod → .GET) + // - the outer `.uri(...)` is reached via one more + // `navigation_expression` wrapping that inner call + // + // We capture the verb at the `simple_identifier` under `HttpMethod`'s + // `navigation_suffix`. That `simple_identifier` is the literal field + // name (`GET`, `POST`, ...) used in source — Kotlin enum fields by + // convention are upper-case, matching `HttpMethod` from + // `org.springframework.http`. We forward the captured text as-is. + // + // Variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`) + // do NOT match — they fail the `(navigation_expression ...)` shape + // because the value_argument carries a bare `simple_identifier` instead + // of a `HttpMethod.X` field access. This is intentional: source-scan + // can't follow the binding without graph context. Pinned by an + // anti-overreach test in the consumer suite. + const WEB_CLIENT_LONG_PATTERNS = compilePatterns({ + name: 'kotlin-web-client-long', + language, + patterns: [ + { + meta: {}, + query: ` + (call_expression + (navigation_expression + (call_expression + (navigation_expression + (simple_identifier) @obj (#eq? @obj "webClient") + (navigation_suffix + (simple_identifier) @method_call (#eq? @method_call "method"))) + (call_suffix + (value_arguments + . (value_argument + (navigation_expression + (simple_identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + (navigation_suffix (simple_identifier) @verb)))))) + (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 @@ -437,6 +505,33 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { }); } + // ─── Consumers: WebClient long form (.method(HttpMethod.X) → .uri) ─ + for (const match of runCompiledPatterns(WEB_CLIENT_LONG_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + // The captured text is the literal `HttpMethod.X` field name. + // Spring's `org.springframework.http.HttpMethod` defines GET, + // POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE — we only + // emit for the five verbs we already handle elsewhere, so + // exotic ones are silently skipped (consistent with the + // short form's WEB_CLIENT_SHORT_TO_HTTP guard). The accepted + // verb regex is hoisted to module scope (see + // `WEB_CLIENT_LONG_VERB_RE` near the top of this file). + const verbText = verbNode.text; + if (!WEB_CLIENT_LONG_VERB_RE.test(verbText)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: verbText, + 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; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 297501a83..58db213e9 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1844,12 +1844,13 @@ class HttpClients { ).toBeDefined(); }); - // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ── + // ─── Kotlin consumers (RestTemplate / WebClient short+long / 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 grammar via `KOTLIN_HTTP_PLUGIN`. Four + // consumer flavors covered here: RestTemplate (#1855), WebClient + // short form (#1855), OkHttp (#1855), and WebClient long form + // (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) — + // see kotlin.ts file header for the full list. // // tree-sitter-kotlin is an optionalDependency. If the binding is // unavailable, `getPluginForFile` returns undefined for `.kt` and @@ -2028,28 +2029,104 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo }, ); + itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-long-get'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongGetClient.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 LongGetClient(private val webClient: WebClient) { + suspend fun run() { + val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const route = consumers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(route).toBeDefined(); + expect(route!.meta.framework).toBe('spring-web-client'); + }); + + itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => { + const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongVerbClient.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 +import org.springframework.web.reactive.function.client.awaitBodilessEntity + +class LongVerbClient(private val webClient: WebClient) { + suspend fun run() { + webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody() + webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody() + webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity() + webClient.method(HttpMethod.PATCH).uri("/api/orders/3").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::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(); + + // All four should be tagged as `spring-web-client` so polyglot + // repos coalesce on the same framework key as the short form. + // The fixture is fully deterministic — exactly 4 long-form calls, + // no short-form / RestTemplate / OkHttp calls mixed in — so an + // exact count is meaningful (DoD §2.7). If a future change + // accidentally emits a 5th consumer (e.g. duplicate query firing, + // or a regressed receiver constraint matching unrelated calls), + // this assertion catches it. + const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client'); + expect(wcConsumers).toHaveLength(4); + }); + itKotlinConsumer( - 'does NOT match Kotlin WebClient long form (deferred to follow-up)', + 'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)', 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'); + // The long-form query handles `webClient.method(HttpMethod.X).uri(...)`, + // and the short-form query handles `webClient.get().uri(...)`. Both + // queries carry sibling `(navigation_suffix (simple_identifier) @verb)` + // constraints — short form requires the verb name itself + // (`get`/`post`/...), long form requires the literal name + // `method`. The two are disjoint. + // + // This test pins that disjointness: a single `.method(HttpMethod.GET)` + // call must emit ONE consumer, not two (one from each query). + const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( - path.join(dir, 'src', 'LegacyClient.kt'), + path.join(dir, 'src', 'NoDoubleClient.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) { +class NoDoubleClient(private val webClient: WebClient) { suspend fun run() { - val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody() + webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody() } } `, @@ -2058,12 +2135,49 @@ class LegacyClient(private val webClient: WebClient) { 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'), + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('NoDoubleClient.kt'), ); - expect(fromLegacy).toHaveLength(0); + expect(fromThisFile).toHaveLength(1); + expect(fromThisFile[0].contractId).toBe('http::GET::/api/single'); + }, + ); + + itKotlinConsumer( + 'does NOT match Kotlin WebClient long form with variable-bound verb', + async () => { + // Anti-overreach: source-scan can't follow `val verb = HttpMethod.X` + // back to the literal — that's a graph-aware concern. The long-form + // query requires `(navigation_expression HttpMethod . verb)` as the + // `value_argument` shape, so a bare `simple_identifier` (the + // variable name) fails to match. Pin this so a future relaxation + // of the value_argument shape cannot silently start guessing the + // verb from arbitrary identifiers. + const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'VariableVerbClient.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 VariableVerbClient(private val webClient: WebClient) { + suspend fun run() { + val verb = HttpMethod.PATCH + val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody() + } +} +`, + ); + + 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('VariableVerbClient.kt'), + ); + expect(fromThisFile).toHaveLength(0); }, );