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,5 +1,7 @@
1
1
  import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
+ import { METHOD_ANNOTATION_TO_HTTP, 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';
3
5
  /**
4
6
  * Kotlin HTTP plugin (Spring providers + consumers).
5
7
  *
@@ -65,50 +67,34 @@ try {
65
67
  catch {
66
68
  Kotlin = null;
67
69
  }
68
- const METHOD_ANNOTATION_TO_HTTP = {
69
- GetMapping: 'GET',
70
- PostMapping: 'POST',
71
- PutMapping: 'PUT',
72
- DeleteMapping: 'DELETE',
73
- PatchMapping: 'PATCH',
74
- };
70
+ // The Spring `@(Get|...)Mapping` verb map, RestTemplate / WebClient short-form
71
+ // verb maps, the `@(Get|...)Exchange` verb map, `joinPath`, `parseRequestLine`,
72
+ // and the shared confidence/framework constants are imported from
73
+ // `spring-consumer-shared.ts` / `spring-shared.ts` so the Kotlin and Java
74
+ // plugins emit identical contract IDs.
75
+ // The WebClient long-form verb gate (`WEB_CLIENT_LONG_VERB_RE`) is imported from
76
+ // `spring-consumer-shared.ts` so the Java and Kotlin long-form scans accept the
77
+ // same verb set (HEAD/OPTIONS/TRACE excluded, matching the short form).
78
+ // The de-duping prefix accumulator (`pushPrefix`) is imported from
79
+ // `spring-consumer-shared.ts` so the Java and Kotlin plugins build their
80
+ // per-declaration prefix maps identically.
75
81
  /**
76
- * RestTemplate method-name HTTP verb. Mirrors the Java plugin's
77
- * `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the
78
- * same contract IDs from .java and .kt sources.
79
- */
80
- const REST_TEMPLATE_TO_HTTP = {
81
- getForObject: 'GET',
82
- getForEntity: 'GET',
83
- postForObject: 'POST',
84
- postForEntity: 'POST',
85
- put: 'PUT',
86
- delete: 'DELETE',
87
- patchForObject: 'PATCH',
88
- };
89
- /**
90
- * WebClient short-form verb → HTTP verb. The reactive WebClient API
91
- * exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as
92
- * one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)`
93
- * carries the path. We capture both pieces in a single query (see
94
- * `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here.
95
- */
96
- const WEB_CLIENT_SHORT_TO_HTTP = {
97
- get: 'GET',
98
- post: 'POST',
99
- put: 'PUT',
100
- delete: 'DELETE',
101
- patch: 'PATCH',
102
- };
103
- /**
104
- * Allowed HTTP verbs for the WebClient long-form path
105
- * `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module
106
- * load (instead of inside the scan loop) per maintainer feedback on
107
- * PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above —
108
- * keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry
109
- * with the short form and the Java plugin.
82
+ * Tree-sitter sub-pattern for the Kotlin `arrayOf("/a", "/b")` annotation-array
83
+ * form, capturing each element string under `cap` (`@prefix` or `@path`).
84
+ *
85
+ * Kept as a DEDICATED query fragment embedded in its own pattern — NEVER as an
86
+ * arm of the `[(string_literal) (collection_literal …)]` alternation. The
87
+ * `#eq? @arrayOf "arrayOf"` predicate, sharing a single alternation bucket with
88
+ * the string/collection arms, would evaluate FALSE for those arms (where
89
+ * `@arrayOf` is absent) and silently drop them — the tree-sitter 0.21.x hazard
90
+ * documented in `java.ts`. tree-sitter yields one match per `arrayOf` element,
91
+ * so multi-element arrays accumulate through the same loops as `collection_literal`
92
+ * (verified by AST probe). The `arrayOf` callee constraint keeps unrelated calls
93
+ * (`buildPath("/x")`) from matching.
110
94
  */
111
- const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/;
95
+ const arrayOfArg = (cap) => `(call_expression
96
+ (simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
97
+ (call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
112
98
  /**
113
99
  * Build the plugin only if the Kotlin grammar is available. Compiling
114
100
  * the queries against a null grammar would throw at module load time
@@ -148,7 +134,7 @@ function buildKotlinPlugin(language) {
148
134
  (constructor_invocation
149
135
  (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
150
136
  (value_arguments
151
- (value_argument . (string_literal) @prefix)))))
137
+ (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))
152
138
  (type_identifier) @cls) @class
153
139
  `,
154
140
  },
@@ -163,7 +149,35 @@ function buildKotlinPlugin(language) {
163
149
  (value_arguments
164
150
  (value_argument
165
151
  (simple_identifier) @key (#match? @key "^(path|value)$")
166
- (string_literal) @prefix)))))
152
+ [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))
153
+ (type_identifier) @cls) @class
154
+ `,
155
+ },
156
+ {
157
+ meta: {},
158
+ query: `
159
+ (class_declaration
160
+ (modifiers
161
+ (annotation
162
+ (constructor_invocation
163
+ (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
164
+ (value_arguments
165
+ (value_argument . ${arrayOfArg('@prefix')})))))
166
+ (type_identifier) @cls) @class
167
+ `,
168
+ },
169
+ {
170
+ meta: {},
171
+ query: `
172
+ (class_declaration
173
+ (modifiers
174
+ (annotation
175
+ (constructor_invocation
176
+ (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
177
+ (value_arguments
178
+ (value_argument
179
+ (simple_identifier) @key (#match? @key "^(path|value)$")
180
+ ${arrayOfArg('@prefix')})))))
167
181
  (type_identifier) @cls) @class
168
182
  `,
169
183
  },
@@ -186,7 +200,35 @@ function buildKotlinPlugin(language) {
186
200
  (constructor_invocation
187
201
  (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
188
202
  (value_arguments
189
- (value_argument . (string_literal) @path)))))
203
+ (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)])))))
204
+ (simple_identifier) @method_name) @method
205
+ `,
206
+ },
207
+ {
208
+ meta: {},
209
+ query: `
210
+ (function_declaration
211
+ (modifiers
212
+ (annotation
213
+ (constructor_invocation
214
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
215
+ (value_arguments
216
+ (value_argument
217
+ (simple_identifier) @key (#match? @key "^(path|value)$")
218
+ [(string_literal) @path (collection_literal (string_literal) @path)])))))
219
+ (simple_identifier) @method_name) @method
220
+ `,
221
+ },
222
+ {
223
+ meta: {},
224
+ query: `
225
+ (function_declaration
226
+ (modifiers
227
+ (annotation
228
+ (constructor_invocation
229
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
230
+ (value_arguments
231
+ (value_argument . ${arrayOfArg('@path')})))))
190
232
  (simple_identifier) @method_name) @method
191
233
  `,
192
234
  },
@@ -201,7 +243,7 @@ function buildKotlinPlugin(language) {
201
243
  (value_arguments
202
244
  (value_argument
203
245
  (simple_identifier) @key (#match? @key "^(path|value)$")
204
- (string_literal) @path)))))
246
+ ${arrayOfArg('@path')})))))
205
247
  (simple_identifier) @method_name) @method
206
248
  `,
207
249
  },
@@ -381,32 +423,355 @@ function buildKotlinPlugin(language) {
381
423
  },
382
424
  ],
383
425
  });
426
+ // ─── Consumer (OpenFeign): @FeignClient interface marker + path prefix ─
427
+ // A `@FeignClient` interface's `@(Get|...)Mapping` methods describe OUTBOUND
428
+ // calls (consumers), not routes the service serves. Pattern 1 marks the
429
+ // interface; pattern 2 captures its optional `path = "/prefix"` (the
430
+ // `name`/`value`/`url` attributes identify the remote service, not a path).
431
+ // In tree-sitter-kotlin an `interface` is a `class_declaration`, so the
432
+ // method-route loop reclassifies @*Mapping methods whose enclosing
433
+ // class_declaration is in `feignClassIds` (see scan()).
434
+ const SPRING_FEIGN_CLIENT_PATTERNS = compilePatterns({
435
+ name: 'kotlin-spring-feign-client',
436
+ language,
437
+ patterns: [
438
+ {
439
+ meta: {},
440
+ query: `
441
+ (class_declaration
442
+ (modifiers
443
+ (annotation
444
+ (constructor_invocation
445
+ (user_type (type_identifier) @ann (#eq? @ann "FeignClient")))))) @class
446
+ `,
447
+ },
448
+ {
449
+ meta: {},
450
+ query: `
451
+ (class_declaration
452
+ (modifiers
453
+ (annotation
454
+ (constructor_invocation
455
+ (user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
456
+ (value_arguments
457
+ (value_argument
458
+ (simple_identifier) @key (#eq? @key "path")
459
+ [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class
460
+ `,
461
+ },
462
+ {
463
+ meta: {},
464
+ query: `
465
+ (class_declaration
466
+ (modifiers
467
+ (annotation
468
+ (constructor_invocation
469
+ (user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
470
+ (value_arguments
471
+ (value_argument
472
+ (simple_identifier) @key (#eq? @key "path")
473
+ ${arrayOfArg('@prefix')})))))) @class
474
+ `,
475
+ },
476
+ ],
477
+ });
478
+ // ─── Consumer: Spring 6 HTTP Interface @(Get|...)Exchange ─────────────
479
+ // Declarative client interfaces proxied by HttpServiceProxyFactory (over
480
+ // RestClient / WebClient / RestTemplate). The path lives in `url`/`value`
481
+ // (named) or positionally. Always a consumer — no provider ambiguity.
482
+ const SPRING_EXCHANGE_PATTERNS = compilePatterns({
483
+ name: 'kotlin-spring-http-exchange',
484
+ language,
485
+ patterns: [
486
+ {
487
+ meta: {},
488
+ query: `
489
+ (function_declaration
490
+ (modifiers
491
+ (annotation
492
+ (constructor_invocation
493
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$"))
494
+ (value_arguments
495
+ (value_argument . [(string_literal) @path (collection_literal (string_literal) @path)])))))
496
+ (simple_identifier) @method_name) @method
497
+ `,
498
+ },
499
+ {
500
+ meta: {},
501
+ query: `
502
+ (function_declaration
503
+ (modifiers
504
+ (annotation
505
+ (constructor_invocation
506
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$"))
507
+ (value_arguments
508
+ (value_argument
509
+ (simple_identifier) @key (#match? @key "^(url|value)$")
510
+ [(string_literal) @path (collection_literal (string_literal) @path)])))))
511
+ (simple_identifier) @method_name) @method
512
+ `,
513
+ },
514
+ {
515
+ meta: {},
516
+ query: `
517
+ (function_declaration
518
+ (modifiers
519
+ (annotation
520
+ (constructor_invocation
521
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$"))
522
+ (value_arguments
523
+ (value_argument . ${arrayOfArg('@path')})))))
524
+ (simple_identifier) @method_name) @method
525
+ `,
526
+ },
527
+ {
528
+ meta: {},
529
+ query: `
530
+ (function_declaration
531
+ (modifiers
532
+ (annotation
533
+ (constructor_invocation
534
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Exchange$"))
535
+ (value_arguments
536
+ (value_argument
537
+ (simple_identifier) @key (#match? @key "^(url|value)$")
538
+ ${arrayOfArg('@path')})))))
539
+ (simple_identifier) @method_name) @method
540
+ `,
541
+ },
542
+ ],
543
+ });
544
+ // ─── Consumer: HTTP Interface type-level @HttpExchange(url) prefix ─────
545
+ const SPRING_HTTP_EXCHANGE_CLASS_PATTERNS = compilePatterns({
546
+ name: 'kotlin-spring-http-exchange-class',
547
+ language,
548
+ patterns: [
549
+ {
550
+ meta: {},
551
+ query: `
552
+ (class_declaration
553
+ (modifiers
554
+ (annotation
555
+ (constructor_invocation
556
+ (user_type (type_identifier) @ann (#eq? @ann "HttpExchange"))
557
+ (value_arguments
558
+ (value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class
559
+ `,
560
+ },
561
+ {
562
+ meta: {},
563
+ query: `
564
+ (class_declaration
565
+ (modifiers
566
+ (annotation
567
+ (constructor_invocation
568
+ (user_type (type_identifier) @ann (#eq? @ann "HttpExchange"))
569
+ (value_arguments
570
+ (value_argument
571
+ (simple_identifier) @key (#match? @key "^(url|value)$")
572
+ [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))) @class
573
+ `,
574
+ },
575
+ {
576
+ meta: {},
577
+ query: `
578
+ (class_declaration
579
+ (modifiers
580
+ (annotation
581
+ (constructor_invocation
582
+ (user_type (type_identifier) @ann (#eq? @ann "HttpExchange"))
583
+ (value_arguments
584
+ (value_argument . ${arrayOfArg('@prefix')})))))) @class
585
+ `,
586
+ },
587
+ {
588
+ meta: {},
589
+ query: `
590
+ (class_declaration
591
+ (modifiers
592
+ (annotation
593
+ (constructor_invocation
594
+ (user_type (type_identifier) @ann (#eq? @ann "HttpExchange"))
595
+ (value_arguments
596
+ (value_argument
597
+ (simple_identifier) @key (#match? @key "^(url|value)$")
598
+ ${arrayOfArg('@prefix')})))))) @class
599
+ `,
600
+ },
601
+ ],
602
+ });
603
+ // ─── Consumer: OpenFeign native @RequestLine("VERB /path") ────────────
604
+ // Two patterns mirror the positional vs named split. java.ts accepts the
605
+ // named `value` argument (java.ts:442 drops any non-`value` key); the
606
+ // positional pattern's `.` anchor only matches when the string literal is the
607
+ // first argument, so the named form needs its own pattern. Constraining
608
+ // `#eq? @key "value"` keeps non-`value` keys (`name`, etc.) dropped — Java
609
+ // parity, just enforced in the query rather than the JS loop.
610
+ const SPRING_REQUEST_LINE_PATTERNS = compilePatterns({
611
+ name: 'kotlin-spring-request-line',
612
+ language,
613
+ patterns: [
614
+ {
615
+ meta: {},
616
+ query: `
617
+ (function_declaration
618
+ (modifiers
619
+ (annotation
620
+ (constructor_invocation
621
+ (user_type (type_identifier) @ann (#eq? @ann "RequestLine"))
622
+ (value_arguments
623
+ (value_argument . (string_literal) @value)))))
624
+ (simple_identifier) @method_name) @method
625
+ `,
626
+ },
627
+ {
628
+ meta: {},
629
+ query: `
630
+ (function_declaration
631
+ (modifiers
632
+ (annotation
633
+ (constructor_invocation
634
+ (user_type (type_identifier) @ann (#eq? @ann "RequestLine"))
635
+ (value_arguments
636
+ (value_argument
637
+ (simple_identifier) @key (#eq? @key "value")
638
+ (string_literal) @value)))))
639
+ (simple_identifier) @method_name) @method
640
+ `,
641
+ },
642
+ ],
643
+ });
644
+ // ─── Provider via interface inheritance (Spring interface-based controller) ─
645
+ // Pattern: `@RestController class X(...) : XApi` where the route annotations
646
+ // live on the `XApi` interface and the controller's `override fun` carries
647
+ // none. Java resolves this in `scanProject` (scanSpringProject); this is the
648
+ // Kotlin port. tree-sitter-kotlin models BOTH class and interface as
649
+ // `class_declaration`; the `interface` keyword token distinguishes them.
650
+ const KOTLIN_TYPE_DECLARATION_PATTERNS = compilePatterns({
651
+ name: 'kotlin-type-declaration',
652
+ language,
653
+ patterns: [{ meta: {}, query: `(class_declaration (type_identifier) @name) @type` }],
654
+ });
655
+ /** A `class_declaration` is an interface when it carries the `interface` keyword token. */
656
+ const isKotlinInterface = (node) => node.children.some((c) => c.type === 'interface');
657
+ /** Resolve an `annotation` node's simple name: `@Foo` / `@Foo(...)` / `@a.b.Foo` → "Foo". */
658
+ const kotlinAnnotationName = (annotation) => {
659
+ const direct = annotation.namedChildren.find((c) => c.type === 'user_type');
660
+ const ctor = annotation.namedChildren.find((c) => c.type === 'constructor_invocation');
661
+ const userType = direct ?? ctor?.namedChildren.find((c) => c.type === 'user_type');
662
+ // A fully-qualified annotation (`@a.b.Foo`) parses to a `user_type` carrying
663
+ // one `type_identifier` per dotted segment (`a`, `b`, `Foo`); the trailing
664
+ // one is the simple name. Taking the FIRST would resolve `@org…RestController`
665
+ // to "org" and miss the controller.
666
+ const idents = userType?.namedChildren.filter((c) => c.type === 'type_identifier') ?? [];
667
+ const ident = idents.at(-1);
668
+ return ident ? ident.text : null;
669
+ };
384
670
  /**
385
- * Find the nearest enclosing class_declaration ancestor for a node, or
386
- * null if the node is top-level. Mirrors the Java plugin's helper.
671
+ * Whether a `class_declaration` is a Spring `@RestController` / `@Controller`.
672
+ * All forms attach under `modifiers` as an `annotation` (confirmed against
673
+ * tree-sitter-kotlin fwcd): the bare `@RestController`, the common
674
+ * `@RestController @RequestMapping("/x")` pair, AND the arg-form
675
+ * `@RestController("beanName")` — the last parses to an `annotation` whose
676
+ * child is a `constructor_invocation` (NOT a detached sibling), which
677
+ * `kotlinAnnotationName` reads. A single pass over `modifiers` covers them all.
387
678
  */
388
- function findEnclosingClass(node) {
389
- let cur = node.parent;
390
- while (cur) {
391
- if (cur.type === 'class_declaration')
392
- return cur;
393
- cur = cur.parent;
679
+ const CONTROLLER_ANNOTATIONS = new Set(['RestController', 'Controller']);
680
+ const kotlinClassIsController = (typeNode) => {
681
+ const modifiers = typeNode.namedChildren.find((c) => c.type === 'modifiers');
682
+ for (const ann of modifiers?.namedChildren ?? []) {
683
+ if (ann.type !== 'annotation')
684
+ continue;
685
+ const name = kotlinAnnotationName(ann);
686
+ if (name && CONTROLLER_ANNOTATIONS.has(name))
687
+ return true;
394
688
  }
395
- return null;
396
- }
397
- /**
398
- * Join a class-level prefix and a method-level path. Identical
399
- * semantics to the Java plugin: strip leading/trailing slashes on
400
- * the prefix, strip leading slashes on the method path, ensure a
401
- * single slash between them.
402
- */
403
- function joinPath(prefix, methodPath) {
404
- const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
405
- const cleanSub = methodPath.replace(/^\/+/, '');
406
- if (!cleanPrefix)
407
- return `/${cleanSub}`;
408
- return `/${cleanPrefix}/${cleanSub}`;
409
- }
689
+ return false;
690
+ };
691
+ /** Supertype names from `: A, B` (`delegation_specifier` → `user_type` → `type_identifier`). */
692
+ const collectKotlinSupertypes = (node) => {
693
+ const out = [];
694
+ for (const child of node.namedChildren) {
695
+ if (child.type !== 'delegation_specifier')
696
+ continue;
697
+ const userType = child.namedChildren.find((c) => c.type === 'user_type');
698
+ // FQN supertype (`: a.b.Api`) → one `type_identifier` per segment; the
699
+ // trailing one is the simple name (taking the first would yield "a").
700
+ const idents = userType?.namedChildren.filter((c) => c.type === 'type_identifier') ?? [];
701
+ const ident = idents.at(-1);
702
+ if (ident)
703
+ out.push(ident.text);
704
+ }
705
+ return out;
706
+ };
707
+ /** Direct `function_declaration` members of a type (no descent into nested types). */
708
+ const collectKotlinDirectMethods = (typeNode) => {
709
+ const body = typeNode.namedChildren.find((c) => c.type === 'class_body');
710
+ if (!body)
711
+ return [];
712
+ return body.namedChildren.filter((c) => c.type === 'function_declaration');
713
+ };
714
+ const kotlinFunctionName = (fn) => fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null;
715
+ const collectKotlinSpringTypes = (filePath, tree) => {
716
+ // Class-level @RequestMapping prefixes (reuse the provider class-prefix query).
717
+ const prefixByClassId = new Map();
718
+ for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
719
+ const prefixNode = match.captures.prefix;
720
+ const classNode = match.captures.class;
721
+ if (!prefixNode || !classNode)
722
+ continue;
723
+ const prefix = unquoteLiteral(prefixNode.text);
724
+ if (prefix !== null)
725
+ pushPrefix(prefixByClassId, classNode.id, prefix);
726
+ }
727
+ // Method @(Get|...)Mapping routes keyed by the function_declaration node id.
728
+ const routesByMethodId = new Map();
729
+ for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
730
+ const annNode = match.captures.ann;
731
+ const pathNode = match.captures.path;
732
+ const methodNode = match.captures.method;
733
+ if (!annNode || !pathNode || !methodNode)
734
+ continue;
735
+ const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
736
+ if (!httpMethod)
737
+ continue;
738
+ const rawPath = unquoteLiteral(pathNode.text);
739
+ if (rawPath === null)
740
+ continue;
741
+ const arr = routesByMethodId.get(methodNode.id) ?? [];
742
+ arr.push({ method: httpMethod, path: rawPath });
743
+ routesByMethodId.set(methodNode.id, arr);
744
+ }
745
+ const out = [];
746
+ for (const match of runCompiledPatterns(KOTLIN_TYPE_DECLARATION_PATTERNS, tree)) {
747
+ const typeNode = match.captures.type;
748
+ const nameNode = match.captures.name;
749
+ if (!typeNode || !nameNode)
750
+ continue;
751
+ const kind = isKotlinInterface(typeNode) ? 'interface' : 'class';
752
+ const methods = collectKotlinDirectMethods(typeNode)
753
+ .map((fn) => ({ name: kotlinFunctionName(fn), routes: routesByMethodId.get(fn.id) ?? [] }))
754
+ .filter((m) => {
755
+ return m.name !== null;
756
+ });
757
+ out.push({
758
+ filePath,
759
+ kind,
760
+ name: nameNode.text,
761
+ isController: kind === 'class' ? kotlinClassIsController(typeNode) : false,
762
+ classPrefixes: prefixByClassId.get(typeNode.id) ?? [],
763
+ implementedInterfaces: kind === 'class' ? collectKotlinSupertypes(typeNode) : [],
764
+ methods,
765
+ });
766
+ }
767
+ return out;
768
+ };
769
+ // The interface-based-controller inheritance algorithm is shared with java.ts
770
+ // (`scanSpringInheritanceProject`); this collects the language-specific
771
+ // `SharedSpringType` view and delegates. kotlinClassIsController handles every
772
+ // controller form (bare, paired, and the arg-form `@RestController("bean")`)
773
+ // via the `modifiers` `annotation`/`constructor_invocation` shape.
774
+ const scanKotlinProject = (files) => scanSpringInheritanceProject(files.flatMap((f) => collectKotlinSpringTypes(f.filePath, f.tree)));
410
775
  return {
411
776
  name: 'kotlin-http',
412
777
  language,
@@ -421,9 +786,38 @@ function buildKotlinPlugin(language) {
421
786
  continue;
422
787
  const prefix = unquoteLiteral(prefixNode.text);
423
788
  if (prefix !== null)
424
- prefixByClassId.set(classNode.id, prefix);
789
+ pushPrefix(prefixByClassId, classNode.id, prefix);
425
790
  }
426
- // ─── Method routes ──────────────────────────────────────────────
791
+ // ─── OpenFeign client interfaces + HTTP Interface type prefixes ──
792
+ // In tree-sitter-kotlin an `interface` is a `class_declaration`, so a
793
+ // `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be
794
+ // mis-emitted as providers. Collect the FeignClient class ids (and their
795
+ // optional `path` prefix) so the method-route loop can reclassify them.
796
+ const feignClassIds = new Set();
797
+ const feignPrefixByClassId = new Map();
798
+ for (const match of runCompiledPatterns(SPRING_FEIGN_CLIENT_PATTERNS, tree)) {
799
+ const classNode = match.captures.class;
800
+ if (!classNode)
801
+ continue;
802
+ feignClassIds.add(classNode.id);
803
+ const prefixNode = match.captures.prefix;
804
+ if (prefixNode) {
805
+ const prefix = unquoteLiteral(prefixNode.text);
806
+ if (prefix !== null)
807
+ pushPrefix(feignPrefixByClassId, classNode.id, prefix);
808
+ }
809
+ }
810
+ const httpExchangePrefixByClassId = new Map();
811
+ for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) {
812
+ const classNode = match.captures.class;
813
+ const prefixNode = match.captures.prefix;
814
+ if (!classNode || !prefixNode)
815
+ continue;
816
+ const prefix = unquoteLiteral(prefixNode.text);
817
+ if (prefix !== null)
818
+ pushPrefix(httpExchangePrefixByClassId, classNode.id, prefix);
819
+ }
820
+ // ─── Method routes (Spring providers) + OpenFeign consumers ─────
427
821
  for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
428
822
  const annNode = match.captures.ann;
429
823
  const pathNode = match.captures.path;
@@ -438,16 +832,46 @@ function buildKotlinPlugin(language) {
438
832
  if (rawPath === null)
439
833
  continue;
440
834
  const enclosingClass = findEnclosingClass(methodNode);
441
- const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
442
- const fullPath = joinPath(prefix, rawPath);
443
- out.push({
444
- role: 'provider',
445
- framework: 'spring',
446
- method: httpMethod,
447
- path: fullPath,
448
- name: nameNode?.text ?? null,
449
- confidence: 0.8,
450
- });
835
+ // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
836
+ // consumer (a remote call), not a route this service serves.
837
+ if (enclosingClass && feignClassIds.has(enclosingClass.id)) {
838
+ // @FeignClient(path) wins over @RequestMapping; a multi-element prefix
839
+ // yields one consumer per (prefix × this route).
840
+ const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
841
+ prefixByClassId.get(enclosingClass.id) ?? [''];
842
+ for (const prefix of prefixes) {
843
+ out.push({
844
+ role: 'consumer',
845
+ framework: OPENFEIGN_FRAMEWORK,
846
+ method: httpMethod,
847
+ path: joinPath(prefix, rawPath),
848
+ name: nameNode?.text ?? null,
849
+ confidence: FEIGN_CONFIDENCE,
850
+ });
851
+ }
852
+ continue;
853
+ }
854
+ // A @(Get|...)Mapping on a (non-Feign) interface declares a route
855
+ // *contract*, not a route this service serves — the implementing
856
+ // @RestController is the provider, emitted via scanProject's interface
857
+ // inheritance. Java drops these implicitly (findEnclosingClass returns
858
+ // null for an interface_declaration); tree-sitter-kotlin models an
859
+ // interface as a class_declaration, so skip it explicitly here.
860
+ if (enclosingClass && isKotlinInterface(enclosingClass))
861
+ continue;
862
+ // A multi-element class `@RequestMapping(["/a","/b"])` registers the method
863
+ // under each prefix — emit one provider per (prefix × this route).
864
+ const prefixes = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? ['']) : [''];
865
+ for (const prefix of prefixes) {
866
+ out.push({
867
+ role: 'provider',
868
+ framework: 'spring',
869
+ method: httpMethod,
870
+ path: joinPath(prefix, rawPath),
871
+ name: nameNode?.text ?? null,
872
+ confidence: 0.8,
873
+ });
874
+ }
451
875
  }
452
876
  // ─── Consumers: RestTemplate ────────────────────────────────────
453
877
  for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
@@ -537,8 +961,78 @@ function buildKotlinPlugin(language) {
537
961
  confidence: 0.7,
538
962
  });
539
963
  }
964
+ // ─── Consumers: Spring HTTP Interface @(Get|...)Exchange ────────
965
+ for (const match of runCompiledPatterns(SPRING_EXCHANGE_PATTERNS, tree)) {
966
+ const annNode = match.captures.ann;
967
+ const pathNode = match.captures.path;
968
+ const nameNode = match.captures.method_name;
969
+ const methodNode = match.captures.method;
970
+ if (!annNode || !pathNode || !methodNode)
971
+ continue;
972
+ const httpMethod = EXCHANGE_ANNOTATION_TO_HTTP[annNode.text];
973
+ if (!httpMethod)
974
+ continue;
975
+ const rawPath = unquoteLiteral(pathNode.text);
976
+ if (rawPath === null)
977
+ continue;
978
+ const enclosingClass = findEnclosingClass(methodNode);
979
+ const prefixes = enclosingClass
980
+ ? (httpExchangePrefixByClassId.get(enclosingClass.id) ?? [''])
981
+ : [''];
982
+ for (const prefix of prefixes) {
983
+ out.push({
984
+ role: 'consumer',
985
+ framework: HTTP_INTERFACE_FRAMEWORK,
986
+ method: httpMethod,
987
+ path: joinPath(prefix, rawPath),
988
+ name: nameNode?.text ?? null,
989
+ confidence: EXCHANGE_CONFIDENCE,
990
+ });
991
+ }
992
+ }
993
+ // ─── Consumers: OpenFeign native @RequestLine("VERB /path") ─────
994
+ // Method-level only and always declared on an interface — Feign builds its
995
+ // proxy from the interface, so a `@RequestLine` on a concrete class is not
996
+ // a client call. We do NOT require an enclosing `@FeignClient` (core Feign
997
+ // uses `@RequestLine` with `Feign.builder()`, not Spring Cloud's
998
+ // `@FeignClient`); the `RequestLine` name plus the structural interface
999
+ // check keep false positives away. Mirrors java.ts's `findEnclosingInterface`
1000
+ // gate — in tree-sitter-kotlin an interface is a `class_declaration`, so we
1001
+ // test the `interface` keyword via isKotlinInterface.
1002
+ for (const match of runCompiledPatterns(SPRING_REQUEST_LINE_PATTERNS, tree)) {
1003
+ const valueNode = match.captures.value;
1004
+ const nameNode = match.captures.method_name;
1005
+ const methodNode = match.captures.method;
1006
+ if (!valueNode || !methodNode)
1007
+ continue;
1008
+ const raw = unquoteLiteral(valueNode.text);
1009
+ const parsed = raw !== null ? parseRequestLine(raw) : null;
1010
+ if (!parsed)
1011
+ continue;
1012
+ const enclosingClass = findEnclosingClass(methodNode);
1013
+ if (!enclosingClass || !isKotlinInterface(enclosingClass))
1014
+ continue;
1015
+ // Mirror java.ts (which pre-merges the @RequestMapping fallback into
1016
+ // feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else
1017
+ // the interface's class-level @RequestMapping prefix, else none. Without
1018
+ // the prefixByClassId fallback Kotlin dropped the class prefix that Java
1019
+ // applies — the same fallback chain the @GetMapping-in-Feign path uses above.
1020
+ const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
1021
+ prefixByClassId.get(enclosingClass.id) ?? [''];
1022
+ for (const prefix of prefixes) {
1023
+ out.push({
1024
+ role: 'consumer',
1025
+ framework: OPENFEIGN_FRAMEWORK,
1026
+ method: parsed.method,
1027
+ path: joinPath(prefix, parsed.path),
1028
+ name: nameNode?.text ?? null,
1029
+ confidence: REQUEST_LINE_CONFIDENCE,
1030
+ });
1031
+ }
1032
+ }
540
1033
  return out;
541
1034
  },
1035
+ scanProject: scanKotlinProject,
542
1036
  };
543
1037
  }
544
1038
  /**