feat(group): Kotlin Spring HTTP consumer extraction + provider parity with Java (#2254)

* feat(group): Kotlin Spring HTTP consumer extraction + provider parity with Java

Brings the Kotlin group/contract HTTP extractor up to parity with Java for
inter-service contract detection, and unifies the language-agnostic consumer
logic so it is not duplicated.

Consumers (new for Kotlin):
- @FeignClient interface @(Get|...)Mapping methods are emitted as OpenFeign
  consumers (a remote call), not providers — previously mis-classified because
  tree-sitter-kotlin models an interface as a class_declaration.
- Spring 6 HTTP Interface @(Get|...)Exchange (with optional class-level
  @HttpExchange(url) prefix) — added for BOTH Java and Kotlin.
- Native OpenFeign @RequestLine, gated to interfaces (Feign proxies are
  interfaces only), mirroring java.ts's findEnclosingInterface check.

Providers (Kotlin parity with java.ts scanSpringProject):
- A @(Get|...)Mapping on a non-Feign interface is a route *contract*, not a
  served route; it is skipped in scan() so the implementing controller is the
  sole provider (Java drops these implicitly via interface_declaration).
- scanProject inherits interface routes onto the implementing class, gated on
  the class being a @RestController/@Controller (kotlinClassIsController handles
  both the attached `modifiers` shape and the detached leading-arg-form
  prefix_expression shape) so non-controller implementers don't emit phantom
  providers.

Shared module:
- New spring-consumer-shared.ts holds the language-agnostic primitives
  (REST_TEMPLATE_/WEB_CLIENT_/EXCHANGE verb maps, joinPath, parseRequestLine,
  framework + confidence constants); java.ts and kotlin.ts both import it.

Array-of-paths (both languages):
- Route/Feign/Exchange annotation paths are `String[]`; a multi-element array
  registers the route under EVERY element. The class/Feign/HttpExchange prefix
  maps now accumulate all elements (were last-write-wins) and emission
  cross-products prefixes × method paths, so `@RequestMapping(["/a","/b"])` +
  `@GetMapping(["/x","/y"])` yields all four contract IDs. Array form is matched
  via a predicate-free alternation over Kotlin `collection_literal` / Java
  `element_value_array_initializer`.

Tests: comprehensive Java + Kotlin cases incl. consumer-vs-provider
classification, @*Exchange, @RequestLine (interface-only + plain-interface),
interface-based controller inheritance, non-controller negative case, detached
@RestController, single- and multi-element array paths (method-level and
class-prefix cross-product). 109 http-route + group tests pass; tsc/eslint/
prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(group): apply @RequestMapping prefix to Kotlin @RequestLine consumers (#2254 P2)

A @RequestLine method on an interface with a class-level @RequestMapping
prefix but no @FeignClient(path) dropped the prefix in Kotlin while Java
applied it (java.ts merges the fallback into feignPrefixByInterfaceId).
Mirror the feignPrefixByClassId ?? prefixByClassId ?? [''] chain already
used by the @GetMapping-in-Feign path. Adds Kotlin twins for the
@RequestMapping-prefix and @FeignClient(path)-wins cases.

* fix(group): accept named-arg Kotlin @RequestLine(value=...) (#2254 P2)

The positional pattern's '.' anchor only matched @RequestLine("VERB /x"),
silently dropping the named @RequestLine(value = "VERB /x") form that
java.ts accepts. Add a dedicated named pattern constrained to #eq? @key
"value" (Java parity: non-value keys stay dropped). Adds Kotlin twins
for the named-value and non-value-key cases.

* fix(group): resolve Kotlin FQN annotations/supertypes by trailing segment (#2254)

A fully-qualified @org…RestController / supertype : a.b.Api parses to a
user_type with one type_identifier per dotted segment; kotlinAnnotationName
and collectKotlinSupertypes took the FIRST ("org"/"a"), so FQN controllers
were not recognised and FQN supertypes never matched their interface. Take
the trailing segment. Adds FQN controller + FQN supertype inheritance twins.

* refactor(group): remove dead prefix_expression branch in kotlinClassIsController (#2254)

AST probe (bare and realistic package+constructor forms) confirms the
arg-form @RestController("bean") attaches under the class `modifiers` as an
annotation/constructor_invocation, caught by the modifiers loop — the
prefix_expression sibling branch was unreachable. Remove it and correct the
false grammar comments (source + the arg-form test). The existing arg-form
test stays green via the modifiers branch, confirming no behavior change.

* feat(group): support Kotlin arrayOf(...) annotation arrays (#2254 P3)

arrayOf("/a","/b") (the explicit String[] form) parses to a call_expression,
not a string_literal/collection_literal, so it was missed across all five
annotation-array families. Add dedicated arrayOf query patterns (positional +
named) per family via a shared arrayOfArg fragment — kept out of the existing
[(string_literal) (collection_literal …)] alternation to avoid the
tree-sitter 0.21.x predicate-bucket hazard. Verified one match per element
(multi-element accumulates) with buildPath/produces/empty anti-overreach.

* feat(group): detect WebClient long-form in Java for Kotlin parity (#2254 P3)

Java deliberately deferred webClient.method(HttpMethod.X).uri(...); the
Kotlin plugin proves a single structural query suffices (same field-access
shape as REST_TEMPLATE_EXCHANGE). Add WEB_CLIENT_LONG_FORM_PATTERNS + scan
loop so .java and .kt detect it identically. Move WEB_CLIENT_LONG_VERB_RE to
the shared module (single source for both). Flip the now-obsolete java :1741
negative test to positive (verbs + no-double-emit) and add a Java var-verb
anti-overreach twin.

* refactor(group): share pushPrefix between java.ts and kotlin.ts (#2254)

The de-duping prefix accumulator was duplicated as kotlin.ts pushKotlinPrefix
and a java.ts closure. Hoist a single export pushPrefix into
spring-consumer-shared.ts; both plugins import it. No behavior change.

* test(group): add Kotlin interface-inheritance boundary twins (#2254)

Twins for the Java inheritance-boundary cases that had no Kotlin counterpart:
shared-leading-segment combine, prefix-less method overlap, ambiguous
duplicate-interface-name suppression, plus a positive multi-interface
implementer. These pin Kotlin's scanProject behavior before U8 extracts the
shared inheritance algorithm.

* refactor(group): share the Spring interface-inheritance scanProject algorithm (#2254)

scanKotlinProject and scanSpringProject were ~80-line near-duplicates over
structurally identical type records. Extract scanSpringInheritanceProject +
SharedSpringType into spring-consumer-shared.ts; collapse KotlinTypeInfo and
SpringTypeInfo into the shared type; both plugins' scanProject become thin
collect-and-delegate wrappers. The ownerPrefix-carrying intermediate is owned
by the shared function. Behavior-preserving — Java and Kotlin inheritance
suites (incl. the new Kotlin boundary twins) byte-identical; tsc clean.

* test(group): close Kotlin↔Java consumer test-parity gaps + assert confidence (#2254)

Add Kotlin twins for Java-tested consumer scenarios with no Kotlin coverage:
@RequestLine query-strip, mixed @RequestLine+@GetMapping, malformed-value
rejection, and @FeignClient(path)-wins-when-@RequestMapping-first. Add the
Java dual-role twin (interface as consumer + implementing controller as
provider). Add two-sided provider confidence (0.8) assertions on the
canonical Java and Kotlin interface-inheritance tests.

* docs(group): document Java FQN route-annotation limitation + pin it (#2254)

Per KTD6, the Java FQN route-annotation gap is documentation-first: the gap is
route-string-only (FQN controllers are already recognised via hasAnnotation)
and FQN-written annotations are vanishingly rare. Document the asymmetry with
Kotlin in JAVA_ROUTE_ANNOTATION_PATTERNS and pin current behavior with an
anti-overreach test. The scoped_identifier query change is deferred to avoid
re-keying existing contracts via the predicate-bucket hazard.

* test(group): add Java↔Kotlin contract set-equality parity harness (#2254)

Independent per-side twins can both pass while the emitted contract SETS
differ. Add a table-driven harness over the parity-critical families
(@RequestLine prefix-fallback, named @RequestLine, @FeignClient(path)+@GetMapping,
@HttpExchange+@GetExchange, WebClient long-form, interface inheritance) that
runs matched .java/.kt fixtures through both plugins and asserts the full
projected contract set (role+contractId+framework+confidence) is equal across
languages AND equal to the expected set — the durable guard for the
byte-identical goal. Gated on kotlinConsumerAvailable.

* style(group): apply prettier formatting to #2254 changes

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
glier 2026-06-21 18:59:00 +03:00 • committed by GitHub
parent 78885f5b3e
commit a68a69d391
4 changed files with 3236 additions and 319 deletions

View file

@ -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<Record<string, never>>);
// ─── 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<string, string> = {
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<RestTemplateMeta>);
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
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<Record<string, never>>);
// ─── 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<Record<string, never>>);
// ─── 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<number, string>;
/** OpenFeign interface prefix per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */
feignPrefixByInterfaceId: Map<number, string>;
/** Spring `@RequestMapping` URL prefixes per class/interface node id (one per array element). */
prefixByTypeId: Map<number, string[]>;
/** OpenFeign interface prefixes per interface node id; `@FeignClient(path)` wins over `@RequestMapping`. */
feignPrefixByInterfaceId: Map<number, string[]>;
/** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */
httpExchangePrefixByTypeId: Map<number, string[]>;
/** 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<number, string>();
const feignPrefixByInterfaceId = new Map<number, string>();
const prefixByTypeId = new Map<number, string[]>();
const feignPrefixByInterfaceId = new Map<number, string[]>();
const httpExchangePrefixByTypeId = new Map<number, string[]>();
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<number, SpringRouteBinding[]>();
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
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<string, Map<string, SpringRouteBinding[]> | 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<string, SpringRouteBinding[]>();
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<string, HttpDetection[]>();
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;

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<Record<string, never>>);
/**
* 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<Record<string, never>>);
// ─── 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<Record<string, never>>);
// ─── 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<Record<string, never>>);
// ─── 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<Record<string, never>>);
// ─── 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<Record<string, never>>);
/** 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<number, string[]>();
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<number, Array<{ method: string; path: string }>>();
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<number, string>();
const prefixByClassId = new Map<number, string[]>();
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<number>();
const feignPrefixByClassId = new Map<number, string[]>();
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<number, string[]>();
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,
};
}

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<number, string[]>, 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<string, Map<string, InheritedRoute[]> | 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<string, InheritedRoute[]>();
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<string, HttpDetection[]>();
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<string>();
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,
}));
}

File diff suppressed because it is too large Load diff