Merge branch 'main' into main

This commit is contained in:
Gergő Magyar 2026-05-27 21:33:12 +01:00 • committed by GitHub
commit ca91739d86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 492 additions and 9 deletions

View file

@ -9,18 +9,26 @@ import {
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Kotlin HTTP plugin (Spring providers).
* Kotlin HTTP plugin (Spring providers + consumers).
*
* Mirrors the Java plugin for Spring `@RequestMapping` class prefixes
* and `@(Get|Post|...)Mapping` method annotations on Kotlin Spring
* Boot controllers. Both positional shorthand (`@GetMapping("/x")`)
* and named annotation arguments (`@GetMapping(value = "/x")` and
* **Providers** (#1849) — Spring `@RequestMapping` class prefixes and
* `@(Get|Post|...)Mapping` method annotations on Kotlin Spring Boot
* controllers. Both positional shorthand (`@GetMapping("/x")`) and
* named annotation arguments (`@GetMapping(value = "/x")` and
* `@GetMapping(path = "/x")`) are supported.
*
* Consumer detection (RestTemplate / WebClient / OkHttp) is intentionally
* out of scope for this plugin — Kotlin call-site ASTs are sufficiently
* different from Java's `method_invocation` shape that they warrant a
* separate, focused follow-up.
* **Consumers** (this PR) — three call-site patterns common in Kotlin
* Spring projects:
*
* 1. `restTemplate.getForObject("/x", ...)` and friends
* 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop)
* 3. `Request.Builder().url("/x")` (OkHttp)
*
* The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is
* intentionally deferred to a follow-up: it requires walk-up logic
* to recover the verb from a sibling `call_expression`, and we can
* land 80% of real-world Kotlin Spring consumer coverage with the
* three simpler patterns above.
*
* tree-sitter-kotlin (fwcd) AST shapes used here:
* class_declaration
@ -34,6 +42,20 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* string_literal
* type_identifier ← class name
*
* Consumer call shape (Kotlin chains everything via `navigation_expression`):
* call_expression ← outer `.uri("/x")` or `.url("/x")`
* navigation_expression
* call_expression ← inner `.get()` / `Request.Builder()` / `restTemplate.x`
* navigation_expression
* simple_identifier ← receiver: `webClient` / `Request` / `restTemplate`
* navigation_suffix ← `.method` / `.Builder` / `.getForObject`
* call_suffix (value_arguments)
* navigation_suffix ← `.uri` / `.url`
* call_suffix
* value_arguments
* value_argument
* string_literal ← the path
*
* tree-sitter-kotlin is an optional npm dependency — when its native
* binding is unavailable the plugin gracefully exports `null` and
* `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
@ -57,6 +79,36 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
PatchMapping: 'PATCH',
};
/**
* RestTemplate method-name → HTTP verb. Mirrors the Java plugin's
* `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the
* same contract IDs from .java and .kt sources.
*/
const REST_TEMPLATE_TO_HTTP: Record<string, string> = {
getForObject: 'GET',
getForEntity: 'GET',
postForObject: 'POST',
postForEntity: 'POST',
put: 'PUT',
delete: 'DELETE',
patchForObject: 'PATCH',
};
/**
* WebClient short-form verb → HTTP verb. The reactive WebClient API
* exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as
* one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)`
* carries the path. We capture both pieces in a single query (see
* `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here.
*/
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
get: 'GET',
post: 'POST',
put: 'PUT',
delete: 'DELETE',
patch: 'PATCH',
};
/**
* Build the plugin only if the Kotlin grammar is available. Compiling
* the queries against a null grammar would throw at module load time
@ -157,6 +209,130 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: Spring RestTemplate ────────────────────────────────────
// Kotlin call-site shape mirrors the Java plugin's
// `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
// `navigation_expression` instead of Java's `method_invocation`:
//
// restTemplate.getForObject("/x", User::class.java)
//
// becomes
//
// call_expression
// navigation_expression
// simple_identifier "restTemplate"
// navigation_suffix → simple_identifier "getForObject"
// call_suffix
// value_arguments
// value_argument . string_literal "/x" ← captured
// value_argument User::class.java
//
// The receiver name is constrained to `restTemplate` (#eq? @obj),
// matching the Java plugin's heuristic. This means a non-conventional
// field name (e.g. `userServiceTemplate`) will not be picked up;
// that's the same trade-off already accepted on the Java side.
const REST_TEMPLATE_PATTERNS = compilePatterns({
name: 'kotlin-rest-template',
language,
patterns: [
{
meta: {},
query: `
(call_expression
(navigation_expression
(simple_identifier) @obj (#eq? @obj "restTemplate")
(navigation_suffix (simple_identifier) @method))
(call_suffix
(value_arguments . (value_argument . (string_literal) @path))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: Spring WebClient (short form) ──────────────────────────
// Reactive WebClient exposes one-liner verb helpers:
//
// webClient.get().uri("/x").retrieve().awaitBody<T>()
// webClient.post().uri("/x")...
//
// The chain `webClient.get().uri("/x")` parses as two nested
// `call_expression` nodes — the OUTER call is `.uri("/x")` and the
// INNER call is `webClient.get()`. We anchor on the outer call and
// require:
// - inner receiver is `webClient`
// - inner suffix is one of the HTTP verbs (#match?)
// - outer suffix is exactly `uri`
// - outer call's first value_argument is a string literal
//
// The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain
// uses an extra navigation hop and an enum field access — it's
// intentionally out of scope here (see file header).
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
name: 'kotlin-web-client-short',
language,
patterns: [
{
meta: {},
query: `
(call_expression
(navigation_expression
(call_expression
(navigation_expression
(simple_identifier) @obj (#eq? @obj "webClient")
(navigation_suffix
(simple_identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$")))
(call_suffix (value_arguments)))
(navigation_suffix (simple_identifier) @uri (#eq? @uri "uri")))
(call_suffix
(value_arguments . (value_argument . (string_literal) @path))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
// Kotlin parses `Request.Builder()` as a `call_expression` whose
// callee is a `navigation_expression` (Request → .Builder), NOT as
// Java's `object_creation_expression`. The chain `.url("/x")` then
// wraps that in another `call_expression`. The query mirrors Java's
// `OK_HTTP_PATTERNS` (java.ts) but adapts the node types.
//
// Receiver `Request` is constrained by name (#eq? @cls); a project
// that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`)
// would not be picked up — this matches the Java plugin's heuristic.
//
// **Known limitation — verb defaults to GET.** OkHttp encodes the
// verb on a *sibling* call further down the builder chain (e.g.
// `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself.
// This query intentionally does not walk the chain to recover the
// verb — it emits `method: 'GET'` for every match, mirroring
// `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()`
// call becomes `http::GET::/x`, not `http::POST::/x`. This is the
// same trade-off Java has accepted; pinned by an anti-overreach
// test in `http-route-extractor.test.ts` so a future verb-walk
// implementation has to update this comment in lockstep.
const OK_HTTP_PATTERNS = compilePatterns({
name: 'kotlin-okhttp',
language,
patterns: [
{
meta: {},
query: `
(call_expression
(navigation_expression
(call_expression
(navigation_expression
(simple_identifier) @cls (#eq? @cls "Request")
(navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder")))
(call_suffix (value_arguments)))
(navigation_suffix (simple_identifier) @method (#eq? @method "url")))
(call_suffix
(value_arguments . (value_argument . (string_literal) @path))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Find the nearest enclosing class_declaration ancestor for a node, or
* null if the node is top-level. Mirrors the Java plugin's helper.
@ -223,6 +399,60 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
});
}
// ─── Consumers: RestTemplate ────────────────────────────────────
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-rest-template',
method: httpMethod,
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: WebClient short form (.get()/.post()/etc → .uri) ─
for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_PATTERNS, tree)) {
const verbNode = match.captures.verb;
const pathNode = match.captures.path;
if (!verbNode || !pathNode) continue;
const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
method: httpMethod,
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const pathNode = match.captures.path;
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'okhttp',
method: 'GET',
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -1397,6 +1397,259 @@ class ApiClient {
).toBeDefined();
});
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──
// Same shape as the Java consumer test above, but parsed by the
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three
// consumer flavors covered here (long-form WebClient
// `webClient.method(HttpMethod.X).uri(...)` is intentionally
// deferred to a follow-up — see kotlin.ts file header).
//
// tree-sitter-kotlin is an optionalDependency. If the binding is
// unavailable, `getPluginForFile` returns undefined for `.kt` and
// we skip the suite (matches the gating on the Provider tests).
const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined;
const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip;
itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => {
const dir = path.join(tmpDir, 'kotlin-rest-template');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ApiClient.kt'),
`package com.example
import org.springframework.web.client.RestTemplate
class ApiClient(private val restTemplate: RestTemplate) {
fun run() {
restTemplate.getForObject("/api/users/1", User::class.java)
restTemplate.getForEntity("/api/users/2", User::class.java)
restTemplate.postForObject("/api/users", body, User::class.java)
restTemplate.postForEntity("/api/users", body, User::class.java)
restTemplate.put("/api/users/3", body)
restTemplate.delete("/api/users/4")
restTemplate.patchForObject("/api/users/5", body, User::class.java)
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
).toBeDefined();
// Framework label must be the same `spring-rest-template` used
// by the Java plugin so polyglot repos coalesce on a single key.
const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template');
expect(restConsumers.length).toBeGreaterThanOrEqual(5);
});
itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-short');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.kt'),
`package com.example
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity
class OrderClient(private val webClient: WebClient) {
suspend fun run() {
val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody<Order>()
val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody<Order>()
val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody<Order>()
val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity()
val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody<Order>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
expect(wcConsumers.length).toBeGreaterThanOrEqual(5);
});
itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => {
const dir = path.join(tmpDir, 'kotlin-okhttp');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkClient.kt'),
`package com.example
import okhttp3.OkHttpClient
import okhttp3.Request
class OkClient(private val client: OkHttpClient) {
fun fetch() {
val req = Request.Builder().url("/api/items").build()
val resp = client.newCall(req).execute()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items');
expect(okConsumer).toBeDefined();
expect(okConsumer!.meta.framework).toBe('okhttp');
});
itKotlinConsumer(
'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)',
async () => {
// Anti-overreach / known-limitation pin: OkHttp encodes the
// HTTP verb on a sibling call (`.post(body)` / `.delete()` /
// ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS`
// intentionally does not walk the chain to recover the verb —
// it emits `method: 'GET'` for every match, mirroring the Java
// plugin's `OK_HTTP_PATTERNS` (java.ts).
//
// This test pins the accepted behavior so a future verb-walk
// implementation must update kotlin.ts's known-limitation
// comment in lockstep. Concretely:
// - `Request.Builder().url("/api/users").post(body).build()`
// → ONE consumer: `http::GET::/api/users` (heuristic-default)
// → NO `http::POST::/api/users` consumer
//
// Test signal:
// - if this becomes correct (POST detected) without updating
// the kotlin.ts comment + java.ts behavior together, this
// test goes red and the reviewer must reconcile both sides.
const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OkPostClient.kt'),
`package com.example
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) {
fun create() {
val req = Request.Builder().url("/api/users").post(body).build()
client.newCall(req).execute()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('OkPostClient.kt'),
);
// Heuristic-default GET: exactly one consumer is emitted for
// the .url("/x") capture, with method=GET regardless of the
// sibling .post(body) call.
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/api/users');
expect(fromThisFile[0].meta.method).toBe('GET');
// Anti-overreach: no second contract with POST should appear.
// If a future verb-walk lands and this assertion needs to flip
// (i.e. POST is now detected), bump kotlin.ts's known-limitation
// comment and java.ts in the same PR.
expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined();
},
);
itKotlinConsumer(
'does NOT match Kotlin WebClient long form (deferred to follow-up)',
async () => {
// Anti-overreach: confirm the short-form query does NOT
// accidentally fire on the long-form chain
// `webClient.method(HttpMethod.GET).uri(...)`. The long form
// is intentionally unsupported in this PR; if a future change
// to the short-form query starts capturing it we want a loud
// signal here. Long-form support will arrive in a follow-up
// with a dedicated query + verb walk-up helper.
const dir = path.join(tmpDir, 'kotlin-web-client-long');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LegacyClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class LegacyClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody<String>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// No consumer should be emitted from this file by the
// current short-form query. Documented as a known limitation.
const fromLegacy = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('LegacyClient.kt'),
);
expect(fromLegacy).toHaveLength(0);
},
);
itKotlinConsumer(
'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver',
async () => {
// Anti-regression: the RestTemplate receiver constraint
// (#eq? @obj "restTemplate") must hold. A field with a
// different conventional name (e.g. `cacheClient`) calling
// `.getForObject("/x", ...)` should NOT produce a route.
const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'CacheClient.kt'),
`package com.example
class CacheClient(private val cacheClient: SomeCache) {
fun run() {
cacheClient.getForObject("/cache/key", String::class.java)
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::GET::/cache/key')).toBeUndefined();
const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt'));
expect(fromCache).toHaveLength(0);
},
);
it('extracts Go stdlib and resty calls', async () => {
const dir = path.join(tmpDir, 'go-consumer');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });