diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 0bafb7a7e6..0e56b554b6 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -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 = { 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>); + // ─── 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() + // + // 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>); + // ─── 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; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 297501a833..58db213e9c 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -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,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() + } +} +`, + ); + + 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() + webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody() + webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity() + webClient.method(HttpMethod.PATCH).uri("/api/orders/3").retrieve().awaitBody() + } +} +`, + ); + + 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() + } +} +`, + ); + + 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() + val verb = HttpMethod.PATCH + val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody() } } `, @@ -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); }, );