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

This commit is contained in:
Gergő Magyar 2026-05-29 06:12:09 +01:00 • committed by GitHub
commit 25dd0234dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 519 additions and 17 deletions

View file

@ -11,8 +11,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
/**
* Java HTTP plugin. Handles:
* - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations
* - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)`
* - Spring `RestTemplate.getForObject/...`, `exchange(...)`
* - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
* - OkHttp `new Request.Builder().url("...")`
* - OpenFeign interfaces with Spring MVC method annotations
* - Java / Apache HttpClient literal request construction
*
* The plugin runs two pattern bundles: one to collect class-level
* `@RequestMapping` prefixes keyed by the enclosing class node, and a
@ -73,6 +76,53 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OpenFeign interface-level prefixes ───────────────────
// Feign's `name`/`value` attributes identify a service, not an HTTP path,
// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign
// interface is also common and does carry a path prefix.
const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({
name: 'java-feign-interface-prefix',
language: Java,
patterns: [
{
meta: {},
query: `
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "FeignClient")
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key (#eq? @key "path")
value: (string_literal) @prefix))))) @interface
`,
},
{
meta: {},
query: `
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list (string_literal) @prefix)))) @interface
`,
},
{
meta: {},
query: `
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key (#match? @key "^(path|value)$")
value: (string_literal) @prefix))))) @interface
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
// Same dual-pattern approach: positional vs named argument. The named
// pattern restricts the annotation member name to `path`/`value` to
@ -116,6 +166,8 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
// RestTemplate.put → PUT
// RestTemplate.delete → DELETE
// RestTemplate.patchForObject → PATCH
// Source-scan only: receiver must be named exactly `restTemplate`.
// Fields, `this.restTemplate`, aliases, and other injection names are deferred.
const REST_TEMPLATE_TO_HTTP: Record<string, string> = {
getForObject: 'GET',
getForEntity: 'GET',
@ -146,22 +198,48 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<RestTemplateMeta>);
// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─
const WEB_CLIENT_PATTERNS = compilePatterns({
name: 'java-web-client',
const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({
name: 'java-rest-template-exchange',
language: Java,
patterns: [
{
meta: { framework: 'spring-rest-template' },
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "restTemplate")
name: (identifier) @method (#eq? @method "exchange")
arguments: (argument_list
. (string_literal) @path
(field_access
object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
field: (identifier) @http_method)))
`,
},
],
} satisfies LanguagePatterns<RestTemplateMeta>);
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
get: 'GET',
post: 'POST',
put: 'PUT',
delete: 'DELETE',
patch: 'PATCH',
};
const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({
name: 'java-web-client-short-form',
language: Java,
patterns: [
{
meta: {},
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "webClient")
name: (identifier) @method (#eq? @method "method")
arguments: (argument_list
(field_access
object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
field: (identifier) @http_method)
(string_literal) @path))
object: (method_invocation
object: (identifier) @obj (#eq? @obj "webClient")
name: (identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$")
arguments: (argument_list))
name: (identifier) @uri_method (#eq? @uri_method "uri")
arguments: (argument_list . (string_literal) @path))
`,
},
],
@ -188,6 +266,54 @@ const OK_HTTP_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({
name: 'java-http-client',
language: Java,
patterns: [
{
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)$"))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const APACHE_HTTP_CLIENT_TO_HTTP: Record<string, string> = {
HttpGet: 'GET',
HttpPost: 'POST',
HttpPut: 'PUT',
HttpDelete: 'DELETE',
HttpPatch: 'PATCH',
};
const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
name: 'java-apache-http-client',
language: Java,
patterns: [
{
meta: {},
query: `
(object_creation_expression
type: (type_identifier) @type (#match? @type "^Http(Get|Post|Put|Delete|Patch)$")
arguments: (argument_list . (string_literal) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Find the nearest enclosing class_declaration ancestor for a node, or
* null if the node is top-level. Tree-sitter's SyntaxNode.parent walks
@ -202,6 +328,29 @@ function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
return null;
}
function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'interface_declaration') return cur;
cur = cur.parent;
}
return null;
}
function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean {
for (const child of node.namedChildren) {
if (child.type !== 'modifiers') continue;
for (const modifier of child.namedChildren) {
if (modifier.type !== 'annotation') continue;
const nameNode = modifier.childForFieldName('name');
if (!nameNode) continue;
const simpleName = nameNode.text.split('.').pop();
if (nameNode.text === annotationName || simpleName === annotationName) return true;
}
}
return false;
}
/**
* Join a class-level prefix and a method-level path into a single URL
* path. Mirrors the semantics of the original regex implementation:
@ -231,6 +380,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
}
const feignPrefixByInterfaceId = new Map<number, string>();
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const interfaceNode = match.captures.interface;
if (!prefixNode || !interfaceNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id))
feignPrefixByInterfaceId.set(interfaceNode.id, prefix);
}
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
@ -241,6 +400,20 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
const enclosingInterface = findEnclosingInterface(methodNode);
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? '';
const fullPath = joinPath(prefix, rawPath);
out.push({
role: 'consumer',
framework: 'openfeign',
method: httpMethod,
path: fullPath,
name: nameNode?.text ?? null,
confidence: 0.7,
});
continue;
}
const enclosingClass = findEnclosingClass(methodNode);
const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
const fullPath = joinPath(prefix, rawPath);
@ -273,8 +446,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
// ─── Consumers: WebClient.method(HttpMethod.X, "path") ──────────
for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) {
for (const match of runCompiledPatterns(REST_TEMPLATE_EXCHANGE_PATTERNS, tree)) {
const httpMethodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!httpMethodNode || !pathNode) continue;
@ -282,7 +454,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
framework: 'spring-rest-template',
method: httpMethodNode.text.toUpperCase(),
path,
name: null,
@ -290,6 +462,28 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
// ─── Consumers: WebClient.get().uri("path") short form ─────────
// Source-scan only: receiver must be named exactly `webClient`.
// The real long-form chain `webClient.method(HttpMethod.X).uri("/x")`
// needs multi-hop chain analysis and is intentionally deferred.
for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_FORM_PATTERNS, tree)) {
const verbNode = match.captures.verb;
const pathNode = match.captures.path;
if (!verbNode || !pathNode) continue;
const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
method: httpMethod,
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const pathNode = match.captures.path;
@ -306,6 +500,44 @@ 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.
for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) {
const httpMethodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!httpMethodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'java-http-client',
method: httpMethodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.65,
});
}
// ─── Consumers: Apache HttpClient request constructors ──────────
for (const match of runCompiledPatterns(APACHE_HTTP_CLIENT_PATTERNS, tree)) {
const typeNode = match.captures.type;
const pathNode = match.captures.path;
if (!typeNode || !pathNode) continue;
const httpMethod = APACHE_HTTP_CLIENT_TO_HTTP[typeNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'apache-http-client',
method: httpMethod,
path,
name: null,
confidence: 0.65,
});
}
return out;
},
};

View file

@ -1364,7 +1364,7 @@ shadowed_module_client.get("/module-level-rebind-fp")
).toBeUndefined();
});
it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => {
it('extracts Java Spring RestTemplate, WebClient and OkHttp literal calls', async () => {
const dir = path.join(tmpDir, 'java-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
@ -1378,7 +1378,8 @@ import okhttp3.Request;
class ApiClient {
void run(RestTemplate restTemplate, WebClient webClient) {
restTemplate.getForObject("/api/users/{id}", String.class, 42);
webClient.method(HttpMethod.PATCH, "/api/users/42");
restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class);
webClient.post().uri("/api/users");
new Request.Builder().url("/api/orders/42").build();
}
}
@ -1390,11 +1391,280 @@ class ApiClient {
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}/details'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/api/users/{param}/details' &&
c.meta.framework === 'spring-rest-template' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/users' &&
c.meta.framework === 'spring-web-client' &&
c.confidence === 0.7,
),
).toBeDefined();
});
it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => {
const dir = path.join(tmpDir, 'java-web-client-long-form');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongFormClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.client.WebClient;
class LongFormClient {
void run(WebClient webClient) {
webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve();
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
).toBeUndefined();
});
it('extracts OpenFeign clients as consumers, not providers', async () => {
const dir = path.join(tmpDir, 'java-openfeign-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'OrderClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(name = "order-service", url = "\${order.service.url}", path = "/api")
interface OrderClient {
@GetMapping("/orders/{id}")
OrderDto getOrder(@PathVariable("id") String id);
@PostMapping(path = "/orders")
OrderDto createOrder(OrderDto body);
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/orders' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('OrderClient.java')),
).toBeUndefined();
});
it('extracts OpenFeign clients without an interface path prefix', async () => {
const dir = path.join(tmpDir, 'java-openfeign-no-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HealthClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
@FeignClient(name = "health-service")
interface HealthClient {
@GetMapping("/health")
String health();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(
consumers.find(
(c) =>
c.contractId === 'http::GET::/health' &&
c.meta.framework === 'openfeign' &&
c.confidence === 0.7,
),
).toBeDefined();
expect(
providers.find((c) => c.symbolRef.filePath.endsWith('HealthClient.java')),
).toBeUndefined();
});
it('does not treat @FeignClient text in an interface body as a Feign annotation', async () => {
const dir = path.join(tmpDir, 'java-non-feign-interface-text');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'NotFeignClient.java'),
`
import org.springframework.web.bind.annotation.GetMapping;
interface NotFeignClient {
String MARKER = "@FeignClient";
@GetMapping("/not-feign")
String call();
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const providers = contracts.filter((c) => c.role === 'provider');
expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined();
});
it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => {
const dir = path.join(tmpDir, 'java-openfeign-request-mapping-prefix');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'InventoryClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@FeignClient(name = "inventory-service")
@RequestMapping(path = "/api")
interface InventoryClient {
@GetMapping("/inventory/{id}")
InventoryDto getInventory(String id);
}
`,
);
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/inventory/{param}' &&
c.meta.framework === 'openfeign',
),
).toBeDefined();
});
it('prefers @FeignClient(path=...) over @RequestMapping prefixes on OpenFeign clients', async () => {
const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'PrecedenceClient.java'),
`
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@FeignClient(name = "order-service", path = "/feign-path")
@RequestMapping("/rm-path")
interface PrecedenceClient {
@GetMapping("/orders")
OrderDto getOrders();
}
`,
);
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::/feign-path/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined();
});
it('extracts Java and Apache HttpClient literal request construction', async () => {
const dir = path.join(tmpDir, 'java-http-client-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'HttpClients.java'),
`
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpPatch;
class HttpClients {
void run(HttpClient client) throws Exception {
HttpRequest get = HttpRequest.newBuilder()
.uri(URI.create("/api/users/1"))
.GET()
.build();
HttpRequest post = HttpRequest.newBuilder()
.uri(URI.create("/api/users"))
.POST(HttpRequest.BodyPublishers.ofString("{}"))
.build();
new HttpGet("/api/orders/2");
new HttpPost("/api/orders");
new HttpPut("/api/orders/3");
new HttpDelete("/api/orders/4");
new HttpPatch("/api/orders/5");
}
}
`,
);
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/users/{param}')).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/users' &&
c.meta.framework === 'java-http-client' &&
c.confidence === 0.65,
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find(
(c) =>
c.contractId === 'http::POST::/api/orders' &&
c.meta.framework === 'apache-http-client' &&
c.confidence === 0.65,
),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
});
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──