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.
- package/dist/core/group/extractors/http-patterns/kotlin.js +522 -7
- package/dist/core/group/extractors/http-patterns/php.js +279 -11
- package/dist/core/index-freshness.d.ts +2 -2
- package/dist/core/index-freshness.js +13 -0
- package/dist/core/ingestion/parsing-processor.d.ts +2 -0
- package/dist/core/ingestion/parsing-processor.js +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +7 -0
- package/dist/core/ingestion/pipeline-phases/parse.d.ts +4 -0
- package/dist/core/ingestion/pipeline.js +4 -1
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +377 -0
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +1203 -0
- package/dist/core/ingestion/scope-extractor-bridge.js +8 -2
- package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +14 -2
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +11 -1
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.d.ts +14 -0
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.js +35 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +6 -0
- package/dist/core/ingestion/workers/parse-worker.js +7 -1
- package/dist/core/ingestion/workers/result-merge.js +4 -1
- package/dist/core/run-analyze.js +6 -0
- package/dist/mcp/local/local-backend.d.ts +2 -0
- package/dist/mcp/local/local-backend.js +29 -0
- package/dist/mcp/tools.js +6 -4
- package/dist/storage/parse-cache.js +6 -6
- package/dist/storage/repo-meta.d.ts +17 -1
- package/dist/storage/repo-meta.js +1 -1
- package/dist/types/pipeline.d.ts +4 -0
- 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
|
-
|
|
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 =
|
|
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 =
|
|
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',
|