diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 3ad66543ef..5afebbaced 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -11,6 +11,22 @@ import { isRouteMemberKey, findEnclosingClass, } from '../../../ingestion/route-extractors/spring-shared.js'; +import { + REST_TEMPLATE_TO_HTTP, + WEB_CLIENT_SHORT_TO_HTTP, + WEB_CLIENT_LONG_VERB_RE, + EXCHANGE_ANNOTATION_TO_HTTP, + parseRequestLine, + pushPrefix, + joinPath, + scanSpringInheritanceProject, + type SharedSpringType, + OPENFEIGN_FRAMEWORK, + HTTP_INTERFACE_FRAMEWORK, + FEIGN_CONFIDENCE, + REQUEST_LINE_CONFIDENCE, + EXCHANGE_CONFIDENCE, +} from './spring-consumer-shared.js'; import type { HttpDetection, HttpFileDetections, @@ -49,38 +65,23 @@ import type { // corrupt every route). That key filtering is done in `isRouteMemberKey`, and // all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS` // query below (see its header for why the filtering lives in JS, not the query). -interface SpringRouteBinding { - method: string; - path: string; - ownerPrefix?: string; -} - -interface SpringMethodInfo { - name: string; - routes: SpringRouteBinding[]; -} - -interface SpringTypeInfo { - filePath: string; - kind: 'class' | 'interface'; - name: string; - classPrefix: string; - implementedInterfaces: string[]; - isController: boolean; - methods: SpringMethodInfo[]; -} +// The Spring class/interface view (`SharedSpringType`) and the interface-based +// controller inheritance algorithm (`scanSpringInheritanceProject`) are shared +// with kotlin.ts via spring-consumer-shared.ts so both plugins emit identical +// provider contracts. `collectSpringTypes` below produces that shared shape. // ─── Route-defining annotations (one generic query, one pass) ───────── // Every Java route-mapper annotation shares one shape: an annotation carrying a -// single string argument — positional `"..."` or named `key = "..."` — on a -// class, interface, or method. This SINGLE query matches that shape generically; -// `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration -// kind (`@node.type`) in its for-loop to decide what each match means. Adding a -// new framework annotation that follows this single-string-argument shape is a -// change to that loop (and the lookup maps), not to this query. Annotations with -// a different argument shape — e.g. an array value `@RequestMapping({"/a","/b"})` -// — are out of scope here (as they were for the prior queries) and would need a -// new branch. +// string argument — positional `"..."` or named `key = "..."`, each also in its +// array form `{"..."}` / `key = {"..."}` (Spring's `path`/`value` are +// `String[]`) — on a class, interface, or method. This SINGLE query matches that +// shape generically; the `@value` capture is an alternation over a bare +// `string_literal` and one nested in an `element_value_array_initializer`, so a +// single-element array yields the same `@value` (a multi-element array yields one +// match per element). `scanRouteAnnotations` then reads the annotation NAME +// (`@ann`) and declaration kind (`@node.type`) in its for-loop to decide what +// each match means. Adding a new framework annotation that follows this shape is +// a change to that loop (and the lookup maps), not to this query. // // Captures (shared across all branches; intentionally framework-agnostic): // @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …) @@ -96,6 +97,18 @@ interface SpringTypeInfo { // silently dropping sibling-branch matches. Keeping the query predicate-free // sidesteps that hazard entirely; all name/key discrimination lives in the // for-loop, where it reads as straight-line code. +// +// KNOWN LIMITATION — fully-qualified route annotations are not matched. `@ann` +// binds `name: (identifier)`, but a FQN annotation (`@org.springframework… +// GetMapping("/x")`) parses its name as a `scoped_identifier`, which this query +// does not match, so its route is not extracted. (The class is still recognized +// as a controller — `hasAnnotation` trailing-segment-matches the FQN — only the +// route-string extraction is missed.) In practice annotations are imported and +// written by simple name, so this is rare. It is a minor asymmetry with the +// Kotlin plugin, whose grammar models a FQN as separate `type_identifier` +// segments that its route queries DO match. Aligning Java would mean matching +// `scoped_identifier` too; that is deferred to avoid re-keying existing Java +// contracts via the predicate hazard above. Pinned by an anti-overreach test. const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ name: 'java-route-annotation', language: Java, @@ -108,12 +121,12 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ (modifiers (annotation name: (identifier) @ann - arguments: (annotation_argument_list (string_literal) @value)))) @node + arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node (interface_declaration (modifiers (annotation name: (identifier) @ann - arguments: (annotation_argument_list (string_literal) @value)))) @node + arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node (class_declaration (modifiers (annotation @@ -121,7 +134,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ arguments: (annotation_argument_list (element_value_pair key: (identifier) @key - value: (string_literal) @value))))) @node + value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))) @node (interface_declaration (modifiers (annotation @@ -129,12 +142,12 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ arguments: (annotation_argument_list (element_value_pair key: (identifier) @key - value: (string_literal) @value))))) @node + value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))) @node (method_declaration (modifiers (annotation name: (identifier) @ann - arguments: (annotation_argument_list (string_literal) @value))) + arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))) name: (identifier) @member) @node (method_declaration (modifiers @@ -143,7 +156,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({ arguments: (annotation_argument_list (element_value_pair key: (identifier) @key - value: (string_literal) @value)))) + value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) name: (identifier) @member) @node ] `, @@ -167,59 +180,10 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); -// ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ─────── -// OpenFeign's native annotation pairs an HTTP method and path in a single -// string literal — see https://github.com/OpenFeign/feign#interface-annotations. -// It is method-level only and is mutually exclusive with Spring MVC -// `@GetMapping` / `@PostMapping` etc. on the same method (mixing them -// requires a different Feign Contract — they are not combined). The match -// itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the -// verb from the path of the captured literal. -// -// Examples: -// @RequestLine("GET /users/{id}") -// @RequestLine("POST /users?status=active") -const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i; - -/** - * Parse a Feign `@RequestLine` value into a method + path pair. - * - * `@RequestLine("METHOD /path[?query]")` packs both fields in one string; - * the query portion is dropped because contract IDs are method+path only - * (consistent with how other consumers like RestTemplate/WebClient drop - * query strings when their values are inline literals). - * - * Returns null if the value is not a recognized HTTP verb followed by a - * path beginning with `/`. - */ -function parseRequestLine(raw: string): { method: string; path: string } | null { - const match = REQUEST_LINE_VERB_RE.exec(raw); - if (!match) return null; - const [, verb, rest] = match; - if (typeof verb !== 'string' || typeof rest !== 'string') return null; - const queryIdx = rest.indexOf('?'); - const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim(); - if (!pathOnly.startsWith('/')) return null; - return { method: verb.toUpperCase(), path: pathOnly }; -} - -// ─── Consumer: Spring RestTemplate (object-named + method-named) ────── -// RestTemplate.getForObject / getForEntity → GET -// RestTemplate.postForObject / postForEntity → POST -// 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', - postForObject: 'POST', - postForEntity: 'POST', - put: 'PUT', - delete: 'DELETE', - patchForObject: 'PATCH', -}; +// OpenFeign `@RequestLine` parsing (`parseRequestLine`), the RestTemplate and +// WebClient short-form verb maps, the `@*Exchange` verb map, `joinPath`, and the +// shared confidence/framework constants live in `spring-consumer-shared.ts` so +// the Java and Kotlin plugins emit identical contract IDs. interface RestTemplateMeta { framework: 'spring-rest-template'; @@ -261,14 +225,6 @@ const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({ ], } 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, @@ -288,6 +244,37 @@ const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// ─── Consumer: WebClient long form `webClient.method(HttpMethod.X).uri("/y")` ─ +// The fluent long form carries the verb as a `HttpMethod.X` field access through +// `.method(...)` and the path on a separate `.uri(...)` hop. A single structural +// query matches the whole chain (the same field-access shape used by +// REST_TEMPLATE_EXCHANGE_PATTERNS) — the earlier "intentionally deferred" note +// predated the Kotlin plugin proving the structural query is enough. Variable- +// bound verbs (`webClient.method(verb).uri(...)`) do NOT match: the value carries +// a bare `identifier`, not a `HttpMethod.X` field access — source-scan can't +// follow the binding (anti-overreach test pins this, parity with Kotlin). +const WEB_CLIENT_LONG_FORM_PATTERNS = compilePatterns({ + name: 'java-web-client-long-form', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (method_invocation + object: (identifier) @obj (#eq? @obj "webClient") + name: (identifier) @method_call (#eq? @method_call "method") + arguments: (argument_list + (field_access + object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + field: (identifier) @verb))) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + // ─── Consumer: OkHttp `new Request.Builder().url("path")` ───────────── // Note: `Request.Builder` is a `scoped_type_identifier` whose text includes // the dot, so `#eq?` against the literal string matches cleanly (no need @@ -370,38 +357,6 @@ function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | nu return null; } -/** - * Join a class-level prefix and a method-level path into a single URL - * path. Mirrors the semantics of the original regex implementation: - * strip trailing slashes on the prefix, then ensure a single slash - * between prefix and method path. - */ -function joinPath(prefix: string, methodPath: string): string { - const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleanSub = methodPath.replace(/^\/+/, ''); - if (!cleanPrefix) return `/${cleanSub}`; - return `/${cleanPrefix}/${cleanSub}`; -} - -function joinInheritedSpringPath( - controllerPrefix: string, - inheritedPath: string, - inheritedOwnerPrefix = '', -): string { - const joined = joinPath(controllerPrefix, inheritedPath); - const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleanInherited = inheritedPath.replace(/^\/+/, ''); - if (!cleanPrefix) return joined; - if ( - cleanPrefix === cleanOwnerPrefix && - (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`)) - ) { - return `/${cleanInherited}`; - } - return joined; -} - function getNodeName(node: Parser.SyntaxNode): string | null { return node.childForFieldName('name')?.text ?? null; } @@ -440,14 +395,18 @@ interface RequestLineAnnotation { } interface RouteAnnotationScan { - /** Spring `@RequestMapping` URL prefix per class/interface node id (last write wins). */ - prefixByTypeId: Map; - /** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */ - feignPrefixByInterfaceId: Map; + /** Spring `@RequestMapping` URL prefixes per class/interface node id (one per array element). */ + prefixByTypeId: Map; + /** OpenFeign interface prefixes per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */ + feignPrefixByInterfaceId: Map; + /** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */ + httpExchangePrefixByTypeId: Map; /** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */ methodRoutes: MethodRouteAnnotation[]; /** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */ requestLines: RequestLineAnnotation[]; + /** One entry per Spring HTTP Interface `@(Get|...)Exchange` method — always a consumer. */ + exchangeRoutes: MethodRouteAnnotation[]; } /** @@ -468,13 +427,17 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // collectSpringTypes cross-file inheritance), while `feignPrefixByInterfaceId` // feeds the OpenFeign *consumer* path in scan(). An interface carrying both // `@RequestMapping` and `@FeignClient(path)` lands a different value in each. - const prefixByTypeId = new Map(); - const feignPrefixByInterfaceId = new Map(); + const prefixByTypeId = new Map(); + const feignPrefixByInterfaceId = new Map(); + const httpExchangePrefixByTypeId = new Map(); const methodRoutes: MethodRouteAnnotation[] = []; const requestLines: RequestLineAnnotation[] = []; + const exchangeRoutes: MethodRouteAnnotation[] = []; // Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`; // collect them and apply only after the FeignClient pass below. const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = []; + // `pushPrefix` (the de-duping accumulator) is shared from + // spring-consumer-shared.ts so Java and Kotlin build prefix maps identically. for (const { captures } of matches) { const annNode = captures.ann; @@ -510,6 +473,20 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { parsed, }); } + } else if (EXCHANGE_ANNOTATION_TO_HTTP[ann]) { + // Spring 6 HTTP Interface `@(Get|...)Exchange` — the path lives in the + // `url` or `value` attribute (or positionally); other attributes + // (`accept`, `contentType`, …) are not routes. + if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue; + const rawPath = unquoteLiteral(valueNode.text); + if (rawPath !== null) { + exchangeRoutes.push({ + methodNode: node, + methodName: captures.member?.text ?? null, + httpMethod: EXCHANGE_ANNOTATION_TO_HTTP[ann], + rawPath, + }); + } } continue; } @@ -520,7 +497,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { if (!isRouteMemberKey(keyNode)) continue; const prefix = unquoteLiteral(valueNode.text); if (prefix !== null) { - prefixByTypeId.set(node.id, prefix); + pushPrefix(prefixByTypeId, node.id, prefix); if (node.type === 'interface_declaration') { interfaceRequestMappingPrefixes.push({ id: node.id, prefix }); } @@ -529,17 +506,30 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan { // Feign's `name`/`value` identify a service, not a path — only `path` is a prefix. if (!keyNode || keyNode.text !== 'path') continue; const prefix = unquoteLiteral(valueNode.text); - if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) { - feignPrefixByInterfaceId.set(node.id, prefix); - } + if (prefix !== null) pushPrefix(feignPrefixByInterfaceId, node.id, prefix); + } else if (ann === 'HttpExchange') { + // Spring HTTP Interface type-level prefix: the path lives in `url`/`value` + // (or positionally). Applies to its `@(Get|...)Exchange` consumer methods. + if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue; + const prefix = unquoteLiteral(valueNode.text); + if (prefix !== null) pushPrefix(httpExchangePrefixByTypeId, node.id, prefix); } } + // `@RequestMapping` on a Feign interface is the fallback prefix, but only when + // the interface has no `@FeignClient(path)` of its own (path wins). for (const { id, prefix } of interfaceRequestMappingPrefixes) { - if (!feignPrefixByInterfaceId.has(id)) feignPrefixByInterfaceId.set(id, prefix); + if (!feignPrefixByInterfaceId.has(id)) pushPrefix(feignPrefixByInterfaceId, id, prefix); } - return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines }; + return { + prefixByTypeId, + feignPrefixByInterfaceId, + httpExchangePrefixByTypeId, + methodRoutes, + requestLines, + exchangeRoutes, + }; } function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] { @@ -578,15 +568,15 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] { return out; } -function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] { +function collectSpringTypes(filePath: string, tree: Parser.Tree): SharedSpringType[] { const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree); - const routesByMethodId = new Map(); + const routesByMethodId = new Map>(); for (const route of methodRoutes) { const routes = routesByMethodId.get(route.methodNode.id) ?? []; routes.push({ method: route.httpMethod, path: route.rawPath }); routesByMethodId.set(route.methodNode.id, routes); } - const out: SpringTypeInfo[] = []; + const out: SharedSpringType[] = []; for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) { const typeNode = match.captures.type; @@ -598,13 +588,16 @@ function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo name: getNodeName(methodNode), routes: routesByMethodId.get(methodNode.id) ?? [], })) - .filter((method): method is SpringMethodInfo => method.name !== null); + .filter( + (method): method is { name: string; routes: Array<{ method: string; path: string }> } => + method.name !== null, + ); out.push({ filePath, kind, name: typeNameNode.text, - classPrefix: prefixByTypeId.get(typeNode.id) ?? '', + classPrefixes: prefixByTypeId.get(typeNode.id) ?? [], implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [], isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']), methods, @@ -614,62 +607,13 @@ function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo return out; } +// The interface-based-controller inheritance algorithm is shared with kotlin.ts +// (`scanSpringInheritanceProject`); this collects the `SharedSpringType` view and +// delegates so Java and Kotlin emit byte-identical provider contracts. 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, - ownerPrefix: type.classPrefix, - })); - 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: joinInheritedSpringPath(type.classPrefix, route.path, route.ownerPrefix), - })); - }); - - 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, - })); + return scanSpringInheritanceProject( + files.flatMap((file) => collectSpringTypes(file.filePath, file.tree)), + ); } export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { @@ -682,8 +626,14 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { // `scanRouteAnnotations` resolves every route-defining annotation — // class/interface prefixes, method `@(Get|...)Mapping`s and native // `@RequestLine`s — from a single `matches()` pass over the tree. - const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } = - scanRouteAnnotations(tree); + const { + prefixByTypeId, + feignPrefixByInterfaceId, + httpExchangePrefixByTypeId, + methodRoutes, + requestLines, + exchangeRoutes, + } = scanRouteAnnotations(tree); // A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign // *consumer* (it describes a remote call); the same annotation inside a @@ -693,28 +643,34 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { for (const route of methodRoutes) { const enclosingInterface = findEnclosingInterface(route.methodNode); if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { - const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; - out.push({ - role: 'consumer', - framework: 'openfeign', - method: route.httpMethod, - path: joinPath(prefix, route.rawPath), - name: route.methodName, - confidence: 0.7, - }); + const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: OPENFEIGN_FRAMEWORK, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, + confidence: FEIGN_CONFIDENCE, + }); + } continue; } const enclosingClass = findEnclosingClass(route.methodNode); if (!enclosingClass) continue; - const prefix = prefixByTypeId.get(enclosingClass.id) ?? ''; - out.push({ - role: 'provider', - framework: 'spring', - method: route.httpMethod, - path: joinPath(prefix, route.rawPath), - name: route.methodName, - confidence: 0.8, - }); + // A multi-element class `@RequestMapping({"/a","/b"})` registers the method + // under each prefix — emit one provider per (prefix × this route). + const prefixes = prefixByTypeId.get(enclosingClass.id) ?? ['']; + for (const prefix of prefixes) { + out.push({ + role: 'provider', + framework: 'spring', + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, + confidence: 0.8, + }); + } } // Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only and @@ -730,15 +686,38 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { for (const requestLine of requestLines) { const enclosingInterface = findEnclosingInterface(requestLine.methodNode); if (!enclosingInterface) continue; - const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; - out.push({ - role: 'consumer', - framework: 'openfeign', - method: requestLine.parsed.method, - path: joinPath(prefix, requestLine.parsed.path), - name: requestLine.methodName, - confidence: 0.75, - }); + const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: OPENFEIGN_FRAMEWORK, + method: requestLine.parsed.method, + path: joinPath(prefix, requestLine.parsed.path), + name: requestLine.methodName, + confidence: REQUEST_LINE_CONFIDENCE, + }); + } + } + + // ─── Consumers: Spring HTTP Interface @(Get|...)Exchange ──────── + // Declarative client interfaces proxied by `HttpServiceProxyFactory` + // (over RestClient / WebClient / RestTemplate). Always a consumer — no + // provider ambiguity — with an optional type-level `@HttpExchange(url)` + // prefix. The verb comes from the annotation name (`@GetExchange` → GET). + for (const route of exchangeRoutes) { + const enclosing = + findEnclosingInterface(route.methodNode) ?? findEnclosingClass(route.methodNode); + const prefixes = enclosing ? (httpExchangePrefixByTypeId.get(enclosing.id) ?? ['']) : ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: HTTP_INTERFACE_FRAMEWORK, + method: route.httpMethod, + path: joinPath(prefix, route.rawPath), + name: route.methodName, + confidence: EXCHANGE_CONFIDENCE, + }); + } } // ─── Consumers: RestTemplate ──────────────────────────────────── @@ -777,9 +756,9 @@ 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. + // Source-scan only: receiver must be named exactly `webClient`. The + // long-form chain `webClient.method(HttpMethod.X).uri("/x")` is handled + // separately below by WEB_CLIENT_LONG_FORM_PATTERNS. for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_FORM_PATTERNS, tree)) { const verbNode = match.captures.verb; const pathNode = match.captures.path; @@ -798,6 +777,29 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: WebClient.method(HttpMethod.X).uri("path") long form ─ + // The verb is captured as the literal `HttpMethod.X` field name; gate it on + // the shared verb regex (HEAD/OPTIONS/TRACE excluded, matching the short + // form). The short-form query requires an empty inner argument list, so it + // cannot also fire on this chain — no double-emit. + for (const match of runCompiledPatterns(WEB_CLIENT_LONG_FORM_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + 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/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 14dce0ae1d..4286ba7587 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -1,4 +1,4 @@ -import Parser from 'tree-sitter'; +import type Parser from 'tree-sitter'; import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js'; import { compilePatterns, @@ -6,7 +6,32 @@ 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'; +import { + METHOD_ANNOTATION_TO_HTTP, + findEnclosingClass, +} from '../../../ingestion/route-extractors/spring-shared.js'; +import { + REST_TEMPLATE_TO_HTTP, + WEB_CLIENT_SHORT_TO_HTTP, + WEB_CLIENT_LONG_VERB_RE, + EXCHANGE_ANNOTATION_TO_HTTP, + parseRequestLine, + pushPrefix, + joinPath, + scanSpringInheritanceProject, + type SharedSpringType, + OPENFEIGN_FRAMEWORK, + HTTP_INTERFACE_FRAMEWORK, + FEIGN_CONFIDENCE, + REQUEST_LINE_CONFIDENCE, + EXCHANGE_CONFIDENCE, +} from './spring-consumer-shared.js'; /** * Kotlin HTTP plugin (Spring providers + consumers). @@ -74,53 +99,37 @@ try { Kotlin = null; } -const METHOD_ANNOTATION_TO_HTTP: Record = { - GetMapping: 'GET', - PostMapping: 'POST', - PutMapping: 'PUT', - DeleteMapping: 'DELETE', - PatchMapping: 'PATCH', -}; +// The Spring `@(Get|...)Mapping` verb map, RestTemplate / WebClient short-form +// verb maps, the `@(Get|...)Exchange` verb map, `joinPath`, `parseRequestLine`, +// and the shared confidence/framework constants are imported from +// `spring-consumer-shared.ts` / `spring-shared.ts` so the Kotlin and Java +// plugins emit identical contract IDs. + +// The WebClient long-form verb gate (`WEB_CLIENT_LONG_VERB_RE`) is imported from +// `spring-consumer-shared.ts` so the Java and Kotlin long-form scans accept the +// same verb set (HEAD/OPTIONS/TRACE excluded, matching the short form). + +// The de-duping prefix accumulator (`pushPrefix`) is imported from +// `spring-consumer-shared.ts` so the Java and Kotlin plugins build their +// per-declaration prefix maps identically. /** - * 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. + * Tree-sitter sub-pattern for the Kotlin `arrayOf("/a", "/b")` annotation-array + * form, capturing each element string under `cap` (`@prefix` or `@path`). + * + * Kept as a DEDICATED query fragment embedded in its own pattern — NEVER as an + * arm of the `[(string_literal) (collection_literal …)]` alternation. The + * `#eq? @arrayOf "arrayOf"` predicate, sharing a single alternation bucket with + * the string/collection arms, would evaluate FALSE for those arms (where + * `@arrayOf` is absent) and silently drop them — the tree-sitter 0.21.x hazard + * documented in `java.ts`. tree-sitter yields one match per `arrayOf` element, + * so multi-element arrays accumulate through the same loops as `collection_literal` + * (verified by AST probe). The `arrayOf` callee constraint keeps unrelated calls + * (`buildPath("/x")`) from matching. */ -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', -}; - -/** - * 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)$/; +const arrayOfArg = (cap: string): string => `(call_expression + (simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf") + (call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`; /** * Build the plugin only if the Kotlin grammar is available. Compiling @@ -161,7 +170,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (constructor_invocation (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) (value_arguments - (value_argument . (string_literal) @prefix))))) + (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)]))))) (type_identifier) @cls) @class `, }, @@ -176,7 +185,35 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") - (string_literal) @prefix))))) + [(string_literal) @prefix (collection_literal (string_literal) @prefix)]))))) + (type_identifier) @cls) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument . ${arrayOfArg('@prefix')}))))) + (type_identifier) @cls) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + ${arrayOfArg('@prefix')}))))) (type_identifier) @cls) @class `, }, @@ -200,7 +237,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (constructor_invocation (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) (value_arguments - (value_argument . (string_literal) @path))))) + (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)]))))) (simple_identifier) @method_name) @method `, }, @@ -215,7 +252,35 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { (value_arguments (value_argument (simple_identifier) @key (#match? @key "^(path|value)$") - (string_literal) @path))))) + [(string_literal) @path (collection_literal (string_literal) @path)]))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument . ${arrayOfArg('@path')}))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(path|value)$") + ${arrayOfArg('@path')}))))) (simple_identifier) @method_name) @method `, }, @@ -400,31 +465,362 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } 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. - */ - function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { - let cur: Parser.SyntaxNode | null = node.parent; - while (cur) { - if (cur.type === 'class_declaration') return cur; - cur = cur.parent; - } - return null; - } + // ─── Consumer (OpenFeign): @FeignClient interface marker + path prefix ─ + // A `@FeignClient` interface's `@(Get|...)Mapping` methods describe OUTBOUND + // calls (consumers), not routes the service serves. Pattern 1 marks the + // interface; pattern 2 captures its optional `path = "/prefix"` (the + // `name`/`value`/`url` attributes identify the remote service, not a path). + // In tree-sitter-kotlin an `interface` is a `class_declaration`, so the + // method-route loop reclassifies @*Mapping methods whose enclosing + // class_declaration is in `feignClassIds` (see scan()). + const SPRING_FEIGN_CLIENT_PATTERNS = compilePatterns({ + name: 'kotlin-spring-feign-client', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "FeignClient")))))) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "FeignClient")) + (value_arguments + (value_argument + (simple_identifier) @key (#eq? @key "path") + [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "FeignClient")) + (value_arguments + (value_argument + (simple_identifier) @key (#eq? @key "path") + ${arrayOfArg('@prefix')})))))) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: Spring 6 HTTP Interface @(Get|...)Exchange ───────────── + // Declarative client interfaces proxied by HttpServiceProxyFactory (over + // RestClient / WebClient / RestTemplate). The path lives in `url`/`value` + // (named) or positionally. Always a consumer — no provider ambiguity. + const SPRING_EXCHANGE_PATTERNS = compilePatterns({ + name: 'kotlin-spring-http-exchange', + language, + patterns: [ + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$")) + (value_arguments + (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)]))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(url|value)$") + [(string_literal) @path (collection_literal (string_literal) @path)]))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$")) + (value_arguments + (value_argument . ${arrayOfArg('@path')}))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(url|value)$") + ${arrayOfArg('@path')}))))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: HTTP Interface type-level @HttpExchange(url) prefix ───── + const SPRING_HTTP_EXCHANGE_CLASS_PATTERNS = compilePatterns({ + name: 'kotlin-spring-http-exchange-class', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "HttpExchange")) + (value_arguments + (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "HttpExchange")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(url|value)$") + [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "HttpExchange")) + (value_arguments + (value_argument . ${arrayOfArg('@prefix')})))))) @class + `, + }, + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "HttpExchange")) + (value_arguments + (value_argument + (simple_identifier) @key (#match? @key "^(url|value)$") + ${arrayOfArg('@prefix')})))))) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Consumer: OpenFeign native @RequestLine("VERB /path") ──────────── + // Two patterns mirror the positional vs named split. java.ts accepts the + // named `value` argument (java.ts:442 drops any non-`value` key); the + // positional pattern's `.` anchor only matches when the string literal is the + // first argument, so the named form needs its own pattern. Constraining + // `#eq? @key "value"` keeps non-`value` keys (`name`, etc.) dropped — Java + // parity, just enforced in the query rather than the JS loop. + const SPRING_REQUEST_LINE_PATTERNS = compilePatterns({ + name: 'kotlin-spring-request-line', + language, + patterns: [ + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestLine")) + (value_arguments + (value_argument . (string_literal) @value))))) + (simple_identifier) @method_name) @method + `, + }, + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestLine")) + (value_arguments + (value_argument + (simple_identifier) @key (#eq? @key "value") + (string_literal) @value))))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + // ─── Provider via interface inheritance (Spring interface-based controller) ─ + // Pattern: `@RestController class X(...) : XApi` where the route annotations + // live on the `XApi` interface and the controller's `override fun` carries + // none. Java resolves this in `scanProject` (scanSpringProject); this is the + // Kotlin port. tree-sitter-kotlin models BOTH class and interface as + // `class_declaration`; the `interface` keyword token distinguishes them. + const KOTLIN_TYPE_DECLARATION_PATTERNS = compilePatterns({ + name: 'kotlin-type-declaration', + language, + patterns: [{ meta: {}, query: `(class_declaration (type_identifier) @name) @type` }], + } satisfies LanguagePatterns>); + + /** A `class_declaration` is an interface when it carries the `interface` keyword token. */ + const isKotlinInterface = (node: Parser.SyntaxNode): boolean => + node.children.some((c) => c.type === 'interface'); + + /** Resolve an `annotation` node's simple name: `@Foo` / `@Foo(...)` / `@a.b.Foo` → "Foo". */ + const kotlinAnnotationName = (annotation: Parser.SyntaxNode): string | null => { + const direct = annotation.namedChildren.find((c) => c.type === 'user_type'); + const ctor = annotation.namedChildren.find((c) => c.type === 'constructor_invocation'); + const userType = direct ?? ctor?.namedChildren.find((c) => c.type === 'user_type'); + // A fully-qualified annotation (`@a.b.Foo`) parses to a `user_type` carrying + // one `type_identifier` per dotted segment (`a`, `b`, `Foo`); the trailing + // one is the simple name. Taking the FIRST would resolve `@org…RestController` + // to "org" and miss the controller. + const idents = userType?.namedChildren.filter((c) => c.type === 'type_identifier') ?? []; + const ident = idents.at(-1); + return ident ? ident.text : null; + }; /** - * Join a class-level prefix and a method-level path. Identical - * semantics to the Java plugin: strip leading/trailing slashes on - * the prefix, strip leading slashes on the method path, ensure a - * single slash between them. + * Whether a `class_declaration` is a Spring `@RestController` / `@Controller`. + * All forms attach under `modifiers` as an `annotation` (confirmed against + * tree-sitter-kotlin fwcd): the bare `@RestController`, the common + * `@RestController @RequestMapping("/x")` pair, AND the arg-form + * `@RestController("beanName")` — the last parses to an `annotation` whose + * child is a `constructor_invocation` (NOT a detached sibling), which + * `kotlinAnnotationName` reads. A single pass over `modifiers` covers them all. */ - function joinPath(prefix: string, methodPath: string): string { - const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleanSub = methodPath.replace(/^\/+/, ''); - if (!cleanPrefix) return `/${cleanSub}`; - return `/${cleanPrefix}/${cleanSub}`; - } + const CONTROLLER_ANNOTATIONS = new Set(['RestController', 'Controller']); + const kotlinClassIsController = (typeNode: Parser.SyntaxNode): boolean => { + const modifiers = typeNode.namedChildren.find((c) => c.type === 'modifiers'); + for (const ann of modifiers?.namedChildren ?? []) { + if (ann.type !== 'annotation') continue; + const name = kotlinAnnotationName(ann); + if (name && CONTROLLER_ANNOTATIONS.has(name)) return true; + } + return false; + }; + + /** Supertype names from `: A, B` (`delegation_specifier` → `user_type` → `type_identifier`). */ + const collectKotlinSupertypes = (node: Parser.SyntaxNode): string[] => { + const out: string[] = []; + for (const child of node.namedChildren) { + if (child.type !== 'delegation_specifier') continue; + const userType = child.namedChildren.find((c) => c.type === 'user_type'); + // FQN supertype (`: a.b.Api`) → one `type_identifier` per segment; the + // trailing one is the simple name (taking the first would yield "a"). + const idents = userType?.namedChildren.filter((c) => c.type === 'type_identifier') ?? []; + const ident = idents.at(-1); + if (ident) out.push(ident.text); + } + return out; + }; + + /** Direct `function_declaration` members of a type (no descent into nested types). */ + const collectKotlinDirectMethods = (typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] => { + const body = typeNode.namedChildren.find((c) => c.type === 'class_body'); + if (!body) return []; + return body.namedChildren.filter((c) => c.type === 'function_declaration'); + }; + + const kotlinFunctionName = (fn: Parser.SyntaxNode): string | null => + fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null; + + const collectKotlinSpringTypes = (filePath: string, tree: Parser.Tree): SharedSpringType[] => { + // Class-level @RequestMapping prefixes (reuse the provider class-prefix query). + 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) pushPrefix(prefixByClassId, classNode.id, prefix); + } + // Method @(Get|...)Mapping routes keyed by the function_declaration node id. + 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 arr = routesByMethodId.get(methodNode.id) ?? []; + arr.push({ method: httpMethod, path: rawPath }); + routesByMethodId.set(methodNode.id, arr); + } + + const out: SharedSpringType[] = []; + for (const match of runCompiledPatterns(KOTLIN_TYPE_DECLARATION_PATTERNS, tree)) { + const typeNode = match.captures.type; + const nameNode = match.captures.name; + if (!typeNode || !nameNode) continue; + const kind = isKotlinInterface(typeNode) ? 'interface' : 'class'; + const methods = collectKotlinDirectMethods(typeNode) + .map((fn) => ({ name: kotlinFunctionName(fn), routes: routesByMethodId.get(fn.id) ?? [] })) + .filter((m): m is { name: string; routes: Array<{ method: string; path: string }> } => { + return m.name !== null; + }); + out.push({ + filePath, + kind, + name: nameNode.text, + isController: kind === 'class' ? kotlinClassIsController(typeNode) : false, + classPrefixes: prefixByClassId.get(typeNode.id) ?? [], + implementedInterfaces: kind === 'class' ? collectKotlinSupertypes(typeNode) : [], + methods, + }); + } + return out; + }; + + // The interface-based-controller inheritance algorithm is shared with java.ts + // (`scanSpringInheritanceProject`); this collects the language-specific + // `SharedSpringType` view and delegates. kotlinClassIsController handles every + // controller form (bare, paired, and the arg-form `@RestController("bean")`) + // via the `modifiers` `annotation`/`constructor_invocation` shape. + const scanKotlinProject = (files: readonly HttpScanInput[]): HttpFileDetections[] => + scanSpringInheritanceProject( + files.flatMap((f) => collectKotlinSpringTypes(f.filePath, f.tree)), + ); return { name: 'kotlin-http', @@ -433,16 +829,42 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const out: HttpDetection[] = []; // ─── Class prefixes ───────────────────────────────────────────── - const prefixByClassId = new Map(); + 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); + if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix); } - // ─── Method routes ────────────────────────────────────────────── + // ─── OpenFeign client interfaces + HTTP Interface type prefixes ── + // In tree-sitter-kotlin an `interface` is a `class_declaration`, so a + // `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be + // mis-emitted as providers. Collect the FeignClient class ids (and their + // optional `path` prefix) so the method-route loop can reclassify them. + const feignClassIds = new Set(); + const feignPrefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_FEIGN_CLIENT_PATTERNS, tree)) { + const classNode = match.captures.class; + if (!classNode) continue; + feignClassIds.add(classNode.id); + const prefixNode = match.captures.prefix; + if (prefixNode) { + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) pushPrefix(feignPrefixByClassId, classNode.id, prefix); + } + } + const httpExchangePrefixByClassId = new Map(); + for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) { + const classNode = match.captures.class; + const prefixNode = match.captures.prefix; + if (!classNode || !prefixNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null) pushPrefix(httpExchangePrefixByClassId, classNode.id, prefix); + } + + // ─── Method routes (Spring providers) + OpenFeign consumers ───── for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; @@ -454,16 +876,45 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; const enclosingClass = findEnclosingClass(methodNode); - const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; - const fullPath = joinPath(prefix, rawPath); - out.push({ - role: 'provider', - framework: 'spring', - method: httpMethod, - path: fullPath, - name: nameNode?.text ?? null, - confidence: 0.8, - }); + // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign + // consumer (a remote call), not a route this service serves. + if (enclosingClass && feignClassIds.has(enclosingClass.id)) { + // @FeignClient(path) wins over @RequestMapping; a multi-element prefix + // yields one consumer per (prefix × this route). + const prefixes = feignPrefixByClassId.get(enclosingClass.id) ?? + prefixByClassId.get(enclosingClass.id) ?? ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: OPENFEIGN_FRAMEWORK, + method: httpMethod, + path: joinPath(prefix, rawPath), + name: nameNode?.text ?? null, + confidence: FEIGN_CONFIDENCE, + }); + } + continue; + } + // A @(Get|...)Mapping on a (non-Feign) interface declares a route + // *contract*, not a route this service serves — the implementing + // @RestController is the provider, emitted via scanProject's interface + // inheritance. Java drops these implicitly (findEnclosingClass returns + // null for an interface_declaration); tree-sitter-kotlin models an + // interface as a class_declaration, so skip it explicitly here. + if (enclosingClass && isKotlinInterface(enclosingClass)) continue; + // A multi-element class `@RequestMapping(["/a","/b"])` registers the method + // under each prefix — emit one provider per (prefix × this route). + const prefixes = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? ['']) : ['']; + for (const prefix of prefixes) { + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: joinPath(prefix, rawPath), + name: nameNode?.text ?? null, + confidence: 0.8, + }); + } } // ─── Consumers: RestTemplate ──────────────────────────────────── @@ -547,8 +998,74 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { }); } + // ─── Consumers: Spring HTTP Interface @(Get|...)Exchange ──────── + for (const match of runCompiledPatterns(SPRING_EXCHANGE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const pathNode = match.captures.path; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!annNode || !pathNode || !methodNode) continue; + const httpMethod = EXCHANGE_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const rawPath = unquoteLiteral(pathNode.text); + if (rawPath === null) continue; + const enclosingClass = findEnclosingClass(methodNode); + const prefixes = enclosingClass + ? (httpExchangePrefixByClassId.get(enclosingClass.id) ?? ['']) + : ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: HTTP_INTERFACE_FRAMEWORK, + method: httpMethod, + path: joinPath(prefix, rawPath), + name: nameNode?.text ?? null, + confidence: EXCHANGE_CONFIDENCE, + }); + } + } + + // ─── Consumers: OpenFeign native @RequestLine("VERB /path") ───── + // Method-level only and always declared on an interface — Feign builds its + // proxy from the interface, so a `@RequestLine` on a concrete class is not + // a client call. We do NOT require an enclosing `@FeignClient` (core Feign + // uses `@RequestLine` with `Feign.builder()`, not Spring Cloud's + // `@FeignClient`); the `RequestLine` name plus the structural interface + // check keep false positives away. Mirrors java.ts's `findEnclosingInterface` + // gate — in tree-sitter-kotlin an interface is a `class_declaration`, so we + // test the `interface` keyword via isKotlinInterface. + for (const match of runCompiledPatterns(SPRING_REQUEST_LINE_PATTERNS, tree)) { + const valueNode = match.captures.value; + const nameNode = match.captures.method_name; + const methodNode = match.captures.method; + if (!valueNode || !methodNode) continue; + const raw = unquoteLiteral(valueNode.text); + const parsed = raw !== null ? parseRequestLine(raw) : null; + if (!parsed) continue; + const enclosingClass = findEnclosingClass(methodNode); + if (!enclosingClass || !isKotlinInterface(enclosingClass)) continue; + // Mirror java.ts (which pre-merges the @RequestMapping fallback into + // feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else + // the interface's class-level @RequestMapping prefix, else none. Without + // the prefixByClassId fallback Kotlin dropped the class prefix that Java + // applies — the same fallback chain the @GetMapping-in-Feign path uses above. + const prefixes = feignPrefixByClassId.get(enclosingClass.id) ?? + prefixByClassId.get(enclosingClass.id) ?? ['']; + for (const prefix of prefixes) { + out.push({ + role: 'consumer', + framework: OPENFEIGN_FRAMEWORK, + method: parsed.method, + path: joinPath(prefix, parsed.path), + name: nameNode?.text ?? null, + confidence: REQUEST_LINE_CONFIDENCE, + }); + } + } + return out; }, + scanProject: scanKotlinProject, }; } diff --git a/gitnexus/src/core/group/extractors/http-patterns/spring-consumer-shared.ts b/gitnexus/src/core/group/extractors/http-patterns/spring-consumer-shared.ts new file mode 100644 index 0000000000..f42894da6f --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/spring-consumer-shared.ts @@ -0,0 +1,269 @@ +/** + * Shared, language-agnostic primitives for Spring / OpenFeign / Spring-HTTP-Interface + * HTTP *consumer* extraction, used by BOTH the Java (`java.ts`) and Kotlin + * (`kotlin.ts`) group-layer HTTP plugins so the two cannot drift apart. + * + * These are pure value maps + string helpers — no tree-sitter AST knowledge. + * Each language plugin keeps its own grammar-specific queries and walkers and + * funnels the extracted (verb, path, prefix) facts through these helpers, so a + * polyglot repo emits byte-identical contract IDs from `.java` and `.kt`. + * + * The provider-side annotation→verb map (`METHOD_ANNOTATION_TO_HTTP`), + * `isRouteMemberKey`, and `findEnclosingClass` live in the lower + * `ingestion/route-extractors/spring-shared.ts` (shared with the ingestion + * route extractor). This module is the consumer-side counterpart and lives in + * the group layer beside the plugins that use it. + */ + +import type { HttpDetection, HttpFileDetections } from './types.js'; + +/** + * RestTemplate method-name → HTTP verb. Source-scan only: the receiver must be + * named exactly `restTemplate` (the per-language query enforces that). + */ +export const REST_TEMPLATE_TO_HTTP: Record = { + getForObject: 'GET', + getForEntity: 'GET', + postForObject: 'POST', + postForEntity: 'POST', + put: 'PUT', + delete: 'DELETE', + patchForObject: 'PATCH', +}; + +/** + * Reactive WebClient short-form verb helper → HTTP verb + * (`webClient.get().uri("/x")`, `.post()`, ...). HEAD/OPTIONS/TRACE are + * intentionally excluded for symmetry across the plugins. + */ +export const WEB_CLIENT_SHORT_TO_HTTP: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +/** + * Accepted HTTP verbs for the WebClient long form + * `webClient.method(HttpMethod.X).uri("/y")`. The verb is captured as the + * literal `HttpMethod.X` field name (`GET`, `POST`, …); HEAD/OPTIONS/TRACE are + * intentionally excluded for symmetry with the short form + * (`WEB_CLIENT_SHORT_TO_HTTP`). Shared so the Java and Kotlin long-form scans + * gate verbs identically. + */ +export const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/; + +/** + * Spring 6 declarative HTTP Interface shortcut annotation → HTTP verb. + * + * `@GetExchange`/`@PostExchange`/… on a service interface proxied by + * `HttpServiceProxyFactory` (over RestClient / WebClient / RestTemplate) + * describe an OUTBOUND call — i.e. a CONSUMER, the modern analogue of an + * OpenFeign `@(Get|Post|...)Mapping` interface method. The path lives in the + * annotation's `url` (or `value`) attribute, or positionally. + * + * The base `@HttpExchange(method = "GET", url = "...")` form carries its verb + * in an attribute rather than the annotation name; the shortcut annotations + * above are the overwhelmingly common case and the only ones mapped here. + */ +export const EXCHANGE_ANNOTATION_TO_HTTP: Record = { + GetExchange: 'GET', + PostExchange: 'POST', + PutExchange: 'PUT', + DeleteExchange: 'DELETE', + PatchExchange: 'PATCH', +}; + +/** + * Accumulate a route prefix (de-duped) under a class/interface declaration node + * id. A Spring route attribute is `String[]`; a multi-element array (`["/a","/b"]`, + * `arrayOf("/a","/b")`, `{"/a","/b"}`) yields one query match per element, so + * prefixes accumulate rather than overwrite. Shared by the Java and Kotlin plugins + * so both build their prefix maps identically. + */ +export const pushPrefix = (map: Map, id: number, prefix: string): void => { + const arr = map.get(id) ?? []; + if (!arr.includes(prefix)) arr.push(prefix); + map.set(id, arr); +}; + +/** Framework tags emitted on consumer detections (stable contract metadata). */ +export const OPENFEIGN_FRAMEWORK = 'openfeign'; +export const HTTP_INTERFACE_FRAMEWORK = 'spring-http-interface'; + +/** Consumer-detection confidences, shared so `.java` and `.kt` agree. */ +export const FEIGN_CONFIDENCE = 0.7; +export const REQUEST_LINE_CONFIDENCE = 0.75; +export const EXCHANGE_CONFIDENCE = 0.75; + +/** + * OpenFeign's native `@RequestLine("METHOD /path[?query]")` packs an HTTP + * method and path in a single string literal — see + * https://github.com/OpenFeign/feign#interface-annotations. This regex splits + * the verb from the path of that literal. + */ +export const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i; + +/** + * Parse a Feign `@RequestLine` value into a method + path pair. The query + * portion is dropped because contract IDs are method+path only (consistent + * with how RestTemplate/WebClient consumers drop inline query strings). + * + * Returns null if the value is not a recognized HTTP verb followed by a path + * beginning with `/`. + */ +export function parseRequestLine(raw: string): { method: string; path: string } | null { + const match = REQUEST_LINE_VERB_RE.exec(raw); + if (!match) return null; + const [, verb, rest] = match; + if (typeof verb !== 'string' || typeof rest !== 'string') return null; + const queryIdx = rest.indexOf('?'); + const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim(); + if (!pathOnly.startsWith('/')) return null; + return { method: verb.toUpperCase(), path: pathOnly }; +} + +/** + * Join a class/interface-level prefix and a method-level path into a single + * URL path: strip leading/trailing slashes on the prefix and leading slashes + * on the method path, then ensure exactly one slash between them. + */ +export function joinPath(prefix: string, methodPath: string): string { + const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanSub = methodPath.replace(/^\/+/, ''); + if (!cleanPrefix) return `/${cleanSub}`; + return `/${cleanPrefix}/${cleanSub}`; +} + +/** + * Join a controller's own class prefix with a route inherited from an interface + * (interface-based controllers, #1743). The inherited path already has the + * interface's own class prefix (`inheritedOwnerPrefix`) baked in; when the + * controller repeats that same prefix we must NOT prepend it twice (#2057). + * Shared by both plugins' `scanProject` so Java and Kotlin agree. + */ +export function joinInheritedSpringPath( + controllerPrefix: string, + inheritedPath: string, + inheritedOwnerPrefix = '', +): string { + const joined = joinPath(controllerPrefix, inheritedPath); + const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanInherited = inheritedPath.replace(/^\/+/, ''); + if (!cleanPrefix) return joined; + if ( + cleanPrefix === cleanOwnerPrefix && + (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`)) + ) { + return `/${cleanInherited}`; + } + return joined; +} + +/** + * Language-agnostic view of a Spring class/interface that each plugin's + * grammar-specific collector produces. The interface-based-controller + * inheritance algorithm (`scanSpringInheritanceProject`) operates only on this + * shape, so the Java and Kotlin plugins share one algorithm and cannot drift. + * + * `methods[].routes` carry only `{ method, path }` — the interface's own class + * prefix is applied *inside* `scanSpringInheritanceProject` (it is not part of + * the collector's output). + */ +export interface SharedSpringType { + filePath: string; + kind: 'class' | 'interface'; + name: string; + /** Class-level `@RequestMapping` prefixes — one per array element. */ + classPrefixes: string[]; + implementedInterfaces: string[]; + isController: boolean; + methods: Array<{ name: string; routes: Array<{ method: string; path: string }> }>; +} + +/** + * Resolve interface-based-controller provider routes (#1743): a concrete + * `@RestController`/`@Controller` class inherits the `@(Get|...)Mapping` routes + * declared on the interface it implements. Shared by the Java and Kotlin plugins + * so both emit byte-identical provider contracts. + * + * An interface name that resolves to two distinct interfaces is ambiguous and + * its routes are dropped (the `null` marker). The controller's own class + * prefix(es) cross-product the inherited routes; `joinInheritedSpringPath` + * avoids doubling a prefix the interface already baked in (#2057). + */ +export function scanSpringInheritanceProject(types: SharedSpringType[]): HttpFileDetections[] { + // interface name → (method name → routes). `ownerPrefix` records the + // interface's own class prefix so the controller side avoids doubling it + // (#2057). `null` marks an ambiguous (duplicated) interface name. + type InheritedRoute = { method: string; path: string; ownerPrefix: string }; + 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 prefixes = type.classPrefixes.length ? type.classPrefixes : ['']; + const methodMap = new Map(); + for (const method of type.methods) { + // Cross-product the interface's class prefixes with each method route, so a + // multi-element `@RequestMapping(["/a","/b"])` interface yields N bindings. + const routes = method.routes.flatMap((route) => + prefixes.map((prefix) => ({ + method: route.method, + path: prefix ? joinPath(prefix, route.path) : route.path, + ownerPrefix: prefix, + })), + ); + 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; + // Cross-product the controller's own class prefixes with each inherited + // route; `['']` keeps the common no-prefix controller emitting the + // interface path unchanged. + const controllerPrefixes = type.classPrefixes.length ? type.classPrefixes : ['']; + for (const method of type.methods) { + if (method.routes.length > 0) continue; // own @*Mapping → already a provider via scan() + const inherited = type.implementedInterfaces.flatMap((iface) => { + const routeMap = interfaceRoutes.get(iface); + if (!routeMap) return []; + const routes = routeMap.get(method.name) ?? []; + return routes.flatMap((route) => + controllerPrefixes.map((controllerPrefix) => ({ + method: route.method, + path: joinInheritedSpringPath(controllerPrefix, route.path, route.ownerPrefix), + })), + ); + }); + const seen = new Set(); + for (const route of inherited) { + const key = `${route.method} ${route.path}`; + if (seen.has(key)) continue; + seen.add(key); + 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, + })); +} diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 52e6f8a49b..2d688c7e8c 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -950,6 +950,8 @@ public class UserController implements UserApi { expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe( 'src/controller/UserController.java', ); + expect(usersRoute!.meta.framework).toBe('spring'); + expect(usersRoute!.confidence).toBe(0.8); }); it('does not duplicate inherited Spring prefixes already present on the controller', async () => { @@ -1156,6 +1158,37 @@ public class StatusController implements StatusApi { ).toHaveLength(0); }); + it('does not extract fully-qualified Java route annotations (documented limitation #2254)', async () => { + // JAVA_ROUTE_ANNOTATION_PATTERNS binds `name: (identifier)`; a FQN route + // annotation parses its name as `scoped_identifier` and is not matched, so + // its route is not extracted (only the route string — the controller itself + // is still recognised). This pins that documented limitation / asymmetry + // with Kotlin. If FQN matching is ever added, flip this assertion. + const dir = path.join(tmpDir, 'java-fqn-route-annotation'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'FqnController.java'), + ` +@org.springframework.web.bind.annotation.RestController +@org.springframework.web.bind.annotation.RequestMapping("/api") +class FqnController { + @org.springframework.web.bind.annotation.GetMapping("/users") + Object users() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + // The FQN route annotation is not extracted (documented limitation). + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined(); + // And it must not over-match into a bogus contract either. + expect( + providers.filter((c) => c.symbolRef.filePath.endsWith('FqnController.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 }); @@ -1738,7 +1771,10 @@ class ApiClient { ).toBeDefined(); }); - it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => { + it('extracts Java WebClient long-form method(HttpMethod.X).uri(...) — #2254 parity', async () => { + // Parity with the Kotlin plugin: a single structural query matches the + // verb (HttpMethod.X field access) and path. Previously deferred on the + // Java side; PR #2254 lifts it so .java and .kt detect it identically. const dir = path.join(tmpDir, 'java-web-client-long-form'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -1749,6 +1785,10 @@ import org.springframework.web.reactive.function.client.WebClient; class LongFormClient { void run(WebClient webClient) { + webClient.method(HttpMethod.GET).uri("/api/get").retrieve(); + webClient.method(HttpMethod.POST).uri("/api/post").retrieve(); + webClient.method(HttpMethod.PUT).uri("/api/put").retrieve(); + webClient.method(HttpMethod.DELETE).uri("/api/delete").retrieve(); webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve(); } } @@ -1758,11 +1798,105 @@ class LongFormClient { const contracts = await extractor.extract(null, dir, makeRepo(dir)); const consumers = contracts.filter((c) => c.role === 'consumer'); + for (const [verb, p] of [ + ['GET', '/api/get'], + ['POST', '/api/post'], + ['PUT', '/api/put'], + ['DELETE', '/api/delete'], + ['PATCH', '/api/users/{param}'], + ]) { + expect( + consumers.find( + (c) => + c.contractId === `http::${verb}::${p}` && + c.meta.framework === 'spring-web-client' && + c.confidence === 0.7, + ), + ).toBeDefined(); + } + // No double-emit: the short-form query cannot also fire on the long form. + expect(consumers.filter((c) => c.contractId === 'http::GET::/api/get')).toHaveLength(1); + }); + + it('does NOT match Java WebClient long-form with a variable-bound verb', async () => { + // The value carries a bare identifier, not a HttpMethod.X field access — + // source-scan can't follow the binding (anti-overreach, parity with Kotlin). + const dir = path.join(tmpDir, 'java-web-client-long-form-var'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'VarVerbClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class VarVerbClient { + void run(WebClient webClient, HttpMethod verb) { + webClient.method(verb).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}'), + consumers.find( + (c) => c.contractId.startsWith('http::') && c.contractId.includes('/api/users'), + ), ).toBeUndefined(); }); + it('handles Java array-form annotation paths ({"/x"}, key = {"/x"})', async () => { + const dir = path.join(tmpDir, 'java-array-paths'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // Provider: array class prefix + array method path. + fs.writeFileSync( + path.join(dir, 'src', 'ProductController.java'), + ` +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; + +@RestController +@RequestMapping({"/api/products"}) +class ProductController { + @GetMapping(path = {"/{id}"}) + Product get(Integer id) { return null; } +} +`, + ); + // OpenFeign consumer with array positional path. + fs.writeFileSync( + path.join(dir, 'src', 'OrdersClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; + +@FeignClient(name = "orders") +interface OrdersClient { + @PostMapping({"/orders/search"}) + Object search(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Array class prefix + array method path → provider. + expect( + providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'), + ).toBeDefined(); + // @FeignClient with array positional path → consumer. + expect( + consumers.find( + (c) => c.contractId === 'http::POST::/orders/search' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + 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 }); @@ -1805,6 +1939,46 @@ interface OrderClient { ).toBeUndefined(); }); + it('extracts Spring HTTP Interface @(Get|...)Exchange clients as consumers', async () => { + const dir = path.join(tmpDir, 'java-http-exchange-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryApi.java'), + ` +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange(url = "/items") +interface InventoryApi { + @GetExchange(url = "/{id}") + Item getItem(Integer id); + + @PostExchange("/search") + Page search(ItemFilter query); +} +`, + ); + + 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::/items/{param}' && + c.meta.framework === 'spring-http-interface' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined(); + // Declarative HTTP-interface methods are consumers, never providers. + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('InventoryApi.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 }); @@ -2251,6 +2425,64 @@ interface ReversedPrecedenceClient { expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); }); + it('a @FeignClient API interface implemented by a controller yields both a consumer and a provider (Java)', async () => { + // Java twin of the Kotlin dual-role case: an `api` module publishes a + // @FeignClient contract (consumer) that the service's @RestController + // implements (provider). + const dir = path.join(tmpDir, 'java-feign-api-implemented'); + 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/WarehouseApi.java'), + ` +package com.example.rest; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name = "catalog-service") +@RequestMapping("/warehouses") +public interface WarehouseApi { + @GetMapping("/{id}/stock") + Object listStock(); +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src/controller/WarehouseController.java'), + ` +package com.example.controller; +import com.example.rest.WarehouseApi; +import org.springframework.web.bind.annotation.*; + +@RestController +public class WarehouseController implements WarehouseApi { + @Override + public Object listStock() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/warehouses/{param}/stock' && + c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + expect( + providers.find( + (c) => + c.contractId === 'http::GET::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.java') && + c.confidence === 0.8, + ), + ).toBeDefined(); + }); + 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 }); @@ -2688,6 +2920,1903 @@ class CacheClient(private val cacheClient: SomeCache) { }, ); + // ─── Kotlin OpenFeign + Spring HTTP Interface consumers ────────────── + // `@FeignClient` interfaces (Spring MVC `@*Mapping` methods) and Spring 6 + // declarative HTTP Interfaces (`@(Get|...)Exchange`) are the dominant + // outbound-call patterns in Kotlin+Spring services. In tree-sitter-kotlin + // an `interface` is a `class_declaration`, so without a `@FeignClient` + // gate the `@*Mapping` methods would mis-classify as providers. + itKotlinConsumer( + 'extracts Kotlin @FeignClient methods as consumers, not providers', + async () => { + const dir = path.join(tmpDir, 'kotlin-feign-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping + +@FeignClient(name = "inventory-service", configuration = [InventoryFeignClientConfig::class]) +interface InventoryClient { + @GetMapping("items/{itemId}", consumes = [MediaType.APPLICATION_JSON_VALUE], produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getItem(@PathVariable("itemId") itemId: Int): ItemDto + + @PostMapping("items/search", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun getItems(@RequestBody query: ItemFilter): Page +} +`, + ); + + 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::/items/{param}' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined(); + // The Feign interface methods must NOT leak into providers. + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('InventoryClient.kt')), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer('applies @FeignClient(path) and @RequestMapping prefixes', async () => { + // One interface per file — the real-world layout (e.g. InventoryClient.kt). + const dir = path.join(tmpDir, 'kotlin-feign-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'PrecedenceClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@FeignClient(name = "a", path = "/feign-path") +@RequestMapping("/rm-path") +interface PrecedenceClient { + @GetMapping("/orders") + fun getOrders(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@FeignClient(name = "b") +@RequestMapping(path = "/api") +interface InventoryClient { + @GetMapping("/inventory/{id}") + fun getInventory(id: String): Any +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // @FeignClient(path) wins over @RequestMapping. + expect(consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); + // @RequestMapping is the fallback prefix when there is no @FeignClient(path). + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/inventory/{param}'), + ).toBeDefined(); + }); + + itKotlinConsumer( + 'extracts Kotlin Spring HTTP Interface @(Get|...)Exchange consumers', + async () => { + const dir = path.join(tmpDir, 'kotlin-http-exchange'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryApi.kt'), + `package com.example +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.PostExchange +import org.springframework.web.service.annotation.PutExchange +import org.springframework.web.service.annotation.PatchExchange +import org.springframework.web.service.annotation.DeleteExchange + +interface InventoryApi { + @GetExchange(url = "/items/{itemId}", accept = [MediaType.APPLICATION_JSON_VALUE]) + fun obtainItem(@PathVariable itemId: Int): Any + + @PostExchange(url = "/items/search") + fun search(): Any + + @PutExchange(url = "/items") + fun create(): Any + + @PatchExchange(url = "/items/update/{itemId}") + fun update(@PathVariable itemId: Int): Any + + @DeleteExchange(url = "/items/{itemId}") + fun remove(@PathVariable itemId: Int): Any +} +`, + ); + + 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::/items/{param}' && + c.meta.framework === 'spring-http-interface' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/items/search')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PUT::/items')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/items/update/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/items/{param}'), + ).toBeDefined(); + // Declarative HTTP-interface methods are consumers, never providers. + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('InventoryApi.kt')), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'applies class-level @HttpExchange(url) prefix and a positional @GetExchange', + async () => { + const dir = path.join(tmpDir, 'kotlin-http-exchange-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ProductApi.kt'), + `package com.example +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.GetExchange + +@HttpExchange(url = "/products") +interface ProductApi { + @GetExchange("/{id}") + fun get(@PathVariable id: Int): Any +} +`, + ); + + 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::/products/{param}' && + c.meta.framework === 'spring-http-interface', + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer('extracts Kotlin OpenFeign native @RequestLine consumers', async () => { + const dir = path.join(tmpDir, 'kotlin-feign-request-line'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import feign.RequestLine + +@FeignClient(name = "ai-backend") +interface AiClient { + @RequestLine("POST /ai/summarize") + fun summarize(): String + + @RequestLine("GET /ai/health") + fun health(): String +} +`, + ); + + 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::/ai/summarize' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/ai/health')).toBeDefined(); + }); + + itKotlinConsumer( + 'applies the @RequestMapping interface prefix to @RequestLine consumers (no @FeignClient path) — #2254 P2 parity', + async () => { + // Parity with java.ts, which merges the @RequestMapping prefix into + // feignPrefixByInterfaceId: an interface with @RequestMapping("/orders") + // and a @RequestLine method (no @FeignClient(path)) must apply the prefix. + // Kotlin previously dropped it (PR #2254 tri-review, kotlin.ts:978). + const dir = path.join(tmpDir, 'kotlin-request-line-rm-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestMapping +import feign.RequestLine + +@FeignClient(name = "order-service") +@RequestMapping("/orders") +interface OrderClient { + @RequestLine("GET /{id}") + fun get(id: String): Any +} +`, + ); + + 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::/orders/{param}' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + // The un-prefixed form must NOT be emitted (the prefix was applied). + expect(consumers.find((c) => c.contractId === 'http::GET::/{param}')).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'prefers @FeignClient(path) over @RequestMapping for @RequestLine consumers', + async () => { + const dir = path.join(tmpDir, 'kotlin-request-line-feign-path-wins'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestMapping +import feign.RequestLine + +@FeignClient(name = "order-service", path = "/feign-path") +@RequestMapping("/rm-path") +interface OrderClient { + @RequestLine("GET /orders/{id}") + fun get(id: String): Any +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // @FeignClient(path) wins over @RequestMapping (parity with the @GetMapping path). + expect( + consumers.find((c) => c.contractId === 'http::GET::/feign-path/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders/{param}'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'extracts Kotlin @RequestLine written with the named "value" argument (#2254 P2)', + async () => { + const dir = path.join(tmpDir, 'kotlin-request-line-named-value'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import feign.RequestLine + +@FeignClient(name = "ai-backend") +interface AiClient { + @RequestLine(value = "POST /create") + fun create(): String +} +`, + ); + + 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::/create' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'ignores Kotlin @RequestLine whose named argument is not "value"', + async () => { + const dir = path.join(tmpDir, 'kotlin-request-line-non-value-key'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import feign.RequestLine + +@FeignClient(name = "ai-backend") +interface AiClient { + @RequestLine(name = "GET /should-not-extract") + fun nope(): String +} +`, + ); + + 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::/should-not-extract'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'strips query strings from Kotlin @RequestLine values when forming contract IDs', + async () => { + const dir = path.join(tmpDir, 'kotlin-request-line-query'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'SearchClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import feign.RequestLine + +@FeignClient(name = "search-service") +interface SearchClient { + @RequestLine("GET /search?q={query}&limit={limit}") + fun search(): Any +} +`, + ); + + 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::/search')).toBeDefined(); + expect( + consumers.find((c) => c.contractId.includes('?') || c.contractId.includes('limit')), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'mixes Kotlin @RequestLine and @GetMapping methods on the same @FeignClient interface', + async () => { + const dir = path.join(tmpDir, 'kotlin-feign-mixed-annotations'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MixedClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import feign.RequestLine + +@FeignClient(name = "mixed-service", path = "/api") +interface MixedClient { + @GetMapping("/spring-style") + fun springStyle(): String + + @RequestLine("GET /native-style") + fun nativeStyle(): String +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // @GetMapping → @FeignClient(path) prefix; confidence 0.7. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/spring-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + // @RequestLine → @FeignClient(path) prefix; confidence 0.75. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/native-style' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'ignores Kotlin @RequestLine values that are not a "VERB /path" line', + async () => { + const dir = path.join(tmpDir, 'kotlin-request-line-malformed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MalformedClient.kt'), + `package com.example +import feign.RequestLine + +interface MalformedClient { + @RequestLine("not a request line at all") + fun noVerb(): String + + @RequestLine("GET relative/no/leading/slash") + fun noLeadingSlash(): String + + @RequestLine("FETCH /unknown-verb") + fun unknownVerb(): String +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.filter((c) => c.symbolRef.filePath.endsWith('MalformedClient.kt')), + ).toHaveLength(0); + }, + ); + + itKotlinConsumer( + 'prefers @FeignClient(path) over @RequestMapping when @RequestMapping appears first (Kotlin)', + async () => { + // Source-order independence twin: @FeignClient(path) wins even when + // @RequestMapping is the first annotation. + const dir = path.join(tmpDir, 'kotlin-feign-prefix-precedence-reversed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ReversedClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@RequestMapping("/rm-path") +@FeignClient(name = "order-service", path = "/feign-path") +interface ReversedClient { + @GetMapping("/orders") + fun getOrders(): Any +} +`, + ); + + 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(); + }, + ); + + itKotlinConsumer( + 'classifies a @RestController class as provider and a @FeignClient interface as consumer', + async () => { + const dir = path.join(tmpDir, 'kotlin-controller-vs-feign'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'Mixed.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.cloud.openfeign.FeignClient + +@RestController +class OrdersController { + @GetMapping("/orders/{id}") + fun getOrder(@PathVariable id: Int): Any = TODO() +} + +@FeignClient(name = "pricing") +interface PricingClient { + @GetMapping("/prices/{id}") + fun getPrice(id: Int): Any +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Controller method → provider (not a consumer). + expect( + providers.find( + (c) => c.contractId === 'http::GET::/orders/{param}' && c.meta.framework === 'spring', + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/orders/{param}'), + ).toBeUndefined(); + // Feign interface method → consumer (not a provider). + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/prices/{param}' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::GET::/prices/{param}'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'emits a provider for a class implementing a route interface (interface-based controller)', + async () => { + // One interface per file (real layout). Routes live on the interface; the + // @RestController override carries none → inherited via scanProject. + const dir = path.join(tmpDir, 'kotlin-interface-based-controller'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class WarehouseController(private val svc: Svc) : WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + // The controller inherits the route declared on WarehouseApi → provider. + expect( + providers.find( + (c) => + c.contractId === 'http::GET::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.kt') && + c.meta.framework === 'spring' && + c.confidence === 0.8, + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'recognises a fully-qualified @org…RestController as a controller (#2254 FQN parity)', + async () => { + // A FQN annotation parses to a user_type with one type_identifier per + // segment; the controller gate must read the trailing segment, not "org". + const dir = path.join(tmpDir, 'kotlin-fqn-controller'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseController.kt'), + `package com.example + +@org.springframework.web.bind.annotation.RestController +class WarehouseController(private val svc: Svc) : WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + 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::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.kt'), + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'resolves a fully-qualified supertype to its trailing segment for interface inheritance', + async () => { + // `: com.example.WarehouseApi` must resolve to "WarehouseApi" (trailing + // segment), not "com", so the inherited interface route is matched. + const dir = path.join(tmpDir, 'kotlin-fqn-supertype'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class WarehouseController(private val svc: Svc) : com.example.WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + 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::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.kt'), + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'a @FeignClient API interface implemented by a controller yields both a consumer and a provider', + async () => { + // catalog-service pattern: an `api` module publishes a @FeignClient contract that + // the service's own @RestController implements. The interface is the + // client SDK (consumer); the implementing controller is the provider. + const dir = path.join(tmpDir, 'kotlin-feign-api-implemented'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@FeignClient(name = "catalog-service") +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class WarehouseController(private val svc: Svc) : WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Interface (published @FeignClient client SDK) → consumer. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/warehouses/{param}/stock' && + c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + // Implementing @RestController → provider (route inherited from the interface). + expect( + providers.find( + (c) => + c.contractId === 'http::GET::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.kt'), + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'handles Kotlin array-form paths (["/x"], value = ["/x"]) for providers and consumers', + async () => { + // Spring path/value attributes are Array; the array literal form + // is common in Kotlin. Each route-bearing annotation must accept both a + // bare string and a single-element array (collection_literal). + const dir = path.join(tmpDir, 'kotlin-array-paths'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // Provider: array class prefix + array method path. + fs.writeFileSync( + path.join(dir, 'src', 'ProductsController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(["/api/products"]) +class ProductsController { + @GetMapping(value = ["/{id}"]) + fun get(id: Int): Any = TODO() +} +`, + ); + // OpenFeign consumer: positional array path. + fs.writeFileSync( + path.join(dir, 'src', 'OrdersClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.PostMapping + +@FeignClient(name = "orders") +interface OrdersClient { + @PostMapping(["/orders/search"]) + fun search(): Any +} +`, + ); + // Spring HTTP Interface consumer: named array url. + fs.writeFileSync( + path.join(dir, 'src', 'PricingApi.kt'), + `package com.example +import org.springframework.web.service.annotation.GetExchange + +interface PricingApi { + @GetExchange(url = ["/pricing/{id}"]) + fun price(id: Int): Any +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Array class prefix + array method path → provider. + expect( + providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'), + ).toBeDefined(); + // @FeignClient positional array path → consumer. + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/orders/search' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + // @GetExchange(url = [...]) array → consumer. + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/pricing/{param}' && + c.meta.framework === 'spring-http-interface', + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'handles Kotlin arrayOf("/x") annotation arrays across families (#2254 P3)', + async () => { + // arrayOf(...) is the explicit (older) form of a Kotlin String[] arg, + // distinct from the ["/x"] collection_literal. Each route-bearing + // annotation must accept it, positional and named, in all families. + const dir = path.join(tmpDir, 'kotlin-array-of'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // Provider: positional arrayOf class prefix + named arrayOf method path. + fs.writeFileSync( + path.join(dir, 'src', 'ProductsController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(arrayOf("/api/products")) +class ProductsController { + @GetMapping(value = arrayOf("/{id}")) + fun get(id: Int): Any = TODO() +} +`, + ); + // OpenFeign consumer: named arrayOf path prefix. + fs.writeFileSync( + path.join(dir, 'src', 'OrdersClient.kt'), + `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.PostMapping + +@FeignClient(name = "orders", path = arrayOf("/feign")) +interface OrdersClient { + @PostMapping(arrayOf("/orders/search")) + fun search(): Any +} +`, + ); + // Spring HTTP Interface consumer: positional arrayOf class prefix + named arrayOf url. + fs.writeFileSync( + path.join(dir, 'src', 'PricingApi.kt'), + `package com.example +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.GetExchange + +@HttpExchange(arrayOf("/pricing")) +interface PricingApi { + @GetExchange(url = arrayOf("/{id}")) + fun price(id: Int): Any +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // class @RequestMapping(arrayOf) + method @GetMapping(value = arrayOf) → provider. + expect( + providers.find((c) => c.contractId === 'http::GET::/api/products/{param}'), + ).toBeDefined(); + // @FeignClient(path = arrayOf) + @PostMapping(arrayOf) → consumer (path applied). + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/feign/orders/search' && + c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + // @HttpExchange(arrayOf) + @GetExchange(url = arrayOf) → consumer (prefix applied). + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/pricing/{param}' && + c.meta.framework === 'spring-http-interface', + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'registers a multi-element arrayOf("/a","/b") under every element (cross-product)', + async () => { + const dir = path.join(tmpDir, 'kotlin-array-of-multi'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'MultiController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(arrayOf("/a", "/b")) +class MultiController { + @GetMapping(arrayOf("/x", "/y")) + fun get(): Any = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + // 2 prefixes × 2 method paths → 4 contract IDs. + for (const id of [ + 'http::GET::/a/x', + 'http::GET::/a/y', + 'http::GET::/b/x', + 'http::GET::/b/y', + ]) { + expect(providers.find((c) => c.contractId === id)).toBeDefined(); + } + }, + ); + + itKotlinConsumer( + 'mixes arrayOf and collection-literal arrays without cannibalising either', + async () => { + // The dedicated arrayOf pattern must not drop the sibling ["/x"] + // collection_literal match (the tree-sitter 0.21.x predicate-bucket hazard). + const dir = path.join(tmpDir, 'kotlin-array-of-mixed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ArrayOfController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(arrayOf("/aof")) +class ArrayOfController { + @GetMapping(arrayOf("/x")) + fun get(): Any = TODO() +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'LiteralController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(["/lit"]) +class LiteralController { + @GetMapping(["/y"]) + fun get(): Any = TODO() +} +`, + ); + + 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::/aof/x')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/lit/y')).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'does not treat a non-arrayOf call or a non-route arrayOf key as a route (anti-overreach)', + async () => { + const dir = path.join(tmpDir, 'kotlin-array-of-negative'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // buildPath(...) is a call_expression but not arrayOf → no prefix. + // produces = arrayOf(...) is a non-route key → no route. + // arrayOf() is empty → no phantom route. + fs.writeFileSync( + path.join(dir, 'src', 'NegController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(buildPath("/built")) +class NegController { + @GetMapping(produces = arrayOf("application/json")) + fun a(): Any = TODO() + + @GetMapping(arrayOf()) + fun b(): Any = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => + c.symbolRef.filePath.endsWith('NegController.kt'), + ); + + // No route should be produced from any of the three anti-overreach forms. + expect(providers).toHaveLength(0); + }, + ); + + itKotlinConsumer( + 'does not extract @RequestLine on a Kotlin class method (Feign proxies are interfaces only)', + async () => { + // Feign builds its proxy from an interface; a @RequestLine on a concrete + // class is not a client call. Anti-overreach guard, parity with java.ts. + const dir = path.join(tmpDir, 'kotlin-request-line-class'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClientImpl.kt'), + `package com.example +import feign.RequestLine + +class AiClientImpl { + @RequestLine("GET /should-not-extract") + fun health(): String = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect( + contracts.find((c) => c.contractId === 'http::GET::/should-not-extract'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'extracts Kotlin @RequestLine on a plain interface without @FeignClient (Feign.builder())', + async () => { + // Core-Feign usage: a plain interface with @RequestLine wired via + // Feign.builder() — no @FeignClient. The structural interface check + // (not a @FeignClient gate) admits it, matching the Java plugin. + const dir = path.join(tmpDir, 'kotlin-request-line-plain-interface'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AiClient.kt'), + `package com.example +import feign.RequestLine + +interface AiClient { + @RequestLine("POST /ai/summarize") + fun summarize(): String + + @RequestLine("GET /ai/health") + fun health(): String +} +`, + ); + + 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::/ai/summarize' && + c.meta.framework === 'openfeign' && + c.confidence === 0.75, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => c.contractId === 'http::GET::/ai/health' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'does not emit a provider for a non-controller class implementing a route interface', + async () => { + // Only a @RestController/@Controller implementer serves the interface's + // routes. A plain service/adapter implementing the same interface must + // NOT emit phantom providers (parity with Java's isController gate). + const dir = path.join(tmpDir, 'kotlin-noncontroller-impl'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseServiceImpl.kt'), + `package com.example + +class WarehouseServiceImpl(private val svc: Svc) : WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + 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::/warehouses/{param}/stock'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'detects a controller using the arg-form @RestController("bean")', + async () => { + // The arg-form @RestController("bean") attaches under the class + // `modifiers` as an `annotation` whose child is a `constructor_invocation` + // (NOT a detached sibling). The controller gate reads its trailing name so + // the inherited route is still emitted. + const dir = path.join(tmpDir, 'kotlin-argform-restcontroller'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WarehouseController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController("warehouseController") +class WarehouseController(private val svc: Svc) : WarehouseApi { + override fun listStock(id: String): Any = TODO() +} +`, + ); + + 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::/warehouses/{param}/stock' && + c.symbolRef.filePath.endsWith('WarehouseController.kt'), + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'treats @(Get|...)Exchange as a consumer even on a concrete class (parity with Java)', + async () => { + // @(Get|...)Exchange is definitionally a client (HttpServiceProxyFactory) + // annotation. Like java.ts, the extractor classifies it as a consumer + // regardless of the enclosing type — so even a (mis-)use on a concrete + // class yields a consumer, never a provider. Pins the accepted behavior. + const dir = path.join(tmpDir, 'kotlin-exchange-on-class'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ReportClient.kt'), + `package com.example +import org.springframework.stereotype.Component +import org.springframework.web.service.annotation.GetExchange + +@Component +class ReportClient { + @GetExchange("/reports/{id}") + fun report(id: Int): Any = TODO() +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect( + contracts.find( + (c) => + c.role === 'consumer' && + c.contractId === 'http::GET::/reports/{param}' && + c.meta.framework === 'spring-http-interface', + ), + ).toBeDefined(); + // Never a provider. + expect( + contracts.find( + (c) => c.role === 'provider' && c.contractId === 'http::GET::/reports/{param}', + ), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'emits one contract per element of a multi-element method-level path array', + async () => { + // Spring registers `@GetMapping(["/a", "/b"])` under BOTH paths, so the + // extractor must emit N contracts (one per array element), not just one. + const dir = path.join(tmpDir, 'kotlin-multi-method-array'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AliasController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping("/api") +class AliasController { + @GetMapping(value = ["/primary", "/alias"]) + fun get(): Any = TODO() +} +`, + ); + + 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::/api/primary')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/api/alias')).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'emits one contract per element of a multi-element class-level prefix array', + async () => { + // Spring registers a method under EVERY class-level prefix, so a + // `@RequestMapping(["/api/v1", "/api/v2"])` controller must yield a + // contract per (prefix × method-path) combination, not just the last prefix. + const dir = path.join(tmpDir, 'kotlin-multi-prefix-array'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'VersionedController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RestController +@RequestMapping(["/base/one", "/base/two"]) +class VersionedController { + @GetMapping("/items") + fun get(): Any = TODO() +} +`, + ); + + 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::/base/one/items')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/base/two/items')).toBeDefined(); + }, + ); + + it('emits one contract per element of a multi-element Java path array', async () => { + // Java parity: @GetMapping({"/a", "/b"}) method array and a multi-element + // class-level @RequestMapping must both expand to N contracts. + const dir = path.join(tmpDir, 'java-multi-array'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'AliasController.java'), + `package com.example; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; + +@RestController +@RequestMapping({"/base/one", "/base/two"}) +public class AliasController { + @GetMapping({"/primary", "/alias"}) + public Object get() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + for (const id of [ + 'http::GET::/base/one/primary', + 'http::GET::/base/one/alias', + 'http::GET::/base/two/primary', + 'http::GET::/base/two/alias', + ]) { + expect(providers.find((c) => c.contractId === id)).toBeDefined(); + } + }); + + itKotlinConsumer( + 'combines a Kotlin controller class prefix with an inherited interface prefix', + async () => { + // Interface-based controller where BOTH the controller and the interface + // carry a class-level @RequestMapping: the inherited route must be prefixed + // by the controller prefix too (parity with java.ts joinInheritedSpringPath). + const dir = path.join(tmpDir, 'kotlin-controller-prefix-inherit'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'WidgetApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/v1") +interface WidgetApi { + @GetMapping("/{id}") + fun fetch(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'WidgetController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping + +@RestController +@RequestMapping("/api") +class WidgetController : WidgetApi { + override fun fetch(id: String): Any = TODO() +} +`, + ); + + 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::/api/v1/{param}' && + c.symbolRef.filePath.endsWith('WidgetController.kt'), + ), + ).toBeDefined(); + }, + ); + + itKotlinConsumer( + 'does not double a shared prefix when a Kotlin controller repeats the interface prefix', + async () => { + // #2057 parity: controller prefix == interface prefix must not be prepended + // twice (no /shared/shared/...). + const dir = path.join(tmpDir, 'kotlin-controller-prefix-dedup'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LedgerApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/shared") +interface LedgerApi { + @GetMapping("/entries") + fun entries(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'LedgerController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping + +@RestController +@RequestMapping("/shared") +class LedgerController : LedgerApi { + override fun entries(): Any = TODO() +} +`, + ); + + 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::/shared/entries')).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::GET::/shared/shared/entries'), + ).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'still combines distinct inherited Kotlin prefixes that share a leading segment', + async () => { + // Twin of the Java 'shared leading segment' case: controller @RequestMapping("/open") + // + interface @RequestMapping("/open/ai") must combine to /open/open/ai/query, NOT + // dedup to /open/ai/query (the dedup only fires on an exact prefix match). + const dir = path.join(tmpDir, 'kotlin-shared-leading-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'DataReleaseApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/open/ai") +interface DataReleaseApi { + @GetMapping("/query") + fun query(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'DataReleaseController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping + +@RestController +@RequestMapping("/open") +class DataReleaseController : DataReleaseApi { + override fun query(): Any = TODO() +} +`, + ); + + 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::/open/open/ai/query'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'keeps a Kotlin controller prefix when a prefix-less interface method starts with the same path', + async () => { + // Twin of the Java prefix-overlap case: controller @RequestMapping("/users") + // + interface @GetMapping("/users/{id}") (no interface prefix) → + // /users/users/{param}, not deduped to /users/{param}. + const dir = path.join(tmpDir, 'kotlin-method-prefix-overlap'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'UserApi.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping + +interface UserApi { + @GetMapping("/users/{id}") + fun getUser(id: String): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'UserController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestMapping + +@RestController +@RequestMapping("/users") +class UserController : UserApi { + override fun getUser(id: String): Any = TODO() +} +`, + ); + + 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::/users/users/{param}'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined(); + }, + ); + + itKotlinConsumer( + 'skips ambiguous inherited Kotlin routes when interfaces share a simple name', + async () => { + // Twin of the Java simple-name-collision case: two distinct interfaces both + // named StatusApi → ambiguous, so the implementing controller emits nothing. + const dir = path.join(tmpDir, 'kotlin-iface-name-collision'); + fs.mkdirSync(path.join(dir, 'src', 'a'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'src', 'b'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'a', 'StatusApi.kt'), + `package com.example.a +import org.springframework.web.bind.annotation.GetMapping + +interface StatusApi { + @GetMapping("/a/status") + fun getStatus(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'b', 'StatusApi.kt'), + `package com.example.b +import org.springframework.web.bind.annotation.GetMapping + +interface StatusApi { + @GetMapping("/b/status") + fun getStatus(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'StatusController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class StatusController : StatusApi { + override fun getStatus(): Any = TODO() +} +`, + ); + + 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::/a/status')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined(); + expect( + providers.filter((c) => c.symbolRef.filePath.endsWith('StatusController.kt')), + ).toHaveLength(0); + }, + ); + + itKotlinConsumer( + 'emits routes from every distinctly-named interface a Kotlin controller implements', + async () => { + // Positive multi-interface case (untested in both languages before #2254): + // a controller implementing two route interfaces emits both their routes. + const dir = path.join(tmpDir, 'kotlin-multi-iface'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'Apis.kt'), + `package com.example +import org.springframework.web.bind.annotation.GetMapping + +interface OrdersApi { + @GetMapping("/orders") + fun orders(): Any +} + +interface UsersApi { + @GetMapping("/users") + fun users(): Any +} +`, + ); + fs.writeFileSync( + path.join(dir, 'src', 'GatewayController.kt'), + `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class GatewayController : OrdersApi, UsersApi { + override fun orders(): Any = TODO() + override fun users(): Any = TODO() +} +`, + ); + + 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::/orders')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + }, + ); + + // ─── Byte-identical Java↔Kotlin contract parity (set-equality harness) ───── + // Independent per-side twin tests can both pass while the emitted contract + // SETS differ (an extra contract on one side, or a confidence/framework + // drift). This harness feeds matched .java/.kt fixtures through both plugins + // and asserts the full projected contract set is equal across languages AND + // equal to the expected set — the only check that actually verifies the + // "byte-identical contract IDs" goal. Per-scenario twins above stay for + // readability and language-specific cases; this covers the parity-critical + // families. Drift in any covered family fails here directly. + describe('Java↔Kotlin contract parity (set-equality)', () => { + interface ParityFile { + name: string; + java: string; + kotlin: string; + } + interface ParityContract { + role: string; + contractId: string; + framework: unknown; + confidence: number; + } + interface ParityRow { + name: string; + files: ParityFile[]; + expected: ParityContract[]; + } + + const sortContracts = (contracts: ParityContract[]): ParityContract[] => + [...contracts].sort((a, b) => + `${a.role} ${a.contractId}`.localeCompare(`${b.role} ${b.contractId}`), + ); + + const projectContracts = ( + contracts: Awaited>, + ): ParityContract[] => + sortContracts( + contracts.map((c) => ({ + role: c.role, + contractId: c.contractId, + framework: c.meta.framework, + confidence: c.confidence, + })), + ); + + const rows: ParityRow[] = [ + { + name: '@RequestLine with @RequestMapping prefix fallback', + files: [ + { + name: 'OrderClient', + java: ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.RequestMapping; +import feign.RequestLine; + +@FeignClient(name = "order-service") +@RequestMapping("/orders") +interface OrderClient { + @RequestLine("GET /{id}") + Object get(); +} +`, + kotlin: `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.RequestMapping +import feign.RequestLine + +@FeignClient(name = "order-service") +@RequestMapping("/orders") +interface OrderClient { + @RequestLine("GET /{id}") + fun get(): Any +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::GET::/orders/{param}', + framework: 'openfeign', + confidence: 0.75, + }, + ], + }, + { + name: 'named @RequestLine(value=...)', + files: [ + { + name: 'CreateClient', + java: ` +import org.springframework.cloud.openfeign.FeignClient; +import feign.RequestLine; + +@FeignClient(name = "create-service") +interface CreateClient { + @RequestLine(value = "POST /create") + Object create(); +} +`, + kotlin: `package com.example +import org.springframework.cloud.openfeign.FeignClient +import feign.RequestLine + +@FeignClient(name = "create-service") +interface CreateClient { + @RequestLine(value = "POST /create") + fun create(): Any +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::POST::/create', + framework: 'openfeign', + confidence: 0.75, + }, + ], + }, + { + name: '@FeignClient(path) + @GetMapping', + files: [ + { + name: 'UsersClient', + java: ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "users-service", path = "/api") +interface UsersClient { + @GetMapping("/users") + Object users(); +} +`, + kotlin: `package com.example +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping + +@FeignClient(name = "users-service", path = "/api") +interface UsersClient { + @GetMapping("/users") + fun users(): Any +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::GET::/api/users', + framework: 'openfeign', + confidence: 0.7, + }, + ], + }, + { + name: '@HttpExchange(url) prefix + @GetExchange', + files: [ + { + name: 'ProductApi', + java: ` +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.GetExchange; + +@HttpExchange(url = "/products") +interface ProductApi { + @GetExchange("/{id}") + Object get(); +} +`, + kotlin: `package com.example +import org.springframework.web.service.annotation.HttpExchange +import org.springframework.web.service.annotation.GetExchange + +@HttpExchange(url = "/products") +interface ProductApi { + @GetExchange("/{id}") + fun get(): Any +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::GET::/products/{param}', + framework: 'spring-http-interface', + confidence: 0.75, + }, + ], + }, + { + name: 'WebClient long-form method(HttpMethod.X).uri(...)', + files: [ + { + name: 'LongFormClient', + java: ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class LongFormClient { + void run(WebClient webClient) { + webClient.method(HttpMethod.GET).uri("/api/items").retrieve(); + } +} +`, + kotlin: `package com.example +import org.springframework.http.HttpMethod +import org.springframework.web.reactive.function.client.WebClient + +class LongFormClient { + fun run(webClient: WebClient) { + webClient.method(HttpMethod.GET).uri("/api/items").retrieve() + } +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::GET::/api/items', + framework: 'spring-web-client', + confidence: 0.7, + }, + ], + }, + { + name: 'interface-based controller inheritance', + files: [ + { + name: 'WarehouseApi', + java: ` +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.GetMapping; + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + Object listStock(); +} +`, + kotlin: `package com.example +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.GetMapping + +@RequestMapping("/warehouses") +interface WarehouseApi { + @GetMapping("/{id}/stock") + fun listStock(): Any +} +`, + }, + { + name: 'WarehouseController', + java: ` +import org.springframework.web.bind.annotation.RestController; + +@RestController +class WarehouseController implements WarehouseApi { + @Override + public Object listStock() { return null; } +} +`, + kotlin: `package com.example +import org.springframework.web.bind.annotation.RestController + +@RestController +class WarehouseController : WarehouseApi { + override fun listStock(): Any = TODO() +} +`, + }, + ], + expected: [ + { + role: 'provider', + contractId: 'http::GET::/warehouses/{param}/stock', + framework: 'spring', + confidence: 0.8, + }, + ], + }, + ]; + + rows.forEach((row) => { + itKotlinConsumer(`emits identical contracts for ${row.name}`, async () => { + const base = path.join(tmpDir, `parity-${row.name.replace(/[^a-z0-9]+/gi, '-')}`); + const javaDir = path.join(base, 'java'); + const kotlinDir = path.join(base, 'kotlin'); + fs.mkdirSync(path.join(javaDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(kotlinDir, 'src'), { recursive: true }); + for (const file of row.files) { + fs.writeFileSync(path.join(javaDir, 'src', `${file.name}.java`), file.java); + fs.writeFileSync(path.join(kotlinDir, 'src', `${file.name}.kt`), file.kotlin); + } + + const javaContracts = projectContracts( + await extractor.extract(null, javaDir, makeRepo(javaDir)), + ); + const kotlinContracts = projectContracts( + await extractor.extract(null, kotlinDir, makeRepo(kotlinDir)), + ); + const expected = sortContracts(row.expected); + + // The two languages emit the same contract set... + expect(kotlinContracts).toEqual(javaContracts); + // ...and it is exactly the expected set (no extra/missing contracts). + expect(javaContracts).toEqual(expected); + }); + }); + }); + it('extracts Go stdlib and resty calls', async () => { const dir = path.join(tmpDir, 'go-consumer'); fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });