gitnexus 1.6.9-rc.2 → 1.6.9-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,53 @@
1
1
  import Java from 'tree-sitter-java';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
3
  import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingClass, } from '../../../ingestion/route-extractors/spring-shared.js';
4
+ import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, joinPath, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
5
+ /**
6
+ * Java HTTP plugin. Handles:
7
+ * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations
8
+ * - Spring `RestTemplate.getForObject/...`, `exchange(...)`
9
+ * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
10
+ * - OkHttp `new Request.Builder().url("...")`
11
+ * - OpenFeign interfaces with Spring MVC method annotations or
12
+ * native `@RequestLine("METHOD /path")` annotations
13
+ * - Java / Apache HttpClient literal request construction
14
+ *
15
+ * Every route-defining annotation (class/interface `@RequestMapping`
16
+ * prefixes, `@FeignClient(path)` prefixes, `@(Get|...)Mapping` method
17
+ * routes and native `@RequestLine`s) is matched by a single consolidated
18
+ * query (`JAVA_ROUTE_ANNOTATION_PATTERNS`) in one pass via
19
+ * `scanRouteAnnotations`. The `scan` function then walks up from each
20
+ * matched method to its enclosing class/interface to combine the prefix
21
+ * with the method path. Call-site consumers (RestTemplate, WebClient,
22
+ * OkHttp, Java/Apache HttpClient) keep their own focused queries.
23
+ */
24
+ // Each route-defining annotation has two AST shapes — a positional argument
25
+ // and a named one — that must both be matched:
26
+ // @RequestMapping("/api") → (annotation_argument_list (string_literal))
27
+ // @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal)))
28
+ // @RequestMapping(value = "/api") → same as above
29
+ // For named arguments only the route member keys (`path`/`value`) carry a URL;
30
+ // non-route attributes (`produces`, `consumes`, `headers`, `name`, `params`)
31
+ // would otherwise be mis-extracted (e.g. `produces = "application/json"` would
32
+ // corrupt every route). That key filtering is done in `isRouteMemberKey`, and
33
+ // all of these annotations are matched by the one `JAVA_ROUTE_ANNOTATION_PATTERNS`
34
+ // query below (see its header for why the filtering lives in JS, not the query).
35
+ // The Spring class/interface view (`SharedSpringType`) and the interface-based
36
+ // controller inheritance algorithm (`scanSpringInheritanceProject`) are shared
37
+ // with kotlin.ts via spring-consumer-shared.ts so both plugins emit identical
38
+ // provider contracts. `collectSpringTypes` below produces that shared shape.
4
39
  // ─── Route-defining annotations (one generic query, one pass) ─────────
5
40
  // Every Java route-mapper annotation shares one shape: an annotation carrying a
6
- // single string argument — positional `"..."` or named `key = "..."` on a
7
- // class, interface, or method. This SINGLE query matches that shape generically;
8
- // `scanRouteAnnotations` then reads the annotation NAME (`@ann`) and declaration
9
- // kind (`@node.type`) in its for-loop to decide what each match means. Adding a
10
- // new framework annotation that follows this single-string-argument shape is a
11
- // change to that loop (and the lookup maps), not to this query. Annotations with
12
- // a different argument shape e.g. an array value `@RequestMapping({"/a","/b"})`
13
- // are out of scope here (as they were for the prior queries) and would need a
14
- // new branch.
41
+ // string argument — positional `"..."` or named `key = "..."`, each also in its
42
+ // array form `{"..."}` / `key = {"..."}` (Spring's `path`/`value` are
43
+ // `String[]`) on a class, interface, or method. This SINGLE query matches that
44
+ // shape generically; the `@value` capture is an alternation over a bare
45
+ // `string_literal` and one nested in an `element_value_array_initializer`, so a
46
+ // single-element array yields the same `@value` (a multi-element array yields one
47
+ // match per element). `scanRouteAnnotations` then reads the annotation NAME
48
+ // (`@ann`) and declaration kind (`@node.type`) in its for-loop to decide what
49
+ // each match means. Adding a new framework annotation that follows this shape is
50
+ // a change to that loop (and the lookup maps), not to this query.
15
51
  //
16
52
  // Captures (shared across all branches; intentionally framework-agnostic):
17
53
  // @ann → the annotation name identifier (RequestMapping, GetMapping, RequestLine, …)
@@ -27,6 +63,18 @@ import { METHOD_ANNOTATION_TO_HTTP, isRouteMemberKey, findEnclosingClass, } from
27
63
  // silently dropping sibling-branch matches. Keeping the query predicate-free
28
64
  // sidesteps that hazard entirely; all name/key discrimination lives in the
29
65
  // for-loop, where it reads as straight-line code.
66
+ //
67
+ // KNOWN LIMITATION — fully-qualified route annotations are not matched. `@ann`
68
+ // binds `name: (identifier)`, but a FQN annotation (`@org.springframework…
69
+ // GetMapping("/x")`) parses its name as a `scoped_identifier`, which this query
70
+ // does not match, so its route is not extracted. (The class is still recognized
71
+ // as a controller — `hasAnnotation` trailing-segment-matches the FQN — only the
72
+ // route-string extraction is missed.) In practice annotations are imported and
73
+ // written by simple name, so this is rare. It is a minor asymmetry with the
74
+ // Kotlin plugin, whose grammar models a FQN as separate `type_identifier`
75
+ // segments that its route queries DO match. Aligning Java would mean matching
76
+ // `scoped_identifier` too; that is deferred to avoid re-keying existing Java
77
+ // contracts via the predicate hazard above. Pinned by an anti-overreach test.
30
78
  const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
31
79
  name: 'java-route-annotation',
32
80
  language: Java,
@@ -39,12 +87,12 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
39
87
  (modifiers
40
88
  (annotation
41
89
  name: (identifier) @ann
42
- arguments: (annotation_argument_list (string_literal) @value)))) @node
90
+ arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
43
91
  (interface_declaration
44
92
  (modifiers
45
93
  (annotation
46
94
  name: (identifier) @ann
47
- arguments: (annotation_argument_list (string_literal) @value)))) @node
95
+ arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
48
96
  (class_declaration
49
97
  (modifiers
50
98
  (annotation
@@ -52,7 +100,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
52
100
  arguments: (annotation_argument_list
53
101
  (element_value_pair
54
102
  key: (identifier) @key
55
- value: (string_literal) @value))))) @node
103
+ value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))) @node
56
104
  (interface_declaration
57
105
  (modifiers
58
106
  (annotation
@@ -60,12 +108,12 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
60
108
  arguments: (annotation_argument_list
61
109
  (element_value_pair
62
110
  key: (identifier) @key
63
- value: (string_literal) @value))))) @node
111
+ value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))) @node
64
112
  (method_declaration
65
113
  (modifiers
66
114
  (annotation
67
115
  name: (identifier) @ann
68
- arguments: (annotation_argument_list (string_literal) @value)))
116
+ arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))
69
117
  name: (identifier) @member) @node
70
118
  (method_declaration
71
119
  (modifiers
@@ -74,7 +122,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
74
122
  arguments: (annotation_argument_list
75
123
  (element_value_pair
76
124
  key: (identifier) @key
77
- value: (string_literal) @value))))
125
+ value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))
78
126
  name: (identifier) @member) @node
79
127
  ]
80
128
  `,
@@ -96,60 +144,6 @@ const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
96
144
  },
97
145
  ],
98
146
  });
99
- // ─── Consumer: OpenFeign `@RequestLine("METHOD /path")` parsing ───────
100
- // OpenFeign's native annotation pairs an HTTP method and path in a single
101
- // string literal — see https://github.com/OpenFeign/feign#interface-annotations.
102
- // It is method-level only and is mutually exclusive with Spring MVC
103
- // `@GetMapping` / `@PostMapping` etc. on the same method (mixing them
104
- // requires a different Feign Contract — they are not combined). The match
105
- // itself comes from `JAVA_ROUTE_ANNOTATION_PATTERNS`; this regex splits the
106
- // verb from the path of the captured literal.
107
- //
108
- // Examples:
109
- // @RequestLine("GET /users/{id}")
110
- // @RequestLine("POST /users?status=active")
111
- const REQUEST_LINE_VERB_RE = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S.*?)\s*$/i;
112
- /**
113
- * Parse a Feign `@RequestLine` value into a method + path pair.
114
- *
115
- * `@RequestLine("METHOD /path[?query]")` packs both fields in one string;
116
- * the query portion is dropped because contract IDs are method+path only
117
- * (consistent with how other consumers like RestTemplate/WebClient drop
118
- * query strings when their values are inline literals).
119
- *
120
- * Returns null if the value is not a recognized HTTP verb followed by a
121
- * path beginning with `/`.
122
- */
123
- function parseRequestLine(raw) {
124
- const match = REQUEST_LINE_VERB_RE.exec(raw);
125
- if (!match)
126
- return null;
127
- const [, verb, rest] = match;
128
- if (typeof verb !== 'string' || typeof rest !== 'string')
129
- return null;
130
- const queryIdx = rest.indexOf('?');
131
- const pathOnly = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).trim();
132
- if (!pathOnly.startsWith('/'))
133
- return null;
134
- return { method: verb.toUpperCase(), path: pathOnly };
135
- }
136
- // ─── Consumer: Spring RestTemplate (object-named + method-named) ──────
137
- // RestTemplate.getForObject / getForEntity → GET
138
- // RestTemplate.postForObject / postForEntity → POST
139
- // RestTemplate.put → PUT
140
- // RestTemplate.delete → DELETE
141
- // RestTemplate.patchForObject → PATCH
142
- // Source-scan only: receiver must be named exactly `restTemplate`.
143
- // Fields, `this.restTemplate`, aliases, and other injection names are deferred.
144
- const REST_TEMPLATE_TO_HTTP = {
145
- getForObject: 'GET',
146
- getForEntity: 'GET',
147
- postForObject: 'POST',
148
- postForEntity: 'POST',
149
- put: 'PUT',
150
- delete: 'DELETE',
151
- patchForObject: 'PATCH',
152
- };
153
147
  const REST_TEMPLATE_PATTERNS = compilePatterns({
154
148
  name: 'java-rest-template',
155
149
  language: Java,
@@ -184,13 +178,6 @@ const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({
184
178
  },
185
179
  ],
186
180
  });
187
- const WEB_CLIENT_SHORT_TO_HTTP = {
188
- get: 'GET',
189
- post: 'POST',
190
- put: 'PUT',
191
- delete: 'DELETE',
192
- patch: 'PATCH',
193
- };
194
181
  const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({
195
182
  name: 'java-web-client-short-form',
196
183
  language: Java,
@@ -209,6 +196,36 @@ const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({
209
196
  },
210
197
  ],
211
198
  });
199
+ // ─── Consumer: WebClient long form `webClient.method(HttpMethod.X).uri("/y")` ─
200
+ // The fluent long form carries the verb as a `HttpMethod.X` field access through
201
+ // `.method(...)` and the path on a separate `.uri(...)` hop. A single structural
202
+ // query matches the whole chain (the same field-access shape used by
203
+ // REST_TEMPLATE_EXCHANGE_PATTERNS) — the earlier "intentionally deferred" note
204
+ // predated the Kotlin plugin proving the structural query is enough. Variable-
205
+ // bound verbs (`webClient.method(verb).uri(...)`) do NOT match: the value carries
206
+ // a bare `identifier`, not a `HttpMethod.X` field access — source-scan can't
207
+ // follow the binding (anti-overreach test pins this, parity with Kotlin).
208
+ const WEB_CLIENT_LONG_FORM_PATTERNS = compilePatterns({
209
+ name: 'java-web-client-long-form',
210
+ language: Java,
211
+ patterns: [
212
+ {
213
+ meta: {},
214
+ query: `
215
+ (method_invocation
216
+ object: (method_invocation
217
+ object: (identifier) @obj (#eq? @obj "webClient")
218
+ name: (identifier) @method_call (#eq? @method_call "method")
219
+ arguments: (argument_list
220
+ (field_access
221
+ object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
222
+ field: (identifier) @verb)))
223
+ name: (identifier) @uri_method (#eq? @uri_method "uri")
224
+ arguments: (argument_list . (string_literal) @path))
225
+ `,
226
+ },
227
+ ],
228
+ });
212
229
  // ─── Consumer: OkHttp `new Request.Builder().url("path")` ─────────────
213
230
  // Note: `Request.Builder` is a `scoped_type_identifier` whose text includes
214
231
  // the dot, so `#eq?` against the literal string matches cleanly (no need
@@ -287,32 +304,6 @@ function findEnclosingInterface(node) {
287
304
  }
288
305
  return null;
289
306
  }
290
- /**
291
- * Join a class-level prefix and a method-level path into a single URL
292
- * path. Mirrors the semantics of the original regex implementation:
293
- * strip trailing slashes on the prefix, then ensure a single slash
294
- * between prefix and method path.
295
- */
296
- function joinPath(prefix, methodPath) {
297
- const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
298
- const cleanSub = methodPath.replace(/^\/+/, '');
299
- if (!cleanPrefix)
300
- return `/${cleanSub}`;
301
- return `/${cleanPrefix}/${cleanSub}`;
302
- }
303
- function joinInheritedSpringPath(controllerPrefix, inheritedPath, inheritedOwnerPrefix = '') {
304
- const joined = joinPath(controllerPrefix, inheritedPath);
305
- const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
306
- const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, '');
307
- const cleanInherited = inheritedPath.replace(/^\/+/, '');
308
- if (!cleanPrefix)
309
- return joined;
310
- if (cleanPrefix === cleanOwnerPrefix &&
311
- (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`))) {
312
- return `/${cleanInherited}`;
313
- }
314
- return joined;
315
- }
316
307
  function getNodeName(node) {
317
308
  return node.childForFieldName('name')?.text ?? null;
318
309
  }
@@ -353,11 +344,15 @@ function scanRouteAnnotations(tree) {
353
344
  // `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
354
345
  const prefixByTypeId = new Map();
355
346
  const feignPrefixByInterfaceId = new Map();
347
+ const httpExchangePrefixByTypeId = new Map();
356
348
  const methodRoutes = [];
357
349
  const requestLines = [];
350
+ const exchangeRoutes = [];
358
351
  // Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`;
359
352
  // collect them and apply only after the FeignClient pass below.
360
353
  const interfaceRequestMappingPrefixes = [];
354
+ // `pushPrefix` (the de-duping accumulator) is shared from
355
+ // spring-consumer-shared.ts so Java and Kotlin build prefix maps identically.
361
356
  for (const { captures } of matches) {
362
357
  const annNode = captures.ann;
363
358
  const node = captures.node;
@@ -396,6 +391,22 @@ function scanRouteAnnotations(tree) {
396
391
  });
397
392
  }
398
393
  }
394
+ else if (EXCHANGE_ANNOTATION_TO_HTTP[ann]) {
395
+ // Spring 6 HTTP Interface `@(Get|...)Exchange` — the path lives in the
396
+ // `url` or `value` attribute (or positionally); other attributes
397
+ // (`accept`, `contentType`, …) are not routes.
398
+ if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value')
399
+ continue;
400
+ const rawPath = unquoteLiteral(valueNode.text);
401
+ if (rawPath !== null) {
402
+ exchangeRoutes.push({
403
+ methodNode: node,
404
+ methodName: captures.member?.text ?? null,
405
+ httpMethod: EXCHANGE_ANNOTATION_TO_HTTP[ann],
406
+ rawPath,
407
+ });
408
+ }
409
+ }
399
410
  continue;
400
411
  }
401
412
  // Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
@@ -405,7 +416,7 @@ function scanRouteAnnotations(tree) {
405
416
  continue;
406
417
  const prefix = unquoteLiteral(valueNode.text);
407
418
  if (prefix !== null) {
408
- prefixByTypeId.set(node.id, prefix);
419
+ pushPrefix(prefixByTypeId, node.id, prefix);
409
420
  if (node.type === 'interface_declaration') {
410
421
  interfaceRequestMappingPrefixes.push({ id: node.id, prefix });
411
422
  }
@@ -416,16 +427,33 @@ function scanRouteAnnotations(tree) {
416
427
  if (!keyNode || keyNode.text !== 'path')
417
428
  continue;
418
429
  const prefix = unquoteLiteral(valueNode.text);
419
- if (prefix !== null && !feignPrefixByInterfaceId.has(node.id)) {
420
- feignPrefixByInterfaceId.set(node.id, prefix);
421
- }
430
+ if (prefix !== null)
431
+ pushPrefix(feignPrefixByInterfaceId, node.id, prefix);
432
+ }
433
+ else if (ann === 'HttpExchange') {
434
+ // Spring HTTP Interface type-level prefix: the path lives in `url`/`value`
435
+ // (or positionally). Applies to its `@(Get|...)Exchange` consumer methods.
436
+ if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value')
437
+ continue;
438
+ const prefix = unquoteLiteral(valueNode.text);
439
+ if (prefix !== null)
440
+ pushPrefix(httpExchangePrefixByTypeId, node.id, prefix);
422
441
  }
423
442
  }
443
+ // `@RequestMapping` on a Feign interface is the fallback prefix, but only when
444
+ // the interface has no `@FeignClient(path)` of its own (path wins).
424
445
  for (const { id, prefix } of interfaceRequestMappingPrefixes) {
425
446
  if (!feignPrefixByInterfaceId.has(id))
426
- feignPrefixByInterfaceId.set(id, prefix);
447
+ pushPrefix(feignPrefixByInterfaceId, id, prefix);
427
448
  }
428
- return { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines };
449
+ return {
450
+ prefixByTypeId,
451
+ feignPrefixByInterfaceId,
452
+ httpExchangePrefixByTypeId,
453
+ methodRoutes,
454
+ requestLines,
455
+ exchangeRoutes,
456
+ };
429
457
  }
430
458
  function collectDirectMethods(typeNode) {
431
459
  const out = [];
@@ -486,7 +514,7 @@ function collectSpringTypes(filePath, tree) {
486
514
  filePath,
487
515
  kind,
488
516
  name: typeNameNode.text,
489
- classPrefix: prefixByTypeId.get(typeNode.id) ?? '',
517
+ classPrefixes: prefixByTypeId.get(typeNode.id) ?? [],
490
518
  implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [],
491
519
  isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']),
492
520
  methods,
@@ -494,63 +522,11 @@ function collectSpringTypes(filePath, tree) {
494
522
  }
495
523
  return out;
496
524
  }
525
+ // The interface-based-controller inheritance algorithm is shared with kotlin.ts
526
+ // (`scanSpringInheritanceProject`); this collects the `SharedSpringType` view and
527
+ // delegates so Java and Kotlin emit byte-identical provider contracts.
497
528
  function scanSpringProject(files) {
498
- const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree));
499
- const interfaceRoutes = new Map();
500
- for (const type of types) {
501
- if (type.kind !== 'interface')
502
- continue;
503
- if (interfaceRoutes.has(type.name)) {
504
- interfaceRoutes.set(type.name, null);
505
- continue;
506
- }
507
- const methodMap = new Map();
508
- for (const method of type.methods) {
509
- const routes = method.routes.map((route) => ({
510
- method: route.method,
511
- path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path,
512
- ownerPrefix: type.classPrefix,
513
- }));
514
- if (routes.length > 0)
515
- methodMap.set(method.name, routes);
516
- }
517
- interfaceRoutes.set(type.name, methodMap);
518
- }
519
- const detectionsByFile = new Map();
520
- for (const type of types) {
521
- if (type.kind !== 'class' || !type.isController)
522
- continue;
523
- for (const method of type.methods) {
524
- if (method.routes.length > 0)
525
- continue;
526
- const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => {
527
- const routeMap = interfaceRoutes.get(interfaceName);
528
- if (!routeMap)
529
- return [];
530
- const routes = routeMap.get(method.name) ?? [];
531
- return routes.map((route) => ({
532
- method: route.method,
533
- path: joinInheritedSpringPath(type.classPrefix, route.path, route.ownerPrefix),
534
- }));
535
- });
536
- for (const route of inheritedRoutes) {
537
- const detections = detectionsByFile.get(type.filePath) ?? [];
538
- detections.push({
539
- role: 'provider',
540
- framework: 'spring',
541
- method: route.method,
542
- path: route.path,
543
- name: method.name,
544
- confidence: 0.8,
545
- });
546
- detectionsByFile.set(type.filePath, detections);
547
- }
548
- }
549
- }
550
- return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
551
- filePath,
552
- detections,
553
- }));
529
+ return scanSpringInheritanceProject(files.flatMap((file) => collectSpringTypes(file.filePath, file.tree)));
554
530
  }
555
531
  export const JAVA_HTTP_PLUGIN = {
556
532
  name: 'java-http',
@@ -561,7 +537,7 @@ export const JAVA_HTTP_PLUGIN = {
561
537
  // `scanRouteAnnotations` resolves every route-defining annotation —
562
538
  // class/interface prefixes, method `@(Get|...)Mapping`s and native
563
539
  // `@RequestLine`s — from a single `matches()` pass over the tree.
564
- const { prefixByTypeId, feignPrefixByInterfaceId, methodRoutes, requestLines } = scanRouteAnnotations(tree);
540
+ const { prefixByTypeId, feignPrefixByInterfaceId, httpExchangePrefixByTypeId, methodRoutes, requestLines, exchangeRoutes, } = scanRouteAnnotations(tree);
565
541
  // A `@(Get|...)Mapping` inside a `@FeignClient` interface is an OpenFeign
566
542
  // *consumer* (it describes a remote call); the same annotation inside a
567
543
  // class is a Spring *provider*. A mapping on a non-Feign interface has no
@@ -570,29 +546,35 @@ export const JAVA_HTTP_PLUGIN = {
570
546
  for (const route of methodRoutes) {
571
547
  const enclosingInterface = findEnclosingInterface(route.methodNode);
572
548
  if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
573
- const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
549
+ const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? [''];
550
+ for (const prefix of prefixes) {
551
+ out.push({
552
+ role: 'consumer',
553
+ framework: OPENFEIGN_FRAMEWORK,
554
+ method: route.httpMethod,
555
+ path: joinPath(prefix, route.rawPath),
556
+ name: route.methodName,
557
+ confidence: FEIGN_CONFIDENCE,
558
+ });
559
+ }
560
+ continue;
561
+ }
562
+ const enclosingClass = findEnclosingClass(route.methodNode);
563
+ if (!enclosingClass)
564
+ continue;
565
+ // A multi-element class `@RequestMapping({"/a","/b"})` registers the method
566
+ // under each prefix — emit one provider per (prefix × this route).
567
+ const prefixes = prefixByTypeId.get(enclosingClass.id) ?? [''];
568
+ for (const prefix of prefixes) {
574
569
  out.push({
575
- role: 'consumer',
576
- framework: 'openfeign',
570
+ role: 'provider',
571
+ framework: 'spring',
577
572
  method: route.httpMethod,
578
573
  path: joinPath(prefix, route.rawPath),
579
574
  name: route.methodName,
580
- confidence: 0.7,
575
+ confidence: 0.8,
581
576
  });
582
- continue;
583
577
  }
584
- const enclosingClass = findEnclosingClass(route.methodNode);
585
- if (!enclosingClass)
586
- continue;
587
- const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
588
- out.push({
589
- role: 'provider',
590
- framework: 'spring',
591
- method: route.httpMethod,
592
- path: joinPath(prefix, route.rawPath),
593
- name: route.methodName,
594
- confidence: 0.8,
595
- });
596
578
  }
597
579
  // Native OpenFeign `@RequestLine("METHOD /path")`. Method-level only and
598
580
  // always declared on an interface (Feign builds a proxy from the interface).
@@ -608,15 +590,36 @@ export const JAVA_HTTP_PLUGIN = {
608
590
  const enclosingInterface = findEnclosingInterface(requestLine.methodNode);
609
591
  if (!enclosingInterface)
610
592
  continue;
611
- const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
612
- out.push({
613
- role: 'consumer',
614
- framework: 'openfeign',
615
- method: requestLine.parsed.method,
616
- path: joinPath(prefix, requestLine.parsed.path),
617
- name: requestLine.methodName,
618
- confidence: 0.75,
619
- });
593
+ const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? [''];
594
+ for (const prefix of prefixes) {
595
+ out.push({
596
+ role: 'consumer',
597
+ framework: OPENFEIGN_FRAMEWORK,
598
+ method: requestLine.parsed.method,
599
+ path: joinPath(prefix, requestLine.parsed.path),
600
+ name: requestLine.methodName,
601
+ confidence: REQUEST_LINE_CONFIDENCE,
602
+ });
603
+ }
604
+ }
605
+ // ─── Consumers: Spring HTTP Interface @(Get|...)Exchange ────────
606
+ // Declarative client interfaces proxied by `HttpServiceProxyFactory`
607
+ // (over RestClient / WebClient / RestTemplate). Always a consumer — no
608
+ // provider ambiguity — with an optional type-level `@HttpExchange(url)`
609
+ // prefix. The verb comes from the annotation name (`@GetExchange` → GET).
610
+ for (const route of exchangeRoutes) {
611
+ const enclosing = findEnclosingInterface(route.methodNode) ?? findEnclosingClass(route.methodNode);
612
+ const prefixes = enclosing ? (httpExchangePrefixByTypeId.get(enclosing.id) ?? ['']) : [''];
613
+ for (const prefix of prefixes) {
614
+ out.push({
615
+ role: 'consumer',
616
+ framework: HTTP_INTERFACE_FRAMEWORK,
617
+ method: route.httpMethod,
618
+ path: joinPath(prefix, route.rawPath),
619
+ name: route.methodName,
620
+ confidence: EXCHANGE_CONFIDENCE,
621
+ });
622
+ }
620
623
  }
621
624
  // ─── Consumers: RestTemplate ────────────────────────────────────
622
625
  for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
@@ -657,9 +660,9 @@ export const JAVA_HTTP_PLUGIN = {
657
660
  });
658
661
  }
659
662
  // ─── Consumers: WebClient.get().uri("path") short form ─────────
660
- // Source-scan only: receiver must be named exactly `webClient`.
661
- // The real long-form chain `webClient.method(HttpMethod.X).uri("/x")`
662
- // needs multi-hop chain analysis and is intentionally deferred.
663
+ // Source-scan only: receiver must be named exactly `webClient`. The
664
+ // long-form chain `webClient.method(HttpMethod.X).uri("/x")` is handled
665
+ // separately below by WEB_CLIENT_LONG_FORM_PATTERNS.
663
666
  for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_FORM_PATTERNS, tree)) {
664
667
  const verbNode = match.captures.verb;
665
668
  const pathNode = match.captures.path;
@@ -680,6 +683,31 @@ export const JAVA_HTTP_PLUGIN = {
680
683
  confidence: 0.7,
681
684
  });
682
685
  }
686
+ // ─── Consumers: WebClient.method(HttpMethod.X).uri("path") long form ─
687
+ // The verb is captured as the literal `HttpMethod.X` field name; gate it on
688
+ // the shared verb regex (HEAD/OPTIONS/TRACE excluded, matching the short
689
+ // form). The short-form query requires an empty inner argument list, so it
690
+ // cannot also fire on this chain — no double-emit.
691
+ for (const match of runCompiledPatterns(WEB_CLIENT_LONG_FORM_PATTERNS, tree)) {
692
+ const verbNode = match.captures.verb;
693
+ const pathNode = match.captures.path;
694
+ if (!verbNode || !pathNode)
695
+ continue;
696
+ const verbText = verbNode.text;
697
+ if (!WEB_CLIENT_LONG_VERB_RE.test(verbText))
698
+ continue;
699
+ const path = unquoteLiteral(pathNode.text);
700
+ if (path === null)
701
+ continue;
702
+ out.push({
703
+ role: 'consumer',
704
+ framework: 'spring-web-client',
705
+ method: verbText,
706
+ path,
707
+ name: null,
708
+ confidence: 0.7,
709
+ });
710
+ }
683
711
  // ─── Consumers: OkHttp Request.Builder().url("path") ────────────
684
712
  for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
685
713
  const pathNode = match.captures.path;