mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
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:
parent
78885f5b3e
commit
a68a69d391
4 changed files with 3236 additions and 319 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue