mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
* feat(group): extract RestTemplate URI.create(...) static paths (Java)
Widen the RestTemplate query @path captures to (_) and resolve
URI.create("/x") arguments via a new extractStaticPathExpression
helper. Variable-bound paths stay unresolved (no consumer).
* feat(group): resolve RestTemplate UriComponentsBuilder chains (Java)
Add appendPath + recursive extractUriComponentsBuilderPath (fromPath/
fromUriString/fromHttpUrl seeds, path/pathSegment append, build/query
passthrough). Host-bearing seeds are normalized downstream. Non-literal
segments stay unresolved.
* feat(group): infer OkHttp request verb from builder chain (Java)
Walk up from the matched .url(...) call to the sibling verb helper
(.post()/.method("X")), defaulting to GET. Variable-bound verbs stay
GET. Re-document the Kotlin OkHttp GET-default pin as an accepted
Java/Kotlin asymmetry (Kotlin verb-walk is a tracked follow-up).
* feat(group): Java HttpClient HEAD, .method("X"), and default-GET
Add HEAD to the verb-helper regex and two dedicated pattern families:
.method("VERB", body) (covers PATCH) and a bare .build() defaulting to
GET. The three families terminate at distinct calls, so each chain
matches exactly one (no double-emit); variable-bound verbs stay unresolved.
* feat(group): extract fully-qualified Java route annotations
Widen JAVA_ROUTE_ANNOTATION_PATTERNS @ann to match scoped_identifier
(predicate-free node-type change), normalizing to the trailing segment
via simpleName in the scan loop. Snapshot-verified: only previously
unmatched FQN annotations gain routes; existing contracts unchanged.
Brings Java to FQN parity with the Kotlin plugin (closes #2254 limitation).
* refactor(group): reuse simpleName helper in hasAnnotation
Drop the inline split('.').pop() (which shadowed the new module-level
simpleName) and call the helper. Note appendPath's deliberate divergence
from the shared joinPath so it is not accidentally unified.
* docs(test): fix reversed Java/Kotlin OkHttp asymmetry comment
The comment stated .kt->POST / .java->GET; it is the opposite — Java
infers the verb (inferOkHttpMethod) so .java emits POST, while Kotlin
still defaults so .kt emits GET. Aligns the prose with the assertion
below it.
* docs(group): correct stale kotlin.ts OkHttp parity comment
The comment claimed Kotlin's GET-default 'mirrors java.ts:OK_HTTP_PATTERNS'
and is 'the same trade-off Java has accepted'. The Java plugin now infers
the verb (inferOkHttpMethod), so this is now a documented Java/Kotlin
asymmetry; the Kotlin verb-walk is the tracked follow-up.
* test(group): pin OkHttp default-GET branch on a distinct path
The bare `.build()` (no verb call) now uses /api/bare-build and is
asserted individually, so the default-GET branch of inferOkHttpMethod
is no longer masked by the explicit .get() case collapsing into the
same {param} slot.
* fix(group): skip OkHttp emission for variable-bound .method(verb)
inferOkHttpMethod now returns string|null: an explicit .method(verb, …)
with a non-literal verb returns null and the loop skips it, instead of
asserting a wrong GET contract. A bare .url().build() with no verb call
still defaults to GET (OkHttp's real default). Matches WebClient
long-form, which also skips variable-bound verbs.
* fix(group): strip query from UriComponentsBuilder seed literal
A query baked into the seed (fromUriString("/base?x=1")) was returned
verbatim, so a later .path("/sub") glued onto it (/base?x=1/sub) and
normalizeHttpPath truncated the tail at ? to /base. Strip ?query at the
seed so .path() appends to a clean base → /base/sub. Host prefixes are
preserved and stripped downstream by normalizeConsumerPath.
* fix(group): add recursion depth guard to extractUriComponentsBuilderPath
The recursive builder-chain walk was unbounded; a pathological or
machine-generated chain could overflow the stack. Cap recursion at
MAX_BUILDER_DEPTH (100) and return null past it — consistent with the
project's other AST-depth guards.
* docs(group): document accepted FQN simple-name collision trade-off
The route discriminator matches on the trailing annotation segment, so a
non-Spring annotation sharing a route name (@com.evil.GetMapping) is
treated as a route — the same trade-off hasAnnotation makes and the
intended Kotlin parity. Note why package-origin gating is deliberately
not added.
* refactor(group): extract static-path helpers to java-static-path.ts
Move the URI.create / UriComponentsBuilder resolution helpers
(methodInvocation*, firstLiteralArgument, appendPath, extractUri*,
extractStaticPathExpression) out of java.ts (back under ~1000 lines).
java.ts imports the four it consumes; inferOkHttpMethod stays. Pure
move, behavior-preserving — full group suite unchanged.
* fix(group): walk builder chain for Java HttpClient verb (#2268)
Replace the three rigid JAVA_HTTP_CLIENT_* pattern families with one
.uri()-anchored query plus inferHttpClientMethod, which walks up the
fluent chain for the verb (mirroring inferOkHttpMethod). The walk is
transparent to intervening .header()/.timeout()/.version() calls, so a
header/timeout hop before the terminal no longer silently drops the
consumer contract.
Relocate both verb-walks onto a shared inferBuilderVerb in
java-static-path.ts and de-export the now-internal methodInvocation*
primitives; java.ts drops 1015 -> 910 lines.
* fix(group): append UriComponentsBuilder .path() verbatim (#2268)
Spring's UriComponentsBuilder.path(p) appends p as-is without inserting
a slash (then collapses duplicate slashes), unlike .pathSegment() which
slash-joins. The resolver used the always-one-slash appendPath for both,
so fromPath("/api").path("users") resolved to /api/users instead of
Spring's /apiusers. Switch the .path() branch to verbatim append plus a
colon-aware duplicate-slash collapse (preserving a host seed's ://);
.pathSegment() keeps appendPath.
* fix(group): skip empty-string verb literal in builder verb-walk (#2268)
`.method("", body)` produced a malformed `http::::/path` consumer:
unquoteLiteral('""') returns "" (not null), so the `=== null` guard
let an empty method through. Treat a falsy literal verb as unresolvable
(return null from the shared inferBuilderVerb) and switch the OkHttp and
HttpClient emission guards to falsiness, so an empty verb skips like a
variable-bound one.
* test(group): harden Java HTTP consumer coverage (#2268)
Add coverage beyond the tri-review findings: a count guard on the
UriComponentsBuilder query-seed test (so a double-emit can't slip past
the two find assertions), an exchange()+UriComponentsBuilder end-to-end
case (the widened (_) @path exchange capture was only covered with
URI.create), and an HttpClient .method("REPORT") custom-verb
pass-through pin.
* docs(group): document pre-path builder rigidity + fix stale refs (#2268)
Document the OkHttp pre-.url() limitation (a builder call before .url()
is missed) at OK_HTTP_PATTERNS, cross-referencing the Java-HttpClient
pre-.uri() dual the verb-walk rewrite leaves in place — so neither
comment overclaims that the chain is walked before the path call. Update
the now-stale 'inferOkHttpMethod in java.ts' references in kotlin.ts and
the test to point at java-static-path.ts after the relocation.
* feat(group): match Java HTTP consumer chains with a pre-path builder call (#2268)
The OkHttp .url() and HttpClient .uri() queries required the path call to
sit directly on the construction, so a builder call BEFORE it —
new Request.Builder().addHeader(...).url(...) or
HttpRequest.newBuilder().version(v).uri(...) — silently dropped the
consumer contract. Match the path call on any receiver and re-impose the
framework anchor in JS (okHttpUrlRootsAtBuilder / httpClientUriRootsAtNewBuilder:
the chain must root at new Request.Builder() / HttpRequest.newBuilder()), so a
preceding call is captured while an unrelated .url()/.uri() is rejected. The
verb-walk now scans the whole chain, so a verb set before the path call also
resolves. Also extract the HttpRequest.newBuilder(URI.create(...)) constructor-arg
form (skipped when a later .uri() overrides it). Resolves the deferred
follow-ups from the round-2 tri-review.
* feat(group): Kotlin OkHttp verb-walk parity with Java (#2268)
The Kotlin OkHttp consumer always emitted GET while the Java side walks
the builder chain to recover the verb — a documented Java/Kotlin
asymmetry. Mirror the verb-walk into kotlin.ts, adapted to the
tree-sitter-kotlin call_expression/navigation_expression grammar: match
.url("literal") on any receiver, gate to chains rooting at
Request.Builder() (kotlinUrlRootsAtRequestBuilder), and scan the whole
chain for the verb (inferKotlinOkHttpMethod — last-wins, null-skip for a
variable/empty .method(verb), resolves a named-argument .method(method="X")).
This brings .kt to full parity with .java — verb inference, a builder
call before .url(), and verb-before-url — pinned by two new Java<->Kotlin
parity-harness rows. Flips the former GET-default asymmetry test.
This commit is contained in:
parent
65e27fcd78
commit
da4a0c91c9
4 changed files with 1346 additions and 120 deletions
|
|
@ -0,0 +1,269 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import { unquoteLiteral } from '../tree-sitter-scanner.js';
|
||||
|
||||
// ─── Statically-resolvable consumer path + builder verb-walk helpers ──────
|
||||
// RestTemplate calls increasingly pass a non-literal path argument that is
|
||||
// still statically derivable — `URI.create("/x")` or a `UriComponentsBuilder`
|
||||
// fluent chain. These helpers resolve those shapes to a literal path; a
|
||||
// genuinely dynamic argument (a variable, a non-`URI`/`UriComponentsBuilder`
|
||||
// call) resolves to null and the call site is skipped. This module also owns
|
||||
// the OkHttp / Java-HttpClient builder verb-walks (`inferOkHttpMethod` /
|
||||
// `inferHttpClientMethod`), which recover the request verb by walking UP the
|
||||
// fluent chain from the matched `.url(...)` / `.uri(...)` call. Extracted from
|
||||
// java.ts (#2268) so that plugin stays under ~1000 lines; the `methodInvocation*`
|
||||
// primitives + `firstLiteralArgument` are module-internal (shared by the path
|
||||
// resolvers and the verb-walks), while the path resolver and the two verb-walks
|
||||
// are java.ts's only entry points here.
|
||||
|
||||
function methodInvocationName(node: Parser.SyntaxNode): string | null {
|
||||
return node.type === 'method_invocation' ? (node.childForFieldName('name')?.text ?? null) : null;
|
||||
}
|
||||
|
||||
function methodInvocationObject(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
return node.type === 'method_invocation' ? node.childForFieldName('object') : null;
|
||||
}
|
||||
|
||||
function methodInvocationArguments(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
const argsNode = node.type === 'method_invocation' ? node.childForFieldName('arguments') : null;
|
||||
return argsNode?.namedChildren ?? [];
|
||||
}
|
||||
|
||||
function firstLiteralArgument(node: Parser.SyntaxNode): string | null {
|
||||
const first = methodInvocationArguments(node)[0];
|
||||
return first?.type === 'string_literal' ? unquoteLiteral(first.text) : null;
|
||||
}
|
||||
|
||||
/** Resolve a `URI.create("/path")` call to its literal path; null otherwise. */
|
||||
function extractUriCreatePath(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type !== 'method_invocation') return null;
|
||||
if (methodInvocationObject(node)?.text !== 'URI' || methodInvocationName(node) !== 'create')
|
||||
return null;
|
||||
return firstLiteralArgument(node);
|
||||
}
|
||||
|
||||
// Join a builder base with a sub-path using exactly one separating slash. This
|
||||
// is intentionally NOT the shared `joinPath`: `joinPath` force-prepends `/`,
|
||||
// whereas `appendPath` must preserve an absolute/host base (`fromHttpUrl`
|
||||
// "https://host/api") so the host survives until `normalizeConsumerPath` strips
|
||||
// it downstream. Do not unify the two.
|
||||
function appendPath(base: string, subPath: string): string {
|
||||
if (!base) return subPath.startsWith('/') ? subPath : `/${subPath}`;
|
||||
if (!subPath) return base;
|
||||
return `${base.replace(/\/+$/, '')}/${subPath.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `UriComponentsBuilder` fluent chain to its literal path. Seed
|
||||
* methods (`fromPath`/`fromUriString`/`fromHttpUrl`) return the literal arg
|
||||
* VERBATIM — a `fromHttpUrl("https://host/api")` seed keeps its host, which the
|
||||
* shared `normalizeConsumerPath` later reduces to the path (the same single
|
||||
* normalization point every other consumer path goes through). `path` and
|
||||
* `pathSegment` append literal segments; `build`/`toUriString`/`toUri`/`encode`
|
||||
* and the `query*` family pass through (query attributes do not change the
|
||||
* path). Any non-literal segment or unknown call → null.
|
||||
*/
|
||||
// A UriComponentsBuilder chain deeper than this is not realistic source; cap the
|
||||
// recursion so a pathological / machine-generated chain returns null instead of
|
||||
// overflowing the stack (mirrors the project's other AST-depth guards).
|
||||
const MAX_BUILDER_DEPTH = 100;
|
||||
|
||||
function extractUriComponentsBuilderPath(node: Parser.SyntaxNode, depth = 0): string | null {
|
||||
if (depth > MAX_BUILDER_DEPTH) return null;
|
||||
if (node.type !== 'method_invocation') return null;
|
||||
const name = methodInvocationName(node);
|
||||
const objectNode = methodInvocationObject(node);
|
||||
if (
|
||||
(name === 'fromPath' || name === 'fromUriString' || name === 'fromHttpUrl') &&
|
||||
objectNode?.text === 'UriComponentsBuilder'
|
||||
) {
|
||||
// Strip any `?query` baked into the seed literal so a later `.path()` appends
|
||||
// to a clean base; otherwise the sub-path is glued after the query
|
||||
// (`/base?x=1/sub`) and normalizeHttpPath truncates the whole tail at `?`.
|
||||
// A host prefix (`https://h/api`) is preserved and stripped downstream by
|
||||
// normalizeConsumerPath.
|
||||
const seed = firstLiteralArgument(node);
|
||||
return seed === null ? null : seed.split('?')[0];
|
||||
}
|
||||
if (!objectNode) return null;
|
||||
if (name === 'path') {
|
||||
const base = extractUriComponentsBuilderPath(objectNode, depth + 1);
|
||||
const subPath = firstLiteralArgument(node);
|
||||
if (base === null || subPath === null) return null;
|
||||
// Spring `UriComponentsBuilder.path(p)` appends `p` VERBATIM (no slash
|
||||
// inserted — unlike `pathSegment`, which slash-joins), then normalizes the
|
||||
// full path to collapse duplicate slashes. So `fromPath("/api").path("users")`
|
||||
// → `/apiusers`, while `.path("/users")` → `/api/users`, and a trailing-slash
|
||||
// base collapses (`/api/` + `/users` → `/api/users`). The `(?<!:)` keeps a
|
||||
// scheme `://` in a host seed intact; the downstream normalizer is the other
|
||||
// slash-collapser for consumer paths (see KTD2 — do not remove it).
|
||||
return (base + subPath).replace(/(?<!:)\/{2,}/g, '/');
|
||||
}
|
||||
if (name === 'pathSegment') {
|
||||
const base = extractUriComponentsBuilderPath(objectNode, depth + 1);
|
||||
if (base === null) return null;
|
||||
const args = methodInvocationArguments(node);
|
||||
const segments = args
|
||||
.map((arg) => (arg.type === 'string_literal' ? unquoteLiteral(arg.text) : null))
|
||||
.filter((segment): segment is string => segment !== null);
|
||||
if (segments.length !== args.length) return null; // a non-literal segment defeats static resolution
|
||||
return segments.reduce((acc, segment) => appendPath(acc, segment), base);
|
||||
}
|
||||
if (
|
||||
name === 'build' ||
|
||||
name === 'toUriString' ||
|
||||
name === 'toUri' ||
|
||||
name === 'encode' ||
|
||||
name === 'query' ||
|
||||
name === 'queryParam' ||
|
||||
name === 'queryParams' ||
|
||||
name === 'replaceQuery' ||
|
||||
name === 'replaceQueryParam' ||
|
||||
name === 'replaceQueryParams'
|
||||
)
|
||||
return extractUriComponentsBuilderPath(objectNode, depth + 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a statically-derivable path argument to a literal path: a bare
|
||||
* string literal, a `URI.create("/x")` call, or a `UriComponentsBuilder`
|
||||
* fluent chain. Genuinely dynamic arguments → null.
|
||||
*/
|
||||
export function extractStaticPathExpression(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type === 'string_literal') return unquoteLiteral(node.text);
|
||||
return extractUriCreatePath(node) ?? extractUriComponentsBuilderPath(node);
|
||||
}
|
||||
|
||||
// ─── Builder verb-walks ───────────────────────────────────────────────
|
||||
// OkHttp / Java-HttpClient encode the request verb on a SIBLING call elsewhere in
|
||||
// the fluent chain, not on the matched path call. Both queries capture the path
|
||||
// call (`.url(...)` / `.uri(...)`); these helpers recover the verb by scanning
|
||||
// the WHOLE chain — transparent to neutral calls (`.addHeader()`/`.header()`/
|
||||
// `.timeout()`/`.version()`) wherever they sit relative to the path call, so the
|
||||
// verb resolves whether it precedes or follows the path call.
|
||||
|
||||
/** Every `method_invocation` in the fluent chain `pathCall` belongs to, ordered
|
||||
* innermost (next to the construction) → outermost (terminal). Lets the verb-walk
|
||||
* find the verb wherever it sits, and lets the root gates inspect the chain base. */
|
||||
function builderChainCalls(pathCall: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
let innermost = pathCall;
|
||||
let obj = methodInvocationObject(innermost);
|
||||
while (obj?.type === 'method_invocation') {
|
||||
innermost = obj;
|
||||
obj = methodInvocationObject(innermost);
|
||||
}
|
||||
const calls: Parser.SyntaxNode[] = [innermost];
|
||||
let cur = innermost;
|
||||
let parent = cur.parent;
|
||||
while (parent?.type === 'method_invocation' && methodInvocationObject(parent)?.id === cur.id) {
|
||||
calls.push(parent);
|
||||
cur = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** Scan the chain for the verb a `name`-matching helper or a `.method("LITERAL")`
|
||||
* call sets, resolving the LAST such call — each verb-setter overwrites the
|
||||
* previous at runtime, so on the (non-idiomatic) chain that sets two verbs the
|
||||
* one nearest the terminal wins. `null` means "verb is set but not statically
|
||||
* resolvable" (a non-literal/empty `.method(verb)`) → the caller skips rather
|
||||
* than guessing. `defaultVerb` is returned only when the chain has no verb call
|
||||
* at all (a bare build). `verbHelpers` are matched on the method NAME (OkHttp
|
||||
* helpers are lowercase, HttpClient helpers uppercase). */
|
||||
function inferBuilderVerb(
|
||||
pathCall: Parser.SyntaxNode,
|
||||
verbHelpers: readonly string[],
|
||||
defaultVerb: string,
|
||||
): string | null {
|
||||
let lastVerbCall: Parser.SyntaxNode | null = null;
|
||||
for (const call of builderChainCalls(pathCall)) {
|
||||
if (call.id === pathCall.id) continue; // the path call itself is never the verb
|
||||
const name = methodInvocationName(call);
|
||||
if (name === 'method' || (name !== null && verbHelpers.includes(name))) lastVerbCall = call;
|
||||
}
|
||||
if (lastVerbCall === null) return defaultVerb; // no verb call → builder default
|
||||
const name = methodInvocationName(lastVerbCall);
|
||||
// Explicit `.method(...)`: a non-empty string-literal verb resolves; a
|
||||
// non-literal (variable-bound) OR empty-string verb is unresolvable → null
|
||||
// (skip), NOT a guessed default or a malformed empty-method contract.
|
||||
if (name === 'method') {
|
||||
const verb = firstLiteralArgument(lastVerbCall);
|
||||
return verb ? verb.toUpperCase() : null;
|
||||
}
|
||||
return name === null ? defaultVerb : name.toUpperCase(); // a verb-helper name
|
||||
}
|
||||
|
||||
const OK_HTTP_VERB_HELPERS = ['get', 'head', 'post', 'put', 'delete', 'patch'] as const;
|
||||
const HTTP_CLIENT_VERB_HELPERS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD'] as const;
|
||||
|
||||
/**
|
||||
* Infer an OkHttp request verb by scanning the builder chain around the matched
|
||||
* `.url(...)` call: a `.get()/.head()/.post()/.put()/.delete()/.patch()` helper
|
||||
* (before or after `.url()`), or a `.method("VERB", …)` literal, wins. Returns
|
||||
* `'GET'` only when the chain has NO verb call at all (a bare `.url(...).build()`
|
||||
* — OkHttp's real default). Returns `null` for an explicit `.method(verb, …)`
|
||||
* whose verb is a non-literal: the verb is set but not statically resolvable, so
|
||||
* the caller skips the call rather than asserting a wrong GET (parity with the
|
||||
* WebClient long-form variable-bound-verb behavior, which also skips).
|
||||
*/
|
||||
export function inferOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
|
||||
return inferBuilderVerb(urlCall, OK_HTTP_VERB_HELPERS, 'GET');
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a Java-`HttpClient` request verb by walking UP the builder chain from the
|
||||
* matched `.uri(URI.create("..."))` call: the first `.GET()/.POST()/.PUT()/`
|
||||
* `.DELETE()/.HEAD()` verb-helper, or a `.method("VERB", body)` literal, wins.
|
||||
* Returns `'GET'` only when the chain has no verb call (a bare `.build()` — the
|
||||
* builder's real default). Returns `null` for an explicit `.method(verb, …)` with
|
||||
* a non-literal verb (skip, not a guessed GET). The scan is transparent to
|
||||
* neutral calls anywhere in the chain (`.header()`/`.timeout()`/`.version()`),
|
||||
* before or after `.uri()`, so neither a header/timeout hop nor a verb call's
|
||||
* position drops the contract.
|
||||
*/
|
||||
export function inferHttpClientMethod(uriCall: Parser.SyntaxNode): string | null {
|
||||
return inferBuilderVerb(uriCall, HTTP_CLIENT_VERB_HELPERS, 'GET');
|
||||
}
|
||||
|
||||
// ─── Builder-chain root gates (anti-overreach) ────────────────────────
|
||||
// The path queries match a bare `.url(...)` / `.uri(URI.create(...))` call on ANY
|
||||
// receiver so that a builder call BEFORE the path call (`new Request.Builder()`
|
||||
// `.addHeader(...).url(...)`, `HttpRequest.newBuilder().version(v).uri(...)`) is
|
||||
// still captured. These gates re-impose the framework anchor in JS — only a chain
|
||||
// that bottoms out on the right construction emits, so a `.url(...)`/`.uri(...)`
|
||||
// on an unrelated object does not.
|
||||
|
||||
/** True when `urlCall`'s receiver chain bottoms out on a `new Request.Builder()`
|
||||
* object-creation (descending the `.object` chain past any intervening calls). */
|
||||
export function okHttpUrlRootsAtBuilder(urlCall: Parser.SyntaxNode): boolean {
|
||||
let obj = methodInvocationObject(urlCall);
|
||||
while (obj?.type === 'method_invocation') obj = methodInvocationObject(obj);
|
||||
return (
|
||||
obj?.type === 'object_creation_expression' &&
|
||||
obj.childForFieldName('type')?.text === 'Request.Builder'
|
||||
);
|
||||
}
|
||||
|
||||
/** True when `uriCall`'s receiver chain includes a `HttpRequest.newBuilder()`. */
|
||||
export function httpClientUriRootsAtNewBuilder(uriCall: Parser.SyntaxNode): boolean {
|
||||
let obj = methodInvocationObject(uriCall);
|
||||
while (obj?.type === 'method_invocation') {
|
||||
if (
|
||||
methodInvocationName(obj) === 'newBuilder' &&
|
||||
methodInvocationObject(obj)?.text === 'HttpRequest'
|
||||
)
|
||||
return true;
|
||||
obj = methodInvocationObject(obj);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when a `HttpRequest.newBuilder(URI.create(...))` chain ALSO calls `.uri(...)`
|
||||
* later — a later `.uri()` overrides the constructor URI at runtime, so the
|
||||
* constructor-arg path must NOT be emitted (the `.uri()` query emits the override). */
|
||||
export function httpClientChainHasUriCall(newBuilderCall: Parser.SyntaxNode): boolean {
|
||||
return builderChainCalls(newBuilderCall).some(
|
||||
(call) => call.id !== newBuilderCall.id && methodInvocationName(call) === 'uri',
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,14 @@ import {
|
|||
REQUEST_LINE_CONFIDENCE,
|
||||
EXCHANGE_CONFIDENCE,
|
||||
} from './spring-consumer-shared.js';
|
||||
import {
|
||||
extractStaticPathExpression,
|
||||
inferOkHttpMethod,
|
||||
inferHttpClientMethod,
|
||||
okHttpUrlRootsAtBuilder,
|
||||
httpClientUriRootsAtNewBuilder,
|
||||
httpClientChainHasUriCall,
|
||||
} from './java-static-path.js';
|
||||
import type {
|
||||
HttpDetection,
|
||||
HttpFileDetections,
|
||||
|
|
@ -98,17 +106,15 @@ import type {
|
|||
// sidesteps that hazard entirely; all name/key discrimination lives in the
|
||||
// for-loop, where it reads as straight-line code.
|
||||
//
|
||||
// KNOWN LIMITATION — fully-qualified route annotations are not matched. `@ann`
|
||||
// binds `name: (identifier)`, but a FQN annotation (`@org.springframework…
|
||||
// GetMapping("/x")`) parses its name as a `scoped_identifier`, which this query
|
||||
// does not match, so its route is not extracted. (The class is still recognized
|
||||
// as a controller — `hasAnnotation` trailing-segment-matches the FQN — only the
|
||||
// route-string extraction is missed.) In practice annotations are imported and
|
||||
// written by simple name, so this is rare. It is a minor asymmetry with the
|
||||
// Kotlin plugin, whose grammar models a FQN as separate `type_identifier`
|
||||
// segments that its route queries DO match. Aligning Java would mean matching
|
||||
// `scoped_identifier` too; that is deferred to avoid re-keying existing Java
|
||||
// contracts via the predicate hazard above. Pinned by an anti-overreach test.
|
||||
// FULLY-QUALIFIED route annotations ARE matched. `@ann` binds either an
|
||||
// `identifier` (simple name) or a `scoped_identifier` (a FQN annotation such as
|
||||
// `@org.springframework…GetMapping("/x")`); the for-loop normalizes the name to
|
||||
// its trailing segment via `simpleName` before discriminating. This is a node-
|
||||
// type widening only — the query stays predicate-free, so it does NOT reintroduce
|
||||
// the bucket hazard above, and a simple name maps to itself so existing Java
|
||||
// contracts are unchanged (only previously-unmatched FQN annotations gain
|
||||
// routes). This brings Java to parity with the Kotlin plugin, whose grammar
|
||||
// already matches FQN route annotations.
|
||||
const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
||||
name: 'java-route-annotation',
|
||||
language: Java,
|
||||
|
|
@ -120,17 +126,17 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
|
|
@ -138,7 +144,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
|
|
@ -146,13 +152,13 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))
|
||||
name: (identifier) @member) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
|
|
@ -199,7 +205,7 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({
|
|||
(method_invocation
|
||||
object: (identifier) @obj (#eq? @obj "restTemplate")
|
||||
name: (identifier) @method
|
||||
arguments: (argument_list . (string_literal) @path))
|
||||
arguments: (argument_list . (_) @path))
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -216,7 +222,7 @@ const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({
|
|||
object: (identifier) @obj (#eq? @obj "restTemplate")
|
||||
name: (identifier) @method (#eq? @method "exchange")
|
||||
arguments: (argument_list
|
||||
. (string_literal) @path
|
||||
. (_) @path
|
||||
(field_access
|
||||
object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
|
||||
field: (identifier) @http_method)))
|
||||
|
|
@ -275,10 +281,14 @@ const WEB_CLIENT_LONG_FORM_PATTERNS = compilePatterns({
|
|||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: OkHttp `new Request.Builder().url("path")` ─────────────
|
||||
// Note: `Request.Builder` is a `scoped_type_identifier` whose text includes
|
||||
// the dot, so `#eq?` against the literal string matches cleanly (no need
|
||||
// to escape a regex dot).
|
||||
// ─── Consumer: OkHttp `Request.Builder()…url("path")` ─────────────────
|
||||
// Match a bare `.url("literal")` call on ANY receiver, capturing it as `@call`.
|
||||
// `okHttpUrlRootsAtBuilder` (JS) then verifies the receiver chain bottoms out on
|
||||
// `new Request.Builder()` — re-imposing the framework anchor while allowing
|
||||
// builder calls BEFORE `.url()` (`new Request.Builder().addHeader(...).url("/x")`)
|
||||
// that the old object-direct query dropped, and rejecting a `.url(...)` on an
|
||||
// unrelated object. The verb is recovered by `inferOkHttpMethod` scanning the
|
||||
// whole chain (java-static-path.ts).
|
||||
const OK_HTTP_PATTERNS = compilePatterns({
|
||||
name: 'java-okhttp',
|
||||
language: Java,
|
||||
|
|
@ -287,15 +297,22 @@ const OK_HTTP_PATTERNS = compilePatterns({
|
|||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (object_creation_expression
|
||||
type: (scoped_type_identifier) @type (#eq? @type "Request.Builder"))
|
||||
name: (identifier) @method (#eq? @method "url")
|
||||
arguments: (argument_list . (string_literal) @path))
|
||||
arguments: (argument_list . (string_literal) @path)) @call
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// Match a bare `.uri(URI.create("literal"))` call on ANY receiver, capturing it
|
||||
// as `@call`. `httpClientUriRootsAtNewBuilder` (JS) verifies the chain includes
|
||||
// `HttpRequest.newBuilder()` — allowing calls BEFORE `.uri()` (`.version(v)`
|
||||
// `.uri(...)`) that the old object-direct query dropped, and rejecting a `.uri(...)`
|
||||
// on an unrelated object (e.g. WebClient). The verb (a `.GET()/.POST()/.PUT()/`
|
||||
// `.DELETE()/.HEAD()` helper, a `.method("VERB", body)` literal, or the bare-build
|
||||
// default) is recovered by `inferHttpClientMethod` scanning the whole chain.
|
||||
// Matching `.uri(...)` regardless of a trailing `.build()` mirrors the accepted
|
||||
// OkHttp over-match posture.
|
||||
const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({
|
||||
name: 'java-http-client',
|
||||
language: Java,
|
||||
|
|
@ -304,18 +321,36 @@ const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({
|
|||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (method_invocation
|
||||
object: (method_invocation
|
||||
object: (identifier) @builderCls (#eq? @builderCls "HttpRequest")
|
||||
name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder")
|
||||
arguments: (argument_list))
|
||||
name: (identifier) @uri_method (#eq? @uri_method "uri")
|
||||
arguments: (argument_list
|
||||
(method_invocation
|
||||
object: (identifier) @uriCls (#eq? @uriCls "URI")
|
||||
name: (identifier) @create (#eq? @create "create")
|
||||
arguments: (argument_list . (string_literal) @path))))
|
||||
name: (identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE)$"))
|
||||
name: (identifier) @uri_method (#eq? @uri_method "uri")
|
||||
arguments: (argument_list
|
||||
(method_invocation
|
||||
object: (identifier) @uriCls (#eq? @uriCls "URI")
|
||||
name: (identifier) @create (#eq? @create "create")
|
||||
arguments: (argument_list . (string_literal) @path)))) @call
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// Constructor-arg form: `HttpRequest.newBuilder(URI.create("..."))` — the path is
|
||||
// the `newBuilder(...)` argument, with no `.uri()` call. Captures the `newBuilder`
|
||||
// call as `@call`; the emission skips it when a later `.uri(...)` overrides the
|
||||
// constructor URI (`httpClientChainHasUriCall`), so the `.uri()` query owns that case.
|
||||
const JAVA_HTTP_CLIENT_CTOR_PATTERNS = compilePatterns({
|
||||
name: 'java-http-client-ctor',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (identifier) @builderCls (#eq? @builderCls "HttpRequest")
|
||||
name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder")
|
||||
arguments: (argument_list
|
||||
(method_invocation
|
||||
object: (identifier) @uriCls (#eq? @uriCls "URI")
|
||||
name: (identifier) @create (#eq? @create "create")
|
||||
arguments: (argument_list . (string_literal) @path)))) @call
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -361,6 +396,18 @@ function getNodeName(node: Parser.SyntaxNode): string | null {
|
|||
return node.childForFieldName('name')?.text ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing segment of a possibly fully-qualified annotation name
|
||||
* (`org.springframework.web.bind.annotation.GetMapping` → `GetMapping`). The
|
||||
* route query binds `@ann` to either an `identifier` (simple) or a
|
||||
* `scoped_identifier` (FQN); normalizing here lets the one for-loop discriminate
|
||||
* on the simple name in both cases. A simple name maps to itself, so this never
|
||||
* changes how a non-FQN annotation is classified.
|
||||
*/
|
||||
function simpleName(text: string): string {
|
||||
return text.split('.').pop() ?? text;
|
||||
}
|
||||
|
||||
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
|
||||
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
||||
if (!modifiers) return false;
|
||||
|
|
@ -369,10 +416,9 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
|||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
const annotationName = cur.childForFieldName('name')?.text ?? '';
|
||||
const simpleName = annotationName.split('.').pop() ?? annotationName;
|
||||
if (
|
||||
(cur.type === 'annotation' || cur.type === 'marker_annotation') &&
|
||||
(allowed.has(annotationName) || allowed.has(simpleName))
|
||||
(allowed.has(annotationName) || allowed.has(simpleName(annotationName)))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -381,6 +427,11 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
|
|||
return false;
|
||||
}
|
||||
|
||||
// The statically-resolvable consumer path helpers (URI.create /
|
||||
// UriComponentsBuilder resolution) and the OkHttp / HttpClient builder verb-walks
|
||||
// (`inferOkHttpMethod` / `inferHttpClientMethod`) live in ./java-static-path.ts
|
||||
// (#2268), shared with the RestTemplate, OkHttp, and HttpClient consumer loops.
|
||||
|
||||
interface MethodRouteAnnotation {
|
||||
methodNode: Parser.SyntaxNode;
|
||||
methodName: string | null;
|
||||
|
|
@ -444,7 +495,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const node = captures.node;
|
||||
const valueNode = captures.value;
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
const ann = annNode.text;
|
||||
// Discrimination is on the trailing segment only (`simpleName`), so a
|
||||
// non-Spring annotation whose last segment collides with a route annotation
|
||||
// (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the
|
||||
// same accepted trailing-segment trade-off `hasAnnotation` already makes and
|
||||
// the intended parity with the Kotlin plugin — package-origin gating would
|
||||
// break that parity and is deliberately not done.
|
||||
const ann = simpleName(annNode.text);
|
||||
const keyNode = captures.key; // undefined for the positional shape
|
||||
|
||||
if (node.type === 'method_declaration') {
|
||||
|
|
@ -727,7 +784,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
if (!methodNode || !pathNode) continue;
|
||||
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
const path = extractStaticPathExpression(pathNode);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
|
|
@ -743,7 +800,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
const httpMethodNode = match.captures.http_method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!httpMethodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
const path = extractStaticPathExpression(pathNode);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
|
|
@ -801,15 +858,25 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
}
|
||||
|
||||
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
|
||||
// Match any `.url("literal")`, gate to chains rooting at `new Request.Builder()`
|
||||
// (so a call before `.url()` is captured but an unrelated `.url()` is not), then
|
||||
// recover the verb (`.post()`/`.method("X")`) by scanning the builder chain.
|
||||
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
|
||||
const callNode = match.captures.call;
|
||||
const pathNode = match.captures.path;
|
||||
if (!pathNode) continue;
|
||||
if (!callNode || !pathNode) continue;
|
||||
if (!okHttpUrlRootsAtBuilder(callNode)) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
const method = inferOkHttpMethod(callNode);
|
||||
// An explicit `.method(verb, …)` with a non-literal or empty verb is
|
||||
// unresolvable (null) — emit nothing rather than a wrong GET or an empty
|
||||
// `http::::/path` contract.
|
||||
if (!method) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'okhttp',
|
||||
method: 'GET',
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
|
|
@ -817,18 +884,48 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
}
|
||||
|
||||
// ─── Consumers: Java HttpClient request builder ─────────────────
|
||||
// Java's builder exposes GET/POST/PUT/DELETE helpers. PATCH uses
|
||||
// `.method("PATCH", body)`, which is intentionally deferred.
|
||||
// Match any `.uri(URI.create("..."))`, gate to chains including `HttpRequest`
|
||||
// `.newBuilder()` (so a call before `.uri()` is captured but an unrelated
|
||||
// `.uri()` is not); `inferHttpClientMethod` scans the chain for the verb — a
|
||||
// `.GET()/.POST()/.PUT()/.DELETE()/.HEAD()` helper, a `.method("VERB", body)`
|
||||
// literal, or the bare-build default GET. A variable-bound `.method(verb, …)`
|
||||
// is unresolvable → emit nothing. Mirrors the OkHttp loop above. The
|
||||
// constructor-arg form (`newBuilder(URI.create(...))`, no `.uri()`) follows.
|
||||
for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) {
|
||||
const httpMethodNode = match.captures.http_method;
|
||||
const callNode = match.captures.call;
|
||||
const pathNode = match.captures.path;
|
||||
if (!httpMethodNode || !pathNode) continue;
|
||||
if (!callNode || !pathNode) continue;
|
||||
if (!httpClientUriRootsAtNewBuilder(callNode)) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
const method = inferHttpClientMethod(callNode);
|
||||
if (!method) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'java-http-client',
|
||||
method: httpMethodNode.text.toUpperCase(),
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.65,
|
||||
});
|
||||
}
|
||||
|
||||
// Constructor-arg form: `HttpRequest.newBuilder(URI.create("..."))` with the
|
||||
// path in the constructor and no overriding `.uri(...)` later in the chain
|
||||
// (a later `.uri()` wins at runtime, so the loop above owns that case).
|
||||
for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_CTOR_PATTERNS, tree)) {
|
||||
const callNode = match.captures.call;
|
||||
const pathNode = match.captures.path;
|
||||
if (!callNode || !pathNode) continue;
|
||||
if (httpClientChainHasUriCall(callNode)) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
const method = inferHttpClientMethod(callNode);
|
||||
if (!method) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'java-http-client',
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.65,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,118 @@ const arrayOfArg = (cap: string): string => `(call_expression
|
|||
(simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
|
||||
(call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
|
||||
|
||||
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
|
||||
// Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
|
||||
// is a `call_expression` whose callee is a `navigation_expression` (receiver +
|
||||
// `navigation_suffix` → the method name) and whose `call_suffix` holds the
|
||||
// `value_arguments`. The chain is left-nested via `navigation_expression`, so we
|
||||
// walk UP from the matched `.url(...)` call to the sibling verb call (`.post()` /
|
||||
// `.method("X")`), exactly as the Java side does — so `.java` and `.kt` infer the
|
||||
// same verb for the same OkHttp shape.
|
||||
const OK_HTTP_VERB_HELPERS = ['get', 'head', 'post', 'put', 'delete', 'patch'];
|
||||
|
||||
/** The method name a Kotlin `call_expression` invokes (its `navigation_suffix`). */
|
||||
function kotlinCallName(call: Parser.SyntaxNode): string | null {
|
||||
const callee = call.namedChild(0);
|
||||
if (callee?.type !== 'navigation_expression') return null;
|
||||
for (let i = 0; i < callee.namedChildCount; i++) {
|
||||
const child = callee.namedChild(i);
|
||||
if (child?.type === 'navigation_suffix') return child.namedChild(0)?.text ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The first string-literal argument of a Kotlin `call_expression`, else null. */
|
||||
function kotlinFirstStringArg(call: Parser.SyntaxNode): string | null {
|
||||
for (let i = 0; i < call.namedChildCount; i++) {
|
||||
const callSuffix = call.namedChild(i);
|
||||
if (callSuffix?.type !== 'call_suffix') continue;
|
||||
for (let j = 0; j < callSuffix.namedChildCount; j++) {
|
||||
const args = callSuffix.namedChild(j);
|
||||
if (args?.type !== 'value_arguments') continue;
|
||||
const firstArg = args.namedChild(0);
|
||||
if (firstArg?.type !== 'value_argument') return null;
|
||||
// Positional literal `"X"`, or named-argument `name = "X"` (the label is a
|
||||
// leading `simple_identifier` and the literal follows). A non-literal value
|
||||
// (a variable) → null → unresolvable.
|
||||
const positional = firstArg.namedChild(0);
|
||||
if (positional?.type === 'string_literal') return unquoteLiteral(positional.text);
|
||||
const labeled = positional?.type === 'simple_identifier' ? firstArg.namedChild(1) : null;
|
||||
return labeled?.type === 'string_literal' ? unquoteLiteral(labeled.text) : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The receiver expression a Kotlin `call_expression` is invoked on. */
|
||||
function kotlinReceiver(call: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
const nav = call.namedChild(0);
|
||||
return nav?.type === 'navigation_expression' ? nav.namedChild(0) : null;
|
||||
}
|
||||
|
||||
/** The call that invokes a method ON `call` as its receiver — one hop up the chain. */
|
||||
function kotlinChainParentCall(call: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
const nav = call.parent;
|
||||
if (nav?.type !== 'navigation_expression' || nav.namedChild(0)?.id !== call.id) return null;
|
||||
return nav.parent?.type === 'call_expression' ? nav.parent : null;
|
||||
}
|
||||
|
||||
/** Every `call_expression` in the fluent chain `pathCall` belongs to, innermost
|
||||
* (next to the construction) → outermost. Lets the verb-walk find a verb call
|
||||
* wherever it sits relative to `.url(...)` — parity with java-static-path.ts
|
||||
* `builderChainCalls`. */
|
||||
function kotlinBuilderChainCalls(pathCall: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
let innermost = pathCall;
|
||||
let recv = kotlinReceiver(innermost);
|
||||
while (recv?.type === 'call_expression') {
|
||||
innermost = recv;
|
||||
recv = kotlinReceiver(innermost);
|
||||
}
|
||||
const calls: Parser.SyntaxNode[] = [innermost];
|
||||
let cur = innermost;
|
||||
for (let next = kotlinChainParentCall(cur); next; next = kotlinChainParentCall(cur)) {
|
||||
calls.push(next);
|
||||
cur = next;
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** True when `urlCall`'s receiver chain bottoms out on `Request.Builder()` — the
|
||||
* Kotlin anti-overreach gate (mirror of okHttpUrlRootsAtBuilder), so a `.url(...)`
|
||||
* on an unrelated object is rejected while a builder call before `.url()` is kept. */
|
||||
function kotlinUrlRootsAtRequestBuilder(urlCall: Parser.SyntaxNode): boolean {
|
||||
let cur: Parser.SyntaxNode | null = kotlinReceiver(urlCall);
|
||||
while (cur?.type === 'call_expression') {
|
||||
const recv = kotlinReceiver(cur);
|
||||
if (recv?.type === 'simple_identifier')
|
||||
return recv.text === 'Request' && kotlinCallName(cur) === 'Builder';
|
||||
cur = recv;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Infer the OkHttp verb by scanning the builder chain around the matched
|
||||
* `.url(...)` call — parity with java-static-path.ts `inferOkHttpMethod`. The
|
||||
* LAST verb call wins (runtime overwrite); `null` for an unresolvable
|
||||
* `.method(verb)` (non-literal/empty) so the caller skips; `'GET'` when the
|
||||
* chain has no verb call. */
|
||||
function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
|
||||
let lastVerbCall: Parser.SyntaxNode | null = null;
|
||||
for (const call of kotlinBuilderChainCalls(urlCall)) {
|
||||
if (call.id === urlCall.id) continue;
|
||||
const name = kotlinCallName(call);
|
||||
if (name === 'method' || (name !== null && OK_HTTP_VERB_HELPERS.includes(name)))
|
||||
lastVerbCall = call;
|
||||
}
|
||||
if (lastVerbCall === null) return 'GET'; // no verb call → OkHttp default
|
||||
const name = kotlinCallName(lastVerbCall);
|
||||
if (name === 'method') {
|
||||
const verb = kotlinFirstStringArg(lastVerbCall);
|
||||
return verb ? verb.toUpperCase() : null;
|
||||
}
|
||||
return name === null ? 'GET' : name.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plugin only if the Kotlin grammar is available. Compiling
|
||||
* the queries against a null grammar would throw at module load time
|
||||
|
|
@ -423,26 +535,25 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
} 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.
|
||||
// Full parity with the Java OkHttp extraction (java.ts + java-static-path.ts),
|
||||
// adapted to the Kotlin grammar (a call is a `call_expression` whose callee is a
|
||||
// `navigation_expression`, not Java's `object_creation_expression`):
|
||||
//
|
||||
// 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.
|
||||
// • Match a bare `.url("literal")` call on ANY receiver (capture it as `@call`);
|
||||
// `kotlinUrlRootsAtRequestBuilder` (JS) then verifies the receiver chain
|
||||
// bottoms out on `Request.Builder()` — re-imposing the framework anchor while
|
||||
// allowing a builder call BEFORE `.url()` (`Request.Builder().addHeader(...)`
|
||||
// `.url(...)`) and rejecting a `.url(...)` on an unrelated object.
|
||||
// • `inferKotlinOkHttpMethod` scans the whole chain for the verb (`.post(body)`
|
||||
// / `.get()` / `.method("X")`, before or after `.url()`) — the mirror of
|
||||
// `inferOkHttpMethod`. So `Request.Builder().url("/x").post(body).build()`
|
||||
// becomes `http::POST::/x` on both `.java` and `.kt` (pinned by the Java↔Kotlin
|
||||
// parity harness). A variable-bound/empty `.method(verb)` is unresolvable →
|
||||
// the call is skipped (not a guessed GET), matching the Java side.
|
||||
//
|
||||
// **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.
|
||||
// Receiver `Request` is constrained by name (in the JS gate); a project that
|
||||
// imports OkHttp's `Request` under an alias would not be picked up — matching the
|
||||
// Java plugin's heuristic.
|
||||
const OK_HTTP_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-okhttp',
|
||||
language,
|
||||
|
|
@ -452,14 +563,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
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))))
|
||||
(value_arguments . (value_argument . (string_literal) @path)))) @call
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -983,15 +1089,23 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
}
|
||||
|
||||
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
|
||||
// Gate to chains rooting at `Request.Builder()` (so a call before `.url()` is
|
||||
// captured but an unrelated `.url()` is not), then recover the verb by scanning
|
||||
// the chain (parity with Java); a variable-bound or empty `.method(verb)` is
|
||||
// unresolvable → emit nothing rather than a GET.
|
||||
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
|
||||
const callNode = match.captures.call;
|
||||
const pathNode = match.captures.path;
|
||||
if (!pathNode) continue;
|
||||
if (!callNode || !pathNode) continue;
|
||||
if (!kotlinUrlRootsAtRequestBuilder(callNode)) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
const method = inferKotlinOkHttpMethod(callNode);
|
||||
if (!method) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'okhttp',
|
||||
method: 'GET',
|
||||
method,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
|
|
|
|||
|
|
@ -1158,12 +1158,13 @@ public class StatusController implements StatusApi {
|
|||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not extract fully-qualified Java route annotations (documented limitation #2254)', async () => {
|
||||
// JAVA_ROUTE_ANNOTATION_PATTERNS binds `name: (identifier)`; a FQN route
|
||||
// annotation parses its name as `scoped_identifier` and is not matched, so
|
||||
// its route is not extracted (only the route string — the controller itself
|
||||
// is still recognised). This pins that documented limitation / asymmetry
|
||||
// with Kotlin. If FQN matching is ever added, flip this assertion.
|
||||
it('extracts fully-qualified Java route annotations (#2254 FQN follow-through)', async () => {
|
||||
// JAVA_ROUTE_ANNOTATION_PATTERNS now binds `name: [(identifier)
|
||||
// (scoped_identifier)]`; a deep FQN route annotation
|
||||
// (`@org.springframework…GetMapping`) is matched and the for-loop
|
||||
// normalizes the name to its trailing segment (`simpleName`). The
|
||||
// controller was already recognised (hasAnnotation trailing-segment match);
|
||||
// now the route string is extracted too — parity with the Kotlin plugin.
|
||||
const dir = path.join(tmpDir, 'java-fqn-route-annotation');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
|
@ -1181,12 +1182,57 @@ class FqnController {
|
|||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
// The FQN route annotation is not extracted (documented limitation).
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined();
|
||||
// And it must not over-match into a bogus contract either.
|
||||
expect(
|
||||
providers.filter((c) => c.symbolRef.filePath.endsWith('FqnController.java')),
|
||||
).toHaveLength(0);
|
||||
providers.find(
|
||||
(c) =>
|
||||
c.contractId === 'http::GET::/api/users' &&
|
||||
c.meta.framework === 'spring' &&
|
||||
c.confidence === 0.8,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts FQN OpenFeign consumers + two-segment FQN, with anti-overreach', async () => {
|
||||
const dir = path.join(tmpDir, 'java-fqn-feign');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'QualifiedClient.java'),
|
||||
`
|
||||
@org.springframework.cloud.openfeign.FeignClient(name = "order-service", path = "/api")
|
||||
interface QualifiedClient {
|
||||
@org.springframework.web.bind.annotation.GetMapping("/orders/{id}")
|
||||
OrderDto getOrder(String id);
|
||||
}
|
||||
|
||||
@foo.RestController
|
||||
@foo.RequestMapping("/v2")
|
||||
class ShortFqnController {
|
||||
@foo.GetMapping("/items")
|
||||
Object items() { return null; }
|
||||
}
|
||||
|
||||
@com.example.NotARoute("/should-not-extract")
|
||||
class Unrelated {
|
||||
@com.example.AlsoNotARoute("/nope")
|
||||
Object noop() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const fromFile = contracts.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('QualifiedClient.java'),
|
||||
);
|
||||
|
||||
// Exactly two contracts: the FQN @FeignClient(path)+@GetMapping consumer and
|
||||
// the two-segment-FQN provider. The `Unrelated` class's non-route FQN
|
||||
// annotations contribute nothing (anti-overreach — simpleName misses them).
|
||||
expect(new Set(fromFile.map((c) => `${c.role} ${c.contractId} ${c.meta.framework}`))).toEqual(
|
||||
new Set([
|
||||
'consumer http::GET::/api/orders/{param} openfeign',
|
||||
'provider http::GET::/v2/items spring',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts Express router.get patterns', async () => {
|
||||
|
|
@ -1771,6 +1817,366 @@ class ApiClient {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts Java RestTemplate URI.create(...) static paths', async () => {
|
||||
const dir = path.join(tmpDir, 'java-rest-template-uri-create');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'UriClient.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
class UriClient {
|
||||
void run(RestTemplate restTemplate) {
|
||||
String dynamicPath = "/api/dynamic-users/99";
|
||||
restTemplate.getForEntity(URI.create("/api/uri-users/42"), String.class);
|
||||
restTemplate.exchange(URI.create("/api/uri-users/42/details"), HttpMethod.POST, null, String.class);
|
||||
restTemplate.getForObject(dynamicPath, String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
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/uri-users/{param}' &&
|
||||
c.meta.framework === 'spring-rest-template' &&
|
||||
c.confidence === 0.7,
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::POST::/api/uri-users/{param}/details'),
|
||||
).toBeDefined();
|
||||
// Anti-overreach: a variable-bound path is not statically resolvable, so
|
||||
// the widened `(_) @path` capture must NOT emit a consumer for it.
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/dynamic-users/{param}'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts Java RestTemplate UriComponentsBuilder fluent-chain paths', async () => {
|
||||
const dir = path.join(tmpDir, 'java-rest-template-builder');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'BuilderClient.java'),
|
||||
`
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class BuilderClient {
|
||||
void run(RestTemplate restTemplate, String idVar) {
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromPath("/api").path("/builder-users").pathSegment("42").build().toUriString(),
|
||||
String.class);
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromUriString("/base").path("/sub").queryParam("page", "1").build().toUriString(),
|
||||
String.class);
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/external-users").query("page=1").build().toUriString(),
|
||||
String.class);
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromPath("/api").pathSegment(idVar).build().toUriString(),
|
||||
String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// fromPath + path + numeric pathSegment → joined, numeric → {param}.
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/builder-users/{param}'),
|
||||
).toBeDefined();
|
||||
// fromUriString seed + path; the queryParam attribute does not alter the path.
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined();
|
||||
// fromHttpUrl host seed: helper keeps the host; normalizeConsumerPath strips it.
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/external-users'),
|
||||
).toBeDefined();
|
||||
// Anti-overreach: the non-literal `pathSegment(idVar)` call defeats static
|
||||
// resolution, so exactly the three resolvable chains emit — not a fourth.
|
||||
expect(
|
||||
consumers.filter((c) => c.symbolRef.filePath.endsWith('BuilderClient.java')),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('strips a query string baked into a UriComponentsBuilder seed (#2268)', async () => {
|
||||
const dir = path.join(tmpDir, 'java-rest-template-builder-query-seed');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'QuerySeedClient.java'),
|
||||
`
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class QuerySeedClient {
|
||||
void run(RestTemplate restTemplate) {
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromUriString("/base?x=1").path("/sub").build().toUriString(),
|
||||
String.class);
|
||||
restTemplate.getForObject(
|
||||
UriComponentsBuilder.fromHttpUrl("https://example.com/api?x=1").path("/y").build().toUriString(),
|
||||
String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// Query in the seed must NOT swallow the later .path() segment:
|
||||
// fromUriString("/base?x=1").path("/sub") → /base/sub (was /base before the fix).
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined();
|
||||
// Host + query seed: query stripped at the seed, host stripped downstream.
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/api/y')).toBeDefined();
|
||||
// Count guard: exactly the two resolvable chains. A regression that
|
||||
// double-emitted (e.g. both /base and /base/sub) would slip past the two
|
||||
// `find` assertions above without this.
|
||||
expect(
|
||||
consumers.filter((c) => c.symbolRef.filePath.endsWith('QuerySeedClient.java')),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolves a UriComponentsBuilder argument passed to restTemplate.exchange (#2268)', async () => {
|
||||
// The exchange() path capture was widened to `(_) @path` alongside the
|
||||
// plain RestTemplate loop, but was only covered with URI.create. Pin that a
|
||||
// UriComponentsBuilder chain through exchange() also resolves end-to-end.
|
||||
const dir = path.join(tmpDir, 'java-rest-template-exchange-builder');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'ExchangeBuilderClient.java'),
|
||||
`
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class ExchangeBuilderClient {
|
||||
void run(RestTemplate restTemplate) {
|
||||
restTemplate.exchange(
|
||||
UriComponentsBuilder.fromPath("/api").path("/exchange-users").build().toUriString(),
|
||||
HttpMethod.PUT, null, String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::PUT::/api/exchange-users'),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('appends UriComponentsBuilder .path() verbatim per Spring semantics (#2268)', async () => {
|
||||
// Spring `.path(p)` appends `p` as-is (no slash inserted) then collapses
|
||||
// duplicate slashes — unlike `.pathSegment`, which slash-joins. So a
|
||||
// no-leading-slash arg is NOT given a phantom slash, a leading-slash arg
|
||||
// joins cleanly, a trailing-slash base collapses, and a host seed keeps its
|
||||
// `://` until the downstream normalizer strips the host.
|
||||
const dir = path.join(tmpDir, 'java-rest-template-builder-path');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'PathClient.java'),
|
||||
`
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class PathClient {
|
||||
void run(RestTemplate restTemplate) {
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromPath("/api").path("noslash").build().toUriString(), String.class);
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromPath("/svc").path("/withslash").build().toUriString(), String.class);
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromPath("/trail/").path("/seg").build().toUriString(), String.class);
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromPath("/first-").path("value/").path("/end").build().toUriString(), String.class);
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/ext").build().toUriString(), String.class);
|
||||
restTemplate.getForObject(UriComponentsBuilder.fromPath("/empty").path("").build().toUriString(), String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const fromFile = contracts.filter(
|
||||
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('PathClient.java'),
|
||||
);
|
||||
|
||||
// Exactly these six — no phantom slash on the no-leading-slash arg, no
|
||||
// double slash from the trailing-slash base, no `://` corruption.
|
||||
expect(new Set(fromFile.map((c) => c.contractId))).toEqual(
|
||||
new Set([
|
||||
'http::GET::/apinoslash', // fromPath("/api").path("noslash") — verbatim, no slash
|
||||
'http::GET::/svc/withslash', // leading-slash arg joins cleanly
|
||||
'http::GET::/trail/seg', // trailing-slash base collapses
|
||||
'http::GET::/first-value/end', // value/ + /end → one slash
|
||||
'http::GET::/api/ext', // host seed: `://` kept, host stripped downstream
|
||||
'http::GET::/empty', // empty .path("") is a no-op
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overflow on a pathological UriComponentsBuilder chain (#2268)', async () => {
|
||||
const dir = path.join(tmpDir, 'java-rest-template-builder-deep');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
// A chain far deeper than the recursion cap — exercises the depth guard.
|
||||
const deepChain = `UriComponentsBuilder.fromPath("/r")${'.path("/x")'.repeat(200)}.build().toUriString()`;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'DeepClient.java'),
|
||||
`
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
class DeepClient {
|
||||
void run(RestTemplate restTemplate) {
|
||||
restTemplate.getForObject(${deepChain}, String.class);
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
// Must not throw (the depth guard caps recursion). A chain past the cap
|
||||
// resolves to null, so no consumer is emitted for it (without the guard it
|
||||
// would either overflow or resolve to a bogus deep path).
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
expect(
|
||||
contracts.filter(
|
||||
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('DeepClient.java'),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('infers Java OkHttp verbs from sibling Request.Builder calls', async () => {
|
||||
const dir = path.join(tmpDir, 'java-okhttp-verbs');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkHttpVerbs.java'),
|
||||
`
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class OkHttpVerbs {
|
||||
void run(RequestBody body, String verb) {
|
||||
new Request.Builder().url("/api/orders/0").get().build();
|
||||
new Request.Builder().url("/api/orders/head").head().build();
|
||||
new Request.Builder().url("/api/orders").post(body).build();
|
||||
new Request.Builder().url("/api/orders/1").put(body).build();
|
||||
new Request.Builder().url("/api/orders/2").delete().build();
|
||||
new Request.Builder().url("/api/orders/3").method("PATCH", body).build();
|
||||
new Request.Builder().url("/api/bare-build").build();
|
||||
new Request.Builder().url("/api/dyn-verb").method(verb, body).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const okhttp = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'okhttp' &&
|
||||
c.symbolRef.filePath.endsWith('OkHttpVerbs.java'),
|
||||
);
|
||||
|
||||
// The bare `.build()` with no verb call gets its OWN path (`/api/bare-build`)
|
||||
// so the default-GET branch of inferOkHttpMethod is pinned independently.
|
||||
// An explicit `.method(verb, …)` with a *variable* verb (`/api/dyn-verb`) is
|
||||
// unresolvable and emits NOTHING — not a guessed GET (parity with WebClient
|
||||
// long-form). Explicit verbs resolve; numeric segments normalize to {param}.
|
||||
expect(okhttp.find((c) => c.contractId === 'http::GET::/api/bare-build')).toBeDefined();
|
||||
// Variable-bound `.method(verb)` produces no contract at all (any verb).
|
||||
expect(okhttp.find((c) => c.contractId.includes('/api/dyn-verb'))).toBeUndefined();
|
||||
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
|
||||
new Set([
|
||||
'http::GET::/api/bare-build',
|
||||
'http::GET::/api/orders/{param}',
|
||||
'http::POST::/api/orders',
|
||||
'http::PUT::/api/orders/{param}',
|
||||
'http::DELETE::/api/orders/{param}',
|
||||
'http::PATCH::/api/orders/{param}',
|
||||
'http::HEAD::/api/orders/head',
|
||||
]),
|
||||
);
|
||||
expect(okhttp.every((c) => c.confidence === 0.7)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not emit a contract for an empty-string verb literal (#2268)', async () => {
|
||||
// `.method("", body)` is an explicit-but-unresolvable verb — `unquoteLiteral`
|
||||
// returns "" (not null), so the falsiness guard must skip it rather than
|
||||
// emit a malformed `http::::/path` contract or a guessed GET. Covers both
|
||||
// the OkHttp and Java-HttpClient verb-walks (shared `inferBuilderVerb`).
|
||||
const dir = path.join(tmpDir, 'java-empty-verb');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'EmptyVerb.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class EmptyVerb {
|
||||
void run(RequestBody body) throws Exception {
|
||||
new Request.Builder().url("/api/okhttp-empty").method("", body).build();
|
||||
HttpRequest hc = HttpRequest.newBuilder().uri(URI.create("/api/hc-empty")).method("", body).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const fromFile = contracts.filter(
|
||||
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('EmptyVerb.java'),
|
||||
);
|
||||
|
||||
// No contract at all — neither a malformed empty-method id nor a guessed GET.
|
||||
expect(fromFile.map((c) => c.contractId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts OkHttp chains with a builder call before .url(), with anti-overreach (#2268)', async () => {
|
||||
// A builder call BEFORE .url() (`new Request.Builder().addHeader(...).url(...)`)
|
||||
// no longer drops the contract — the chain is matched as long as it roots at
|
||||
// `new Request.Builder()`. The verb-walk scans the whole chain, so a verb set
|
||||
// before .url() also resolves. A `.url(...)` on an unrelated object does NOT
|
||||
// emit (the root gate rejects it).
|
||||
const dir = path.join(tmpDir, 'java-okhttp-pre-url');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkHttpPreUrl.java'),
|
||||
`
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class OkHttpPreUrl {
|
||||
void run(RequestBody body, SomeClient other) {
|
||||
new Request.Builder().addHeader("A", "b").url("/api/pre-url").build();
|
||||
new Request.Builder().post(body).url("/api/verb-first").build();
|
||||
other.url("/api/not-okhttp").build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const okhttp = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'okhttp' &&
|
||||
c.symbolRef.filePath.endsWith('OkHttpPreUrl.java'),
|
||||
);
|
||||
|
||||
// The header-before-url chain (default GET) and the verb-before-url chain
|
||||
// (POST) both emit; `other.url(...)` does not (not a Request.Builder chain).
|
||||
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
|
||||
new Set(['http::GET::/api/pre-url', 'http::POST::/api/verb-first']),
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts Java WebClient long-form method(HttpMethod.X).uri(...) — #2254 parity', async () => {
|
||||
// Parity with the Kotlin plugin: a single structural query matches the
|
||||
// verb (HttpMethod.X field access) and path. Previously deferred on the
|
||||
|
|
@ -2553,6 +2959,208 @@ class HttpClients {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts Java HttpClient HEAD, .method(), and default-GET forms', async () => {
|
||||
const dir = path.join(tmpDir, 'java-http-client-verbs');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'HttpClientVerbs.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
class HttpClientVerbs {
|
||||
void run(String verb) throws Exception {
|
||||
HttpRequest head = HttpRequest.newBuilder().uri(URI.create("/api/users/head")).HEAD().build();
|
||||
HttpRequest patch = HttpRequest.newBuilder().uri(URI.create("/api/users/2")).method("PATCH", HttpRequest.BodyPublishers.ofString("{}")).build();
|
||||
HttpRequest def = HttpRequest.newBuilder().uri(URI.create("/api/default-users/3")).build();
|
||||
HttpRequest dyn = HttpRequest.newBuilder().uri(URI.create("/api/dyn/4")).method(verb, HttpRequest.BodyPublishers.noBody()).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const hc = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'java-http-client' &&
|
||||
c.symbolRef.filePath.endsWith('HttpClientVerbs.java'),
|
||||
);
|
||||
|
||||
// HEAD via verb helper, PATCH via `.method("X")`, default GET via bare
|
||||
// `.build()`. The variable-bound `.method(verb, …)` is NOT resolved
|
||||
// (string literal only) → no contract. Exact set-equality also pins that
|
||||
// no chain double-emits (e.g. HEAD would never also yield a GET).
|
||||
expect(new Set(hc.map((c) => c.contractId))).toEqual(
|
||||
new Set([
|
||||
'http::HEAD::/api/users/head',
|
||||
'http::PATCH::/api/users/{param}',
|
||||
'http::GET::/api/default-users/{param}',
|
||||
]),
|
||||
);
|
||||
expect(hc.every((c) => c.confidence === 0.65)).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts Java HttpClient verbs across intervening builder calls (#2268)', async () => {
|
||||
// The verb-walk is transparent to neutral calls AFTER `.uri(...)`, so a
|
||||
// `.header()`/`.timeout()` hop before the terminal no longer drops the
|
||||
// contract. Each verb-producing branch gets a DISTINCT non-numeric path so
|
||||
// the set-equality assertion cannot mask a branch. A call BEFORE `.uri()`,
|
||||
// a constructor-arg `newBuilder(uri)`, and a non-literal `.uri()` arg are
|
||||
// documented misses (must NOT extract).
|
||||
const dir = path.join(tmpDir, 'java-http-client-intervening');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'HttpClientIntervening.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
class HttpClientIntervening {
|
||||
void run(URI uriVar, Duration dur, HttpClient.Version ver, HttpRequest.BodyPublisher body) throws Exception {
|
||||
// Intervening calls AFTER .uri() — header/timeout transparent to the verb-walk.
|
||||
HttpRequest a = HttpRequest.newBuilder().uri(URI.create("/api/hdr")).header("Accept", "application/json").build();
|
||||
HttpRequest b = HttpRequest.newBuilder().uri(URI.create("/api/tmo")).timeout(dur).method("PUT", body).build();
|
||||
HttpRequest c = HttpRequest.newBuilder().uri(URI.create("/api/hdr-verb")).header("X", "y").DELETE().build();
|
||||
// Verb helper BEFORE an intervening call (walk does not stop at the first non-verb).
|
||||
HttpRequest d = HttpRequest.newBuilder().uri(URI.create("/api/verb-then-hdr")).POST(body).header("X", "y").build();
|
||||
// Unbuilt .uri() — no .build(); over-match emits the default GET (mirrors OkHttp).
|
||||
HttpRequest.Builder e = HttpRequest.newBuilder().uri(URI.create("/api/unbuilt"));
|
||||
// Call BEFORE .uri() and the constructor-arg form are now captured too (the
|
||||
// chain roots at HttpRequest.newBuilder); only a non-literal .uri() is a miss.
|
||||
HttpRequest f = HttpRequest.newBuilder().version(ver).uri(URI.create("/api/pre-uri")).build(); // call before .uri()
|
||||
HttpRequest g = HttpRequest.newBuilder(URI.create("/api/ctor")).build(); // constructor-arg, no .uri()
|
||||
HttpRequest h = HttpRequest.newBuilder().uri(uriVar).build(); // non-literal .uri() arg → miss
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const hc = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'java-http-client' &&
|
||||
c.symbolRef.filePath.endsWith('HttpClientIntervening.java'),
|
||||
);
|
||||
|
||||
// The seven resolvable chains (incl. the pre-`.uri()` and constructor-arg
|
||||
// forms); only the non-literal `.uri(uriVar)` contributes nothing.
|
||||
expect(new Set(hc.map((c) => c.contractId))).toEqual(
|
||||
new Set([
|
||||
'http::GET::/api/hdr',
|
||||
'http::PUT::/api/tmo',
|
||||
'http::DELETE::/api/hdr-verb',
|
||||
'http::POST::/api/verb-then-hdr',
|
||||
'http::GET::/api/unbuilt',
|
||||
'http::GET::/api/pre-uri',
|
||||
'http::GET::/api/ctor',
|
||||
]),
|
||||
);
|
||||
expect(hc.every((c) => c.confidence === 0.65)).toBe(true);
|
||||
});
|
||||
|
||||
it('passes through a non-standard HttpClient .method("VERB") verb (#2268)', async () => {
|
||||
// A custom verb literal passes through (uppercased), matching the OkHttp
|
||||
// .method() precedent — the verb-walk does not restrict to known verbs.
|
||||
const dir = path.join(tmpDir, 'java-http-client-custom-verb');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'CustomVerb.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
class CustomVerb {
|
||||
void run(HttpRequest.BodyPublisher body) throws Exception {
|
||||
HttpRequest r = HttpRequest.newBuilder().uri(URI.create("/api/report")).method("REPORT", body).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const hc = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'java-http-client' &&
|
||||
c.symbolRef.filePath.endsWith('CustomVerb.java'),
|
||||
);
|
||||
expect(new Set(hc.map((c) => c.contractId))).toEqual(new Set(['http::REPORT::/api/report']));
|
||||
});
|
||||
|
||||
it('handles HttpClient constructor-URI override and rejects non-newBuilder .uri (#2268)', async () => {
|
||||
// A constructor URI overridden by a later `.uri(...)` emits ONLY the override
|
||||
// (not both), and a `.uri(URI.create(...))` on a chain that does not root at
|
||||
// HttpRequest.newBuilder (e.g. WebClient) is NOT a java-http-client consumer.
|
||||
const dir = path.join(tmpDir, 'java-http-client-ctor-override');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'CtorOverride.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
class CtorOverride {
|
||||
void run(WebClient webClient) {
|
||||
HttpRequest a = HttpRequest.newBuilder(URI.create("/api/ctor-overridden")).uri(URI.create("/api/override-wins")).build();
|
||||
webClient.get().uri(URI.create("/api/webclient-not-hc")).retrieve();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const hc = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'java-http-client' &&
|
||||
c.symbolRef.filePath.endsWith('CtorOverride.java'),
|
||||
);
|
||||
|
||||
// Only the override URI, exactly once; the overridden constructor URI and the
|
||||
// WebClient `.uri()` are not java-http-client contracts.
|
||||
expect(new Set(hc.map((c) => c.contractId))).toEqual(
|
||||
new Set(['http::GET::/api/override-wins']),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the last verb when a chain sets two (runtime last-wins) (#2268)', async () => {
|
||||
// Each verb-setter overwrites the previous at runtime, so a chain that sets
|
||||
// two verbs resolves to the one nearest the terminal — not the first found.
|
||||
const dir = path.join(tmpDir, 'java-http-two-verb');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'TwoVerb.java'),
|
||||
`
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class TwoVerb {
|
||||
void run(RequestBody body, HttpRequest.BodyPublisher pub) throws Exception {
|
||||
HttpRequest hc = HttpRequest.newBuilder().GET().uri(URI.create("/api/hc-two")).POST(pub).build();
|
||||
new Request.Builder().get().url("/api/ok-two").post(body).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const fromFile = contracts.filter(
|
||||
(c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('TwoVerb.java'),
|
||||
);
|
||||
|
||||
// Both resolve to the LAST verb (POST), not the first (GET).
|
||||
expect(new Set(fromFile.map((c) => `${c.meta.framework} ${c.contractId}`))).toEqual(
|
||||
new Set(['java-http-client http::POST::/api/hc-two', 'okhttp http::POST::/api/ok-two']),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 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`. Four
|
||||
|
|
@ -2678,26 +3286,14 @@ class OkClient(private val client: OkHttpClient) {
|
|||
});
|
||||
|
||||
itKotlinConsumer(
|
||||
'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)',
|
||||
'Kotlin OkHttp .url("/x").post(body) infers POST — verb-walk parity with Java (#2268)',
|
||||
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.
|
||||
// OkHttp encodes the HTTP verb on a sibling call (`.post(body)` / `.delete()`
|
||||
// / `.method("X")`), not on `.url(...)`. The Kotlin plugin now WALKS the
|
||||
// builder chain (`inferKotlinOkHttpMethod`) to recover it — the mirror of
|
||||
// the Java side's `inferOkHttpMethod`. So `Request.Builder().url("/api/users")`
|
||||
// `.post(body).build()` emits `http::POST::/api/users` (not GET) on `.kt`,
|
||||
// identical to `.java` (pinned by the Java↔Kotlin parity harness below).
|
||||
const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
|
@ -2717,24 +3313,102 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo
|
|||
);
|
||||
|
||||
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'),
|
||||
const fromThisFile = contracts.filter(
|
||||
(c) => c.role === 'consumer' && 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.
|
||||
// The sibling `.post(body)` is recovered: exactly one POST consumer, no GET.
|
||||
expect(fromThisFile).toHaveLength(1);
|
||||
expect(fromThisFile[0].contractId).toBe('http::GET::/api/users');
|
||||
expect(fromThisFile[0].meta.method).toBe('GET');
|
||||
expect(fromThisFile[0].contractId).toBe('http::POST::/api/users');
|
||||
expect(fromThisFile[0].meta.method).toBe('POST');
|
||||
expect(fromThisFile.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
// 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(
|
||||
'Kotlin OkHttp verb-walk: helpers, .method("X"), default GET, variable skip (#2268)',
|
||||
async () => {
|
||||
// Parity with the Java OkHttp verb cases: a verb helper resolves, a literal
|
||||
// `.method("X")` resolves, a bare `.build()` defaults to GET, and a
|
||||
// variable-bound `.method(verb)` is unresolvable → emits nothing.
|
||||
const dir = path.join(tmpDir, 'kotlin-okhttp-verbs');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkVerbs.kt'),
|
||||
`package com.example
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
class OkVerbs(private val body: RequestBody, private val verb: String) {
|
||||
fun run() {
|
||||
Request.Builder().url("/api/k-get").build()
|
||||
Request.Builder().url("/api/k-delete").delete().build()
|
||||
Request.Builder().url("/api/k-patch").method("PATCH", body).build()
|
||||
Request.Builder().url("/api/k-named").method(method = "REPORT", body = body).build()
|
||||
Request.Builder().url("/api/k-dyn").method(verb, body).build()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const okhttp = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'okhttp' &&
|
||||
c.symbolRef.filePath.endsWith('OkVerbs.kt'),
|
||||
);
|
||||
|
||||
// The variable-bound `.method(verb)` (`/api/k-dyn`) emits nothing; the
|
||||
// named-argument `.method(method = "REPORT")` resolves its literal verb.
|
||||
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
|
||||
new Set([
|
||||
'http::GET::/api/k-get',
|
||||
'http::DELETE::/api/k-delete',
|
||||
'http::PATCH::/api/k-patch',
|
||||
'http::REPORT::/api/k-named',
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
itKotlinConsumer(
|
||||
'Kotlin OkHttp: builder call before .url(), with anti-overreach (#2268)',
|
||||
async () => {
|
||||
// Parity with the Java OkHttp pre-`.url()` support: a builder call BEFORE
|
||||
// `.url()` is captured (the chain roots at Request.Builder), the verb-walk
|
||||
// scans the whole chain so a verb before `.url()` resolves, and a `.url(...)`
|
||||
// on an unrelated object does NOT emit.
|
||||
const dir = path.join(tmpDir, 'kotlin-okhttp-pre-url');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkPreUrl.kt'),
|
||||
`package com.example
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
class OkPreUrl(private val body: RequestBody, private val other: SomeClient) {
|
||||
fun run() {
|
||||
Request.Builder().addHeader("A", "b").url("/api/k-pre-url").build()
|
||||
Request.Builder().post(body).url("/api/k-verb-first").build()
|
||||
other.url("/api/k-not-okhttp").build()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const okhttp = contracts.filter(
|
||||
(c) =>
|
||||
c.role === 'consumer' &&
|
||||
c.meta.framework === 'okhttp' &&
|
||||
c.symbolRef.filePath.endsWith('OkPreUrl.kt'),
|
||||
);
|
||||
|
||||
// header-before-url → GET, verb-before-url → POST; `other.url(...)` emits nothing.
|
||||
expect(new Set(okhttp.map((c) => c.contractId))).toEqual(
|
||||
new Set(['http::GET::/api/k-pre-url', 'http::POST::/api/k-verb-first']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -4547,6 +5221,78 @@ class GatewayController : OrdersApi, UsersApi {
|
|||
);
|
||||
|
||||
const rows: ParityRow[] = [
|
||||
{
|
||||
name: 'OkHttp builder verb inference',
|
||||
files: [
|
||||
{
|
||||
name: 'OkClient',
|
||||
java: `
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class OkClient {
|
||||
void run(RequestBody body) {
|
||||
new Request.Builder().url("/api/things").post(body).build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
kotlin: `package com.example
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
class OkClient(private val body: RequestBody) {
|
||||
fun run() {
|
||||
Request.Builder().url("/api/things").post(body).build()
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
expected: [
|
||||
{
|
||||
role: 'consumer',
|
||||
contractId: 'http::POST::/api/things',
|
||||
framework: 'okhttp',
|
||||
confidence: 0.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'OkHttp verb + builder call before .url()',
|
||||
files: [
|
||||
{
|
||||
name: 'OkPre',
|
||||
java: `
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
class OkPre {
|
||||
void run(RequestBody body) {
|
||||
new Request.Builder().post(body).url("/api/pre").build();
|
||||
}
|
||||
}
|
||||
`,
|
||||
kotlin: `package com.example
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
class OkPre(private val body: RequestBody) {
|
||||
fun run() {
|
||||
Request.Builder().post(body).url("/api/pre").build()
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
expected: [
|
||||
{
|
||||
role: 'consumer',
|
||||
contractId: 'http::POST::/api/pre',
|
||||
framework: 'okhttp',
|
||||
confidence: 0.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '@RequestLine with @RequestMapping prefix fallback',
|
||||
files: [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue