gitnexus 1.6.10 → 1.6.11-rc.2

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.
Files changed (31) hide show
  1. package/dist/core/group/extractors/http-patterns/kotlin.js +522 -7
  2. package/dist/core/group/extractors/http-patterns/php.js +279 -11
  3. package/dist/core/index-freshness.d.ts +2 -2
  4. package/dist/core/index-freshness.js +13 -0
  5. package/dist/core/ingestion/parsing-processor.d.ts +2 -0
  6. package/dist/core/ingestion/parsing-processor.js +5 -0
  7. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -0
  8. package/dist/core/ingestion/pipeline-phases/parse-impl.js +7 -0
  9. package/dist/core/ingestion/pipeline-phases/parse.d.ts +4 -0
  10. package/dist/core/ingestion/pipeline.js +4 -1
  11. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +377 -0
  12. package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +1203 -0
  13. package/dist/core/ingestion/scope-extractor-bridge.js +8 -2
  14. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +2 -0
  15. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +14 -2
  16. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +2 -0
  17. package/dist/core/ingestion/scope-resolution/pipeline/run.js +11 -1
  18. package/dist/core/ingestion/scope-resolution/scope-extraction-failures.d.ts +14 -0
  19. package/dist/core/ingestion/scope-resolution/scope-extraction-failures.js +35 -0
  20. package/dist/core/ingestion/workers/parse-worker.d.ts +6 -0
  21. package/dist/core/ingestion/workers/parse-worker.js +7 -1
  22. package/dist/core/ingestion/workers/result-merge.js +4 -1
  23. package/dist/core/run-analyze.js +6 -0
  24. package/dist/mcp/local/local-backend.d.ts +2 -0
  25. package/dist/mcp/local/local-backend.js +29 -0
  26. package/dist/mcp/tools.js +6 -4
  27. package/dist/storage/parse-cache.js +6 -6
  28. package/dist/storage/repo-meta.d.ts +17 -1
  29. package/dist/storage/repo-meta.js +1 -1
  30. package/dist/types/pipeline.d.ts +4 -0
  31. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
3
  import { METHOD_ANNOTATION_TO_HTTP, findEnclosingClass, joinPath, } from '../../../ingestion/route-extractors/spring-shared.js';
4
+ import { buildKotlinConstantIndex, extractKotlinModuleConstants, foldKotlinOperands, isKotlinConstantFile, overlayKotlinConstantIndex, parseKotlinConstOperands, unfoldableDeclarationsOf, unquoteKotlinIdentifier, } from '../../../ingestion/route-extractors/kotlin-const-resolver.js';
4
5
  import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_RE, EXCHANGE_ANNOTATION_TO_HTTP, parseRequestLine, pushPrefix, scanSpringInheritanceProject, OPENFEIGN_FRAMEWORK, HTTP_INTERFACE_FRAMEWORK, FEIGN_CONFIDENCE, REQUEST_LINE_CONFIDENCE, EXCHANGE_CONFIDENCE, } from './spring-consumer-shared.js';
5
6
  /**
6
7
  * Kotlin HTTP plugin (Spring providers + consumers).
@@ -11,6 +12,24 @@ import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, WEB_CLIENT_LONG_VERB_R
11
12
  * named annotation arguments (`@GetMapping(value = "/x")` and
12
13
  * `@GetMapping(path = "/x")`) are supported.
13
14
  *
15
+ * A method path that is a CONSTANT rather than a literal —
16
+ * `@GetMapping(ApiPaths.ORDERS)`, `@PostMapping(value = ApiPaths.BASE + "/create")` —
17
+ * is folded against a repo-wide Kotlin constant map built once per `extract()`
18
+ * run by `prepareRepo`, mirroring what the Java plugin does for the same shape
19
+ * in `java.ts`. An unresolvable fold skips the route (never a guessed path), and
20
+ * a class prefix that resolves to NO literal at all suppresses every method
21
+ * route under that class — the rule `java.ts` applies too, because emitting
22
+ * those routes unprefixed would publish paths the application does not serve.
23
+ * A prefix that resolves only PARTLY (Kotlin's vararg spelling
24
+ * `@RequestMapping("/lit", ApiPaths.BASE)`) still publishes its resolvable arm:
25
+ * suppression exists to avoid wrong routes, not to discard right ones. An EMPTY
26
+ * path array (`@RequestMapping(arrayOf())`) is not a prefix at all and
27
+ * suppresses nothing — see `classifyPathArgument`. On a
28
+ * `@FeignClient` the same rule is applied to whichever prefix GOVERNS, in the
29
+ * "path wins" order the URL is assembled in — `@FeignClient(path)` first, then
30
+ * the interface's `@RequestMapping` — and to both consumer lanes, `@(Get|...)Mapping`
31
+ * and `@RequestLine`.
32
+ *
14
33
  * **Consumers** — four call-site patterns common in Kotlin
15
34
  * Spring projects:
16
35
  *
@@ -95,6 +114,171 @@ catch {
95
114
  const arrayOfArg = (cap) => `(call_expression
96
115
  (simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
97
116
  (call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
117
+ /**
118
+ * Expression node types a METHOD route path can be FOLDED from. A
119
+ * `string_literal` is deliberately absent: literal paths are already captured by
120
+ * the dedicated literal patterns, so admitting one here would emit the same
121
+ * route twice.
122
+ *
123
+ * This is an allow-list on purpose, and only safe because it gates FOLDING: a
124
+ * shape missing from it yields no route, which is the skip floor. The
125
+ * unfoldable-CLASS-PREFIX analysis must not be written this way — there a shape
126
+ * missing from the list means "emit unprefixed", a wrong route — so it inverts
127
+ * the test instead (see `classifyPathArgument`).
128
+ */
129
+ const FOLDABLE_PATH_EXPRESSIONS = new Set([
130
+ 'simple_identifier',
131
+ 'navigation_expression',
132
+ 'additive_expression',
133
+ ]);
134
+ /**
135
+ * Repo-relative path in the POSIX form the Kotlin constant map is keyed by.
136
+ *
137
+ * The orchestrator's file list comes from glob v13, which has no `posix: true`
138
+ * option and joins with the platform separator, so on Windows `prepareRepo`
139
+ * receives `src\main\kotlin\com\example\ApiPaths.kt` and `scan` receives the
140
+ * same for `fileRel`. `resolveKotlinImport` turns an import specifier into
141
+ * `com/example/ApiPaths.kt` and asks whether a key ENDS WITH it — a test no
142
+ * backslashed key can pass. Left unnormalized, every cross-file constant fold
143
+ * returns null on Windows and on Windows only: the pre-pass still runs, the
144
+ * context is still built, and the feature is simply, silently absent. The unit
145
+ * fixtures build POSIX keys by hand, so CI cannot see it.
146
+ *
147
+ * Normalizing at this boundary — write side (the map keys below) and read side
148
+ * (`fileRel`) — is the same fix `node.ts` (`normalizeRel`) and `python.ts`
149
+ * (`fileShortKey` / `fileLongKey`) already apply for the same reason, and it is
150
+ * the only coherent place: the resolver returns the key it matched, so
151
+ * normalizing inside it would hand back a value that misses in a map nobody
152
+ * normalized. `readFile` still receives the ORIGINAL `rel`, since the filesystem
153
+ * wants the platform's own spelling.
154
+ */
155
+ function normalizeRel(rel) {
156
+ return rel.replace(/\\/g, '/').replace(/^\.\//, '');
157
+ }
158
+ /**
159
+ * The path expression carried by one route-annotation argument, or null when the
160
+ * argument does not designate a path.
161
+ *
162
+ * tree-sitter-kotlin gives positional and named arguments the same
163
+ * `value_argument` node, distinguished only by a leading `simple_identifier` and
164
+ * an `=` token — so the key must be read here rather than constrained in the
165
+ * query. Non-route keys (`produces`, `consumes`, `headers`, …) return null,
166
+ * matching the `#match? @key "^(path|value)$"` guard the literal patterns use.
167
+ */
168
+ function kotlinRouteArgumentExpression(arg) {
169
+ const first = arg.namedChild(0);
170
+ if (!first)
171
+ return null;
172
+ if (!arg.children.some((c) => c.type === '='))
173
+ return first; // positional
174
+ if (first.type !== 'simple_identifier')
175
+ return null;
176
+ if (first.text !== 'path' && first.text !== 'value')
177
+ return null;
178
+ return arg.namedChild(1);
179
+ }
180
+ /**
181
+ * The `path = …` expression of one `@FeignClient` argument, or null.
182
+ *
183
+ * Deliberately narrower than {@link kotlinRouteArgumentExpression}: on a Feign
184
+ * client the positional argument and `value =` name a SERVICE, not a path, so
185
+ * only the explicit `path` key contributes a URL prefix. This mirrors the
186
+ * `#eq? @key "path"` guard the literal `@FeignClient` patterns use, and the
187
+ * `keyNode.text !== 'path'` guard `java.ts` applies to the same annotation.
188
+ */
189
+ function kotlinFeignPathArgumentExpression(arg) {
190
+ const first = arg.namedChild(0);
191
+ if (!first || first.type !== 'simple_identifier')
192
+ return null;
193
+ if (!arg.children.some((c) => c.type === '='))
194
+ return null;
195
+ if (first.text !== 'path')
196
+ return null;
197
+ return arg.namedChild(1);
198
+ }
199
+ /**
200
+ * Is `node` a string literal whose value is fully known at parse time — that is,
201
+ * a literal carrying no interpolation?
202
+ *
203
+ * tree-sitter-kotlin models `"$base/x"` and `"${base}/x"` as a `string_literal`
204
+ * whose named children INTERLEAVE `string_content` runs with interpolation nodes
205
+ * — `interpolation_identifier_start`/`interpolated_identifier` for the `$name`
206
+ * form, `interpolation_expression_start`/`interpolated_expression`/
207
+ * `interpolation_expression_end` for `${…}` — so the test has to be `every`, not
208
+ * `some`: `"pre${A.B}post"` carries `string_content` too. The route layer
209
+ * unquotes the RAW TEXT, so treating one as a literal publishes the source
210
+ * spelling — `/${ApiPaths.BASE}/orders` — as though the application served it.
211
+ * Escape sequences are NOT separate nodes in this grammar (`"/a\nb"` is one
212
+ * `string_content`), so this accepts exactly what it accepted before; a future
213
+ * grammar that split them would floor to "unknown" rather than to a de-escaped
214
+ * guess. Same test the constant resolver's `stringLiteralValue` applies, so a
215
+ * path is either literal on both sides or folded on neither.
216
+ */
217
+ function isPlainStringLiteral(node) {
218
+ if (node.type !== 'string_literal')
219
+ return false;
220
+ return node.namedChildren.every((child) => child.type === 'string_content');
221
+ }
222
+ /**
223
+ * Element expressions of a Kotlin `arrayOf(...)` call, or null when `node` is
224
+ * not one. The JS mirror of the {@link arrayOfArg} query fragment, so the
225
+ * unfoldable-prefix analysis inspects exactly the elements the literal prefix
226
+ * patterns harvest.
227
+ */
228
+ function kotlinArrayOfElements(node) {
229
+ if (node.type !== 'call_expression')
230
+ return null;
231
+ const callee = node.namedChild(0);
232
+ if (callee?.type !== 'simple_identifier' || callee.text !== 'arrayOf')
233
+ return null;
234
+ const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
235
+ const args = suffix?.namedChildren.find((c) => c.type === 'value_arguments');
236
+ if (!args)
237
+ return null;
238
+ return args.namedChildren
239
+ .filter((c) => c.type === 'value_argument')
240
+ .map((c) => c.namedChild(0))
241
+ .filter((c) => c !== null);
242
+ }
243
+ function classifyPathArgument(expr) {
244
+ if (isPlainStringLiteral(expr))
245
+ return 'literal';
246
+ if (expr.type === 'collection_literal') {
247
+ const elements = expr.namedChildren.filter((child) => child.text.length > 0);
248
+ if (elements.length === 0)
249
+ return 'none';
250
+ return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
251
+ }
252
+ const elements = kotlinArrayOfElements(expr);
253
+ if (elements) {
254
+ if (elements.length === 0)
255
+ return 'none';
256
+ return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
257
+ }
258
+ return 'unresolvable';
259
+ }
260
+ /**
261
+ * Type declarations enclosing `node`, innermost first, by qualified type path.
262
+ *
263
+ * The scope a bare constant in a route annotation is resolved against; passed to
264
+ * `foldKotlinOperands`, which applies it. Collects `class_declaration` (including
265
+ * interfaces) and `object_declaration`. A `companion_object` adds no link of
266
+ * its own — members are keyed under the enclosing class one hop up. For a node
267
+ * inside `Outer.Inner`, returns `['Outer.Inner', 'Outer']`, matching the keys
268
+ * produced by `extractKotlinModuleConstants`. Skips unnamed types rather than
269
+ * guessing.
270
+ */
271
+ function kotlinEnclosingTypeNames(node) {
272
+ const simpleNames = [];
273
+ for (let cur = node.parent; cur; cur = cur.parent) {
274
+ if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration')
275
+ continue;
276
+ const ident = cur.children.find((c) => c.type === 'type_identifier');
277
+ if (ident)
278
+ simpleNames.push(unquoteKotlinIdentifier(ident.text));
279
+ }
280
+ return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.'));
281
+ }
98
282
  // ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
99
283
  // Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
100
284
  // is a `call_expression` whose callee is a `navigation_expression` (receiver +
@@ -362,6 +546,148 @@ function buildKotlinPlugin(language) {
362
546
  },
363
547
  ],
364
548
  });
549
+ // ─── Provider: constant-valued @RequestMapping / @(Get|...)Mapping ────
550
+ // The literal patterns above pin the path node itself (`(string_literal) @path`),
551
+ // which structurally cannot match `@GetMapping(ApiPaths.ORDERS)`. These two
552
+ // capture the whole `value_argument` instead and let
553
+ // `kotlinRouteArgumentExpression` sort out positional vs `path =`/`value =`
554
+ // in JS — a query-level split is not available here, because tree-sitter-kotlin
555
+ // uses one `value_argument` node for both forms and 0.21.x has no negation to
556
+ // test the `=` token with.
557
+ //
558
+ // These deliberately match LITERAL arguments too (any `value_argument` does).
559
+ // The method-route loop drops those via `FOLDABLE_PATH_EXPRESSIONS` so a
560
+ // literal route is emitted once, by the literal patterns; the class-prefix
561
+ // collector instead KEEPS them and tests them for literalness, which is how a
562
+ // prefix that no literal pattern could resolve gets noticed at all.
563
+ const SPRING_CONST_CLASS_PREFIX_PATTERNS = compilePatterns({
564
+ name: 'kotlin-spring-const-class-prefix',
565
+ language,
566
+ patterns: [
567
+ {
568
+ meta: {},
569
+ query: `
570
+ (class_declaration
571
+ (modifiers
572
+ (annotation
573
+ (constructor_invocation
574
+ (user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
575
+ (value_arguments (value_argument) @arg))))
576
+ (type_identifier) @cls) @class
577
+ `,
578
+ },
579
+ ],
580
+ });
581
+ const SPRING_CONST_METHOD_ROUTE_PATTERNS = compilePatterns({
582
+ name: 'kotlin-spring-const-method-route',
583
+ language,
584
+ patterns: [
585
+ {
586
+ meta: {},
587
+ query: `
588
+ (function_declaration
589
+ (modifiers
590
+ (annotation
591
+ (constructor_invocation
592
+ (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
593
+ (value_arguments (value_argument) @arg))))
594
+ (simple_identifier) @method_name) @method
595
+ `,
596
+ },
597
+ ],
598
+ });
599
+ const SPRING_CONST_FEIGN_PATH_PATTERNS = compilePatterns({
600
+ name: 'kotlin-spring-const-feign-path',
601
+ language,
602
+ patterns: [
603
+ {
604
+ meta: {},
605
+ query: `
606
+ (class_declaration
607
+ (modifiers
608
+ (annotation
609
+ (constructor_invocation
610
+ (user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
611
+ (value_arguments (value_argument) @arg))))) @class
612
+ `,
613
+ },
614
+ ],
615
+ });
616
+ /**
617
+ * Ids of classes whose `@RequestMapping` prefix cannot be resolved to any
618
+ * literal, so no route under them can be published at a path the application
619
+ * actually serves.
620
+ *
621
+ * The predicate is INVERTED rather than an allow-list of non-literal node
622
+ * types: a class is marked unless its `path`/`value` argument is provably
623
+ * literal (recursing into `[…]` and `arrayOf(…)` elements, and refusing an
624
+ * interpolated `string_literal`). An allow-list has to enumerate every
625
+ * non-literal spelling and silently passes the ones it forgot —
626
+ * `[ApiPaths.BASE]`, `arrayOf(ApiPaths.BASE)`, `buildPath()`,
627
+ * `if (…) "/a" else "/b"` — each of which then publishes its methods at their
628
+ * UNPREFIXED path, a route the application does not serve. `java.ts` gates on
629
+ * the ABSENCE of a literal (`if (!valueNode)`) for the same reason.
630
+ *
631
+ * `resolvedPrefixes` is the literal prefix map built by the pass ABOVE, and a
632
+ * class holding an entry there is deliberately NOT marked: Kotlin's vararg
633
+ * spelling `@RequestMapping("/lit", ApiPaths.BASE)` leaves a resolvable `/lit`
634
+ * behind, and suppressing it would drop a route that IS derivable — trading a
635
+ * wrong route for a missing one, which is not the bargain this suppression
636
+ * exists to make. The prefix set is then partial (the constant arm is absent)
637
+ * exactly as it was before constant folding existed.
638
+ *
639
+ * The prefix is never folded here: it also feeds the cross-file
640
+ * interface-inheritance pass, which has no repo context, so folding it in
641
+ * `scan` alone would make the two views disagree. Same rule `java.ts` applies
642
+ * (`typesWithUnfoldablePrefix`); folding class prefixes cross-file is a
643
+ * follow-up on both sides. Used by BOTH `scan` and the inheritance-view
644
+ * collector — with the prefix map each has already built — so the two cannot
645
+ * drift apart.
646
+ */
647
+ const collectUnfoldablePrefixClassIds = (tree, resolvedPrefixes) => {
648
+ const ids = new Set();
649
+ for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
650
+ const argNode = match.captures.arg;
651
+ const classNode = match.captures.class;
652
+ if (!argNode || !classNode)
653
+ continue;
654
+ if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0)
655
+ continue;
656
+ const expr = kotlinRouteArgumentExpression(argNode);
657
+ if (!expr || classifyPathArgument(expr) !== 'unresolvable')
658
+ continue;
659
+ ids.add(classNode.id);
660
+ }
661
+ return ids;
662
+ };
663
+ /**
664
+ * Ids of `@FeignClient` interfaces whose `path` argument is present but not
665
+ * resolvable to a literal.
666
+ *
667
+ * `collectUnfoldablePrefixClassIds` cannot see these: it matches
668
+ * `@RequestMapping` only, so `@FeignClient(path = ApiPaths.BASE)` fell through
669
+ * to the `['']` prefix fallback and published the consumer at its unprefixed
670
+ * path — a call the service never makes. Kept as its own set rather than
671
+ * merged into the `@RequestMapping` one because `path` OUTRANKS
672
+ * `@RequestMapping` on a Feign client: an unresolvable `path` is fatal
673
+ * whatever the `@RequestMapping` says, and a resolvable `path` rescues a route
674
+ * whose `@RequestMapping` is a constant. The consumer lanes therefore consult
675
+ * the two in that same "path wins" order.
676
+ */
677
+ const collectFeignUnfoldablePathClassIds = (tree) => {
678
+ const ids = new Set();
679
+ for (const match of runCompiledPatterns(SPRING_CONST_FEIGN_PATH_PATTERNS, tree)) {
680
+ const argNode = match.captures.arg;
681
+ const classNode = match.captures.class;
682
+ if (!argNode || !classNode)
683
+ continue;
684
+ const expr = kotlinFeignPathArgumentExpression(argNode);
685
+ if (!expr || classifyPathArgument(expr) !== 'unresolvable')
686
+ continue;
687
+ ids.add(classNode.id);
688
+ }
689
+ return ids;
690
+ };
365
691
  // ─── Consumer: Spring RestTemplate ────────────────────────────────────
366
692
  // Kotlin call-site shape mirrors the Java plugin's
367
693
  // `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
@@ -827,12 +1153,26 @@ function buildKotlinPlugin(language) {
827
1153
  const classNode = match.captures.class;
828
1154
  if (!prefixNode || !classNode)
829
1155
  continue;
1156
+ // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
1157
+ // its raw text would carry the source spelling into the shared type view
1158
+ // as a served prefix. Refusing it here is also what lets the unfoldable
1159
+ // analysis below mark such a class (it skips classes with a resolved
1160
+ // prefix), so the two stay one decision rather than two.
1161
+ if (!isPlainStringLiteral(prefixNode))
1162
+ continue;
830
1163
  const prefix = unquoteLiteral(prefixNode.text);
831
1164
  if (prefix !== null)
832
1165
  pushPrefix(prefixByClassId, classNode.id, prefix);
833
1166
  }
834
1167
  // Method @(Get|...)Mapping routes keyed by the function_declaration node id.
1168
+ //
1169
+ // Only LITERAL paths land here. A constant-valued path is folded in `scan`
1170
+ // against the repo constant map, which this inheritance-view collector has
1171
+ // no access to; publishing it as an empty path would put `POST /`-shaped
1172
+ // noise into the shared type view, so it is left out — the same skip floor
1173
+ // `java.ts`'s `collectSpringTypes` keeps.
835
1174
  const routesByMethodId = new Map();
1175
+ const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
836
1176
  for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
837
1177
  const annNode = match.captures.ann;
838
1178
  const pathNode = match.captures.path;
@@ -845,6 +1185,11 @@ function buildKotlinPlugin(language) {
845
1185
  const rawPath = unquoteLiteral(pathNode.text);
846
1186
  if (rawPath === null)
847
1187
  continue;
1188
+ // A constant class prefix leaves no single prefix string for the
1189
+ // inheritance view to carry, so this route would be published unprefixed.
1190
+ const owner = findEnclosingClass(methodNode);
1191
+ if (owner && unfoldablePrefixClassIds.has(owner.id))
1192
+ continue;
848
1193
  const arr = routesByMethodId.get(methodNode.id) ?? [];
849
1194
  arr.push({ method: httpMethod, path: rawPath });
850
1195
  routesByMethodId.set(methodNode.id, arr);
@@ -882,8 +1227,91 @@ function buildKotlinPlugin(language) {
882
1227
  return {
883
1228
  name: 'kotlin-http',
884
1229
  language,
885
- scan(tree) {
1230
+ prepareRepo(args) {
1231
+ // Build the repo-wide Kotlin string-constant map and import index once per
1232
+ // extract() run. The orchestrator hands over a bare Parser with no language
1233
+ // bound; bind Kotlin explicitly or `parseSource` spins to its whole time
1234
+ // budget on every file.
1235
+ try {
1236
+ args.parser.setLanguage(language);
1237
+ }
1238
+ catch {
1239
+ // A parser that rejects binding cannot produce a constant map; the
1240
+ // per-file try/catch below then skips everything harmlessly.
1241
+ }
1242
+ const constants = new Map();
1243
+ for (const rel of args.files) {
1244
+ if (!rel.endsWith('.kt') && !rel.endsWith('.kts'))
1245
+ continue;
1246
+ try {
1247
+ const src = args.readFile(rel);
1248
+ // Cheap content gate: only constant-DEFINITION candidates are parsed
1249
+ // here. Import-only files (every controller) are deliberately NOT
1250
+ // parsed in this pass — `scan` extracts the importing file's own
1251
+ // import table from the tree it already holds, on demand, for the
1252
+ // rare file that actually references a constant. A gate that also
1253
+ // matched `import …` would parse the entire repository here.
1254
+ if (!src || !isKotlinConstantFile(src))
1255
+ continue;
1256
+ const tree = args.parseSource(args.parser, src);
1257
+ if (!tree)
1258
+ continue;
1259
+ const mc = extractKotlinModuleConstants(tree);
1260
+ if (mc.literals.size > 0 ||
1261
+ mc.exprs.size > 0 ||
1262
+ mc.imports.size > 0 ||
1263
+ unfoldableDeclarationsOf(mc).size > 0) {
1264
+ // POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`.
1265
+ constants.set(normalizeRel(rel), mc);
1266
+ }
1267
+ }
1268
+ catch {
1269
+ // Per-file resilience: one unreadable/oversized/ill-formed file must
1270
+ // not forfeit the whole repo's constant map.
1271
+ continue;
1272
+ }
1273
+ }
1274
+ return { constants, index: buildKotlinConstantIndex(constants) };
1275
+ },
1276
+ scan(tree, repoContext, fileRel) {
886
1277
  const out = [];
1278
+ const kotlinCtx = repoContext;
1279
+ // Read side of the POSIX keying (see `normalizeRel`): the map `prepareRepo`
1280
+ // built is keyed by normalized path, so every lookup and every fold entry
1281
+ // point below uses `fileKey`, never the raw `fileRel`.
1282
+ const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
1283
+ // Lazy per-file constants/index view. `prepareRepo` only indexes constant-
1284
+ // DEFINING files, so an importing controller is absent from that map. When
1285
+ // a route references a constant, extract THIS file's import table from the
1286
+ // tree `scan` already holds and overlay it. Import-only overlays reuse the
1287
+ // prepared package projections; files whose routes are all literal never
1288
+ // pay this cost.
1289
+ let foldIndex;
1290
+ const getFoldIndex = () => {
1291
+ if (foldIndex !== undefined)
1292
+ return foldIndex;
1293
+ foldIndex = kotlinCtx?.index;
1294
+ if (!kotlinCtx || !fileKey)
1295
+ return foldIndex;
1296
+ if (kotlinCtx.constants.has(fileKey))
1297
+ return foldIndex;
1298
+ try {
1299
+ const mc = extractKotlinModuleConstants(tree);
1300
+ // Same admission test the pre-pass applies above. Keeping the complete
1301
+ // test here also makes this overlay correct if a future gate safely
1302
+ // excludes another declaration shape.
1303
+ if (mc.literals.size > 0 ||
1304
+ mc.exprs.size > 0 ||
1305
+ mc.imports.size > 0 ||
1306
+ unfoldableDeclarationsOf(mc).size > 0) {
1307
+ foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc);
1308
+ }
1309
+ }
1310
+ catch {
1311
+ // fold falls back to the repo-wide map (imports stay unresolved)
1312
+ }
1313
+ return foldIndex;
1314
+ };
887
1315
  // ─── Class prefixes ─────────────────────────────────────────────
888
1316
  const prefixByClassId = new Map();
889
1317
  for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
@@ -891,10 +1319,17 @@ function buildKotlinPlugin(language) {
891
1319
  const classNode = match.captures.class;
892
1320
  if (!prefixNode || !classNode)
893
1321
  continue;
1322
+ // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
1323
+ // `isPlainStringLiteral`. Refusing it here also lets the unfoldable
1324
+ // analysis below mark such a class, since that skips classes whose
1325
+ // prefix already resolved.
1326
+ if (!isPlainStringLiteral(prefixNode))
1327
+ continue;
894
1328
  const prefix = unquoteLiteral(prefixNode.text);
895
1329
  if (prefix !== null)
896
1330
  pushPrefix(prefixByClassId, classNode.id, prefix);
897
1331
  }
1332
+ const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
898
1333
  // ─── OpenFeign client interfaces + HTTP Interface type prefixes ──
899
1334
  // In tree-sitter-kotlin an `interface` is a `class_declaration`, so a
900
1335
  // `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be
@@ -908,12 +1343,13 @@ function buildKotlinPlugin(language) {
908
1343
  continue;
909
1344
  feignClassIds.add(classNode.id);
910
1345
  const prefixNode = match.captures.prefix;
911
- if (prefixNode) {
1346
+ if (prefixNode && isPlainStringLiteral(prefixNode)) {
912
1347
  const prefix = unquoteLiteral(prefixNode.text);
913
1348
  if (prefix !== null)
914
1349
  pushPrefix(feignPrefixByClassId, classNode.id, prefix);
915
1350
  }
916
1351
  }
1352
+ const feignClassesWithUnfoldablePath = collectFeignUnfoldablePathClassIds(tree);
917
1353
  const httpExchangePrefixByClassId = new Map();
918
1354
  for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) {
919
1355
  const classNode = match.captures.class;
@@ -925,10 +1361,12 @@ function buildKotlinPlugin(language) {
925
1361
  pushPrefix(httpExchangePrefixByClassId, classNode.id, prefix);
926
1362
  }
927
1363
  // ─── Method routes (Spring providers) + OpenFeign consumers ─────
1364
+ // Literal and constant-valued paths are normalized into one candidate list
1365
+ // so both reach the same Feign/interface/prefix classification below.
1366
+ const methodRoutes = [];
928
1367
  for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
929
1368
  const annNode = match.captures.ann;
930
1369
  const pathNode = match.captures.path;
931
- const nameNode = match.captures.method_name;
932
1370
  const methodNode = match.captures.method;
933
1371
  if (!annNode || !pathNode || !methodNode)
934
1372
  continue;
@@ -938,14 +1376,76 @@ function buildKotlinPlugin(language) {
938
1376
  const rawPath = unquoteLiteral(pathNode.text);
939
1377
  if (rawPath === null)
940
1378
  continue;
1379
+ methodRoutes.push({
1380
+ httpMethod,
1381
+ rawPath,
1382
+ nameNode: match.captures.method_name,
1383
+ methodNode,
1384
+ });
1385
+ }
1386
+ for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
1387
+ const annNode = match.captures.ann;
1388
+ const argNode = match.captures.arg;
1389
+ const methodNode = match.captures.method;
1390
+ if (!annNode || !argNode || !methodNode)
1391
+ continue;
1392
+ const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
1393
+ if (!httpMethod)
1394
+ continue;
1395
+ const expr = kotlinRouteArgumentExpression(argNode);
1396
+ if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type))
1397
+ continue;
1398
+ // No repo context (context-less fallback scanning) means no constant map
1399
+ // and therefore no honest answer — skip rather than guess a path.
1400
+ if (!fileKey)
1401
+ continue;
1402
+ const index = getFoldIndex();
1403
+ if (!index)
1404
+ continue;
1405
+ const operands = parseKotlinConstOperands(expr);
1406
+ if (operands === null)
1407
+ continue;
1408
+ // A bare reference means whatever the ENCLOSING types bind it to before
1409
+ // it means anything at file level — Kotlin's rule for a companion
1410
+ // member, which is in scope unqualified only inside its own class body.
1411
+ const rawPath = foldKotlinOperands(fileKey, operands, index.repo, kotlinEnclosingTypeNames(methodNode), index);
1412
+ if (rawPath === null)
1413
+ continue;
1414
+ methodRoutes.push({
1415
+ httpMethod,
1416
+ rawPath,
1417
+ nameNode: match.captures.method_name,
1418
+ methodNode,
1419
+ });
1420
+ }
1421
+ for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
941
1422
  const enclosingClass = findEnclosingClass(methodNode);
942
1423
  // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
943
1424
  // consumer (a remote call), not a route this service serves.
944
1425
  if (enclosingClass && feignClassIds.has(enclosingClass.id)) {
1426
+ // Whichever prefix GOVERNS must be resolvable, or the remote URL is
1427
+ // unknowable and an unprefixed consumer would be a call this service
1428
+ // never makes. Checked in the same "path wins" order the fallback
1429
+ // below resolves in, so an unresolvable `@RequestMapping` does not
1430
+ // suppress a client whose literal `@FeignClient(path)` outranks it,
1431
+ // and an unresolvable `path` is fatal even when `@RequestMapping` is
1432
+ // a literal.
1433
+ //
1434
+ // This reaches a Feign INTERFACE at all because tree-sitter-kotlin
1435
+ // models `interface` as a `class_declaration`, and it should: Spring
1436
+ // Cloud prepends the governing prefix to every method of the client.
1437
+ // Java diverges only by accident of its grammar — `findEnclosingClass`
1438
+ // skips `interface_declaration`, so `java.ts` still emits such a
1439
+ // consumer at its unprefixed path. Aligning Java is a change to Java's
1440
+ // behavior and belongs in its own follow-up, not in the Kotlin binding.
1441
+ if (feignClassesWithUnfoldablePath.has(enclosingClass.id))
1442
+ continue;
1443
+ const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
1444
+ if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id))
1445
+ continue;
945
1446
  // @FeignClient(path) wins over @RequestMapping; a multi-element prefix
946
1447
  // yields one consumer per (prefix × this route).
947
- const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
948
- prefixByClassId.get(enclosingClass.id) ?? [''];
1448
+ const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
949
1449
  for (const prefix of prefixes) {
950
1450
  out.push({
951
1451
  role: 'consumer',
@@ -959,6 +1459,11 @@ function buildKotlinPlugin(language) {
959
1459
  }
960
1460
  continue;
961
1461
  }
1462
+ // An unresolvable class prefix leaves no path this service serves, so
1463
+ // every route under such a class is dropped rather than emitted at a
1464
+ // wrong (unprefixed) one — the rule `java.ts` applies for Java.
1465
+ if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id))
1466
+ continue;
962
1467
  // A @(Get|...)Mapping on a (non-Feign) interface declares a route
963
1468
  // *contract*, not a route this service serves — the implementing
964
1469
  // @RestController is the provider, emitted via scanProject's interface
@@ -1142,13 +1647,23 @@ function buildKotlinPlugin(language) {
1142
1647
  const enclosingClass = findEnclosingClass(methodNode);
1143
1648
  if (!enclosingClass || !isKotlinInterface(enclosingClass))
1144
1649
  continue;
1650
+ // The same governing-prefix resolvability guard the @(Get|...)Mapping-in-Feign
1651
+ // lane applies, in the same "path wins" order — this loop resolves through
1652
+ // the identical fallback chain, so an unresolvable governing prefix leaves
1653
+ // the remote URL just as unknowable here. Without it a single interface
1654
+ // could suppress its @(Get|...)Mapping routes and publish its @RequestLine
1655
+ // routes under the very same unresolvable prefix.
1656
+ if (feignClassesWithUnfoldablePath.has(enclosingClass.id))
1657
+ continue;
1658
+ const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
1659
+ if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id))
1660
+ continue;
1145
1661
  // Mirror java.ts (which pre-merges the @RequestMapping fallback into
1146
1662
  // feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else
1147
1663
  // the interface's class-level @RequestMapping prefix, else none. Without
1148
1664
  // the prefixByClassId fallback Kotlin dropped the class prefix that Java
1149
1665
  // applies — the same fallback chain the @GetMapping-in-Feign path uses above.
1150
- const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
1151
- prefixByClassId.get(enclosingClass.id) ?? [''];
1666
+ const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
1152
1667
  for (const prefix of prefixes) {
1153
1668
  out.push({
1154
1669
  role: 'consumer',