Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 106 additions & 11 deletions gitnexus/src/core/group/extractors/http-patterns/kotlin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
158 changes: 136 additions & 22 deletions gitnexus/test/unit/group/http-route-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2028,28 +2029,143 @@ 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(
'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)',
async () => {
// 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', '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 NoDoubleClient(private val webClient: WebClient) {
suspend fun run() {
webClient.method(HttpMethod.GET).uri("/api/single").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('NoDoubleClient.kt'),
);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
},
);

itKotlinConsumer(
'does NOT match Kotlin WebClient long form (deferred to follow-up)',
'does NOT match Kotlin WebClient long form with variable-bound verb',
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');
// 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', 'LegacyClient.kt'),
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 LegacyClient(private val webClient: WebClient) {
class VariableVerbClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody<String>()
val verb = HttpMethod.PATCH
val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
}
}
`,
Expand All @@ -2058,12 +2174,10 @@ 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('VariableVerbClient.kt'),
);
expect(fromLegacy).toHaveLength(0);
expect(fromThisFile).toHaveLength(0);
},
);

Expand Down
Loading