Merge branch 'main' into feat/multi-cli-devcontainer

This commit is contained in:
Gergő Magyar 2026-05-29 09:42:56 +01:00 • committed by GitHub
commit d99149642f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 242 additions and 33 deletions

View file

@ -17,18 +17,22 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* named annotation arguments (`@GetMapping(value = "/x")` and
* `@GetMapping(path = "/x")`) are supported.
*
* **Consumers** (this PR) — three call-site patterns common in Kotlin
* **Consumers** — four 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)
* 1. `restTemplate.getForObject("/x", ...)` and friends (#1855)
* 2. `webClient.get().uri("/x")` — short form (#1855)
* 3. `Request.Builder().url("/x")` — OkHttp (#1855)
* 4. `webClient.method(HttpMethod.X).uri("/y")` — long form (this PR)
*
* 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.
* The long form puts the verb on a sibling `call_expression` two hops
* away from the path. Rather than introducing imperative walk-up logic,
* we use a single deeper tree-sitter query that matches the full chain
* structurally — see `WEB_CLIENT_LONG_PATTERNS` below. The verb is
* captured directly as the `simple_identifier` of `HttpMethod.X`, so
* variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
* are intentionally NOT picked up — those need a graph-aware resolver
* and are out of scope for source-scan.
*
* tree-sitter-kotlin (fwcd) AST shapes used here:
* class_declaration
@ -109,6 +113,16 @@ const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
patch: 'PATCH',
};
/**
* Allowed HTTP verbs for the WebClient long-form path
* `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module
* load (instead of inside the scan loop) per maintainer feedback on
* PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above —
* keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry
* with the short form and the Java plugin.
*/
const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/;
/**
* Build the plugin only if the Kotlin grammar is available. Compiling
* the queries against a null grammar would throw at module load time
@ -265,8 +279,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
// - 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).
// uses an extra navigation hop and an enum field access — handled
// by `WEB_CLIENT_LONG_PATTERNS` below, separately so each query is
// straightforward to reason about.
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
name: 'kotlin-web-client-short',
language,
@ -290,6 +305,59 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: Spring WebClient (long form) ───────────────────────────
// The fluent long form passes the verb as a `HttpMethod.X` enum field
// access through `.method(...)`, then carries the path on a separate
// `.uri(...)` hop further down the chain:
//
// webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody<T>()
//
// Compared to the short form there are two extra structural hops:
// - the inner `.method(...)` `call_expression` has a `value_argument`
// whose payload is itself a `navigation_expression` (HttpMethod → .GET)
// - the outer `.uri(...)` is reached via one more
// `navigation_expression` wrapping that inner call
//
// We capture the verb at the `simple_identifier` under `HttpMethod`'s
// `navigation_suffix`. That `simple_identifier` is the literal field
// name (`GET`, `POST`, ...) used in source — Kotlin enum fields by
// convention are upper-case, matching `HttpMethod` from
// `org.springframework.http`. We forward the captured text as-is.
//
// Variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
// do NOT match — they fail the `(navigation_expression ...)` shape
// because the value_argument carries a bare `simple_identifier` instead
// of a `HttpMethod.X` field access. This is intentional: source-scan
// can't follow the binding without graph context. Pinned by an
// anti-overreach test in the consumer suite.
const WEB_CLIENT_LONG_PATTERNS = compilePatterns({
name: 'kotlin-web-client-long',
language,
patterns: [
{
meta: {},
query: `
(call_expression
(navigation_expression
(call_expression
(navigation_expression
(simple_identifier) @obj (#eq? @obj "webClient")
(navigation_suffix
(simple_identifier) @method_call (#eq? @method_call "method")))
(call_suffix
(value_arguments
. (value_argument
(navigation_expression
(simple_identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
(navigation_suffix (simple_identifier) @verb))))))
(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
@ -437,6 +505,33 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
});
}
// ─── Consumers: WebClient long form (.method(HttpMethod.X) → .uri) ─
for (const match of runCompiledPatterns(WEB_CLIENT_LONG_PATTERNS, tree)) {
const verbNode = match.captures.verb;
const pathNode = match.captures.path;
if (!verbNode || !pathNode) continue;
// The captured text is the literal `HttpMethod.X` field name.
// Spring's `org.springframework.http.HttpMethod` defines GET,
// POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE — we only
// emit for the five verbs we already handle elsewhere, so
// exotic ones are silently skipped (consistent with the
// short form's WEB_CLIENT_SHORT_TO_HTTP guard). The accepted
// verb regex is hoisted to module scope (see
// `WEB_CLIENT_LONG_VERB_RE` near the top of this file).
const verbText = verbNode.text;
if (!WEB_CLIENT_LONG_VERB_RE.test(verbText)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
method: verbText,
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;

View file

@ -1844,12 +1844,13 @@ class HttpClients {
).toBeDefined();
});
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──
// ─── Kotlin consumers (RestTemplate / WebClient short+long / 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 grammar via `KOTLIN_HTTP_PLUGIN`. Four
// consumer flavors covered here: RestTemplate (#1855), WebClient
// short form (#1855), OkHttp (#1855), and WebClient long form
// (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) —
// see kotlin.ts file header for the full list.
//
// tree-sitter-kotlin is an optionalDependency. If the binding is
// unavailable, `getPluginForFile` returns undefined for `.kt` and
@ -2028,28 +2029,104 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo
},
);
itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-get');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongGetClient.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 LongGetClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody<User>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const route = consumers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
expect(route!.meta.framework).toBe('spring-web-client');
});
itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongVerbClient.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
import org.springframework.web.reactive.function.client.awaitBodilessEntity
class LongVerbClient(private val webClient: WebClient) {
suspend fun run() {
webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity()
webClient.method(HttpMethod.PATCH).uri("/api/orders/3").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::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();
// All four should be tagged as `spring-web-client` so polyglot
// repos coalesce on the same framework key as the short form.
// The fixture is fully deterministic — exactly 4 long-form calls,
// no short-form / RestTemplate / OkHttp calls mixed in — so an
// exact count is meaningful (DoD §2.7). If a future change
// accidentally emits a 5th consumer (e.g. duplicate query firing,
// or a regressed receiver constraint matching unrelated calls),
// this assertion catches it.
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
expect(wcConsumers).toHaveLength(4);
});
itKotlinConsumer(
'does NOT match Kotlin WebClient long form (deferred to follow-up)',
'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)',
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');
// The long-form query handles `webClient.method(HttpMethod.X).uri(...)`,
// and the short-form query handles `webClient.get().uri(...)`. Both
// queries carry sibling `(navigation_suffix (simple_identifier) @verb)`
// constraints — short form requires the verb name itself
// (`get`/`post`/...), long form requires the literal name
// `method`. The two are disjoint.
//
// This test pins that disjointness: a single `.method(HttpMethod.GET)`
// call must emit ONE consumer, not two (one from each query).
const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LegacyClient.kt'),
path.join(dir, 'src', 'NoDoubleClient.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) {
class NoDoubleClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody<String>()
webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody<String>()
}
}
`,
@ -2058,12 +2135,49 @@ class LegacyClient(private val webClient: WebClient) {
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'),
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('NoDoubleClient.kt'),
);
expect(fromLegacy).toHaveLength(0);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
},
);
itKotlinConsumer(
'does NOT match Kotlin WebClient long form with variable-bound verb',
async () => {
// Anti-overreach: source-scan can't follow `val verb = HttpMethod.X`
// back to the literal — that's a graph-aware concern. The long-form
// query requires `(navigation_expression HttpMethod . verb)` as the
// `value_argument` shape, so a bare `simple_identifier` (the
// variable name) fails to match. Pin this so a future relaxation
// of the value_argument shape cannot silently start guessing the
// verb from arbitrary identifiers.
const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'VariableVerbClient.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 VariableVerbClient(private val webClient: WebClient) {
suspend fun run() {
val verb = HttpMethod.PATCH
val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
}
}
`,
);
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('VariableVerbClient.kt'),
);
expect(fromThisFile).toHaveLength(0);
},
);