mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into feat/multi-cli-devcontainer
This commit is contained in:
commit
d99149642f
2 changed files with 242 additions and 33 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue