Merge branch 'main' into feat/query-exclude-tests-by-default

This commit is contained in:
jelsco 2026-05-29 06:33:08 -06:00 • committed by GitHub
commit d623b18096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1320 additions and 85 deletions

View file

@ -8,7 +8,13 @@ import { PYTHON_HTTP_PLUGIN } from './python.js';
import { PHP_HTTP_PLUGIN } from './php.js';
import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js';
export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js';
export type {
HttpDetection,
HttpFileDetections,
HttpLanguagePlugin,
HttpRole,
HttpScanInput,
} from './types.js';
/**
* File-extension → HTTP language plugin registry. The top-level

View file

@ -6,13 +6,21 @@ import {
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
import type {
HttpDetection,
HttpFileDetections,
HttpLanguagePlugin,
HttpScanInput,
} 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
@ -43,31 +51,132 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
// route prefixes — e.g. `produces = "application/json"` would corrupt
// every method route under that controller). The sibling
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
name: 'java-spring-class-prefix',
interface SpringRouteBinding {
method: string;
path: string;
}
interface SpringMethodInfo {
name: string;
routes: SpringRouteBinding[];
}
interface SpringTypeInfo {
filePath: string;
kind: 'class' | 'interface';
name: string;
classPrefix: string;
implementedInterfaces: string[];
isController: boolean;
methods: SpringMethodInfo[];
}
// ─── Provider: Spring class/interface-level @RequestMapping prefix ───
const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
name: 'java-spring-type-prefix',
language: Java,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list (string_literal) @prefix)))) @class
[
(class_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann (#eq? @ann "RequestMapping")
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
]
`,
},
{
meta: {},
query: `
(class_declaration
[
(class_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))))) @type
(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))))) @type
]
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
name: 'java-spring-type-declaration',
language: Java,
patterns: [
{
meta: {},
query: `
[
(class_declaration name: (identifier) @type_name) @type
(interface_declaration name: (identifier) @type_name) @type
]
`,
},
],
} 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))))) @class
value: (string_literal) @prefix))))) @interface
`,
},
],
@ -116,6 +225,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 +257,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,10 +325,58 @@ 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
* one level at a time.
* Find the nearest enclosing class/interface declaration ancestor for
* a node, or null if the node is top-level. Tree-sitter's
* SyntaxNode.parent walks one level at a time.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
@ -202,6 +387,15 @@ 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;
}
/**
* Join a class-level prefix and a method-level path into a single URL
* path. Mirrors the semantics of the original regex implementation:
@ -215,6 +409,184 @@ function joinPath(prefix: string, methodPath: string): string {
return `/${cleanPrefix}/${cleanSub}`;
}
function getNodeName(node: Parser.SyntaxNode): string | null {
return node.childForFieldName('name')?.text ?? null;
}
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
if (!modifiers) return false;
const allowed = new Set(typeof names === 'string' ? [names] : names);
const stack = [...modifiers.namedChildren];
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))
) {
return true;
}
stack.push(...cur.namedChildren);
}
return false;
}
function collectTypePrefixes(tree: Parser.Tree): Map<number, string> {
const prefixByTypeId = new Map<number, string>();
for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const typeNode = match.captures.type;
if (!prefixNode || !typeNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix);
}
return prefixByTypeId;
}
function collectMethodRoutes(tree: Parser.Tree): Map<number, SpringRouteBinding[]> {
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
const routes = routesByMethodId.get(methodNode.id) ?? [];
routes.push({ method: httpMethod, path: rawPath });
routesByMethodId.set(methodNode.id, routes);
}
return routesByMethodId;
}
function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
const visit = (node: Parser.SyntaxNode): void => {
for (const child of node.namedChildren) {
if (child.type === 'method_declaration') {
out.push(child);
continue;
}
if (
child !== typeNode &&
(child.type === 'class_declaration' || child.type === 'interface_declaration')
) {
continue;
}
visit(child);
}
};
visit(typeNode);
return out;
}
function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
const interfacesNode = typeNode.childForFieldName('interfaces');
if (!interfacesNode) return [];
const out: string[] = [];
const visit = (node: Parser.SyntaxNode): void => {
if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') {
out.push(node.text.split('.').pop() ?? node.text);
return;
}
for (const child of node.namedChildren) visit(child);
};
visit(interfacesNode);
return out;
}
function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] {
const prefixByTypeId = collectTypePrefixes(tree);
const routesByMethodId = collectMethodRoutes(tree);
const out: SpringTypeInfo[] = [];
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
const typeNode = match.captures.type;
const typeNameNode = match.captures.type_name;
if (!typeNode || !typeNameNode) continue;
const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class';
const methods = collectDirectMethods(typeNode)
.map((methodNode) => ({
name: getNodeName(methodNode),
routes: routesByMethodId.get(methodNode.id) ?? [],
}))
.filter((method): method is SpringMethodInfo => method.name !== null);
out.push({
filePath,
kind,
name: typeNameNode.text,
classPrefix: prefixByTypeId.get(typeNode.id) ?? '',
implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [],
isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']),
methods,
});
}
return out;
}
function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[] {
const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree));
const interfaceRoutes = new Map<string, Map<string, SpringRouteBinding[]> | null>();
for (const type of types) {
if (type.kind !== 'interface') continue;
if (interfaceRoutes.has(type.name)) {
interfaceRoutes.set(type.name, null);
continue;
}
const methodMap = new Map<string, SpringRouteBinding[]>();
for (const method of type.methods) {
const routes = method.routes.map((route) => ({
method: route.method,
path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path,
}));
if (routes.length > 0) methodMap.set(method.name, routes);
}
interfaceRoutes.set(type.name, methodMap);
}
const detectionsByFile = new Map<string, HttpDetection[]>();
for (const type of types) {
if (type.kind !== 'class' || !type.isController) continue;
for (const method of type.methods) {
if (method.routes.length > 0) continue;
const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => {
const routeMap = interfaceRoutes.get(interfaceName);
if (!routeMap) return [];
const routes = routeMap.get(method.name) ?? [];
return routes.map((route) => ({
method: route.method,
path: joinPath(type.classPrefix, route.path),
}));
});
for (const route of inheritedRoutes) {
const detections = detectionsByFile.get(type.filePath) ?? [];
detections.push({
role: 'provider',
framework: 'spring',
method: route.method,
path: route.path,
name: method.name,
confidence: 0.8,
});
detectionsByFile.set(type.filePath, detections);
}
}
}
return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
filePath,
detections,
}));
}
export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'java-http',
language: Java,
@ -222,13 +594,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
const out: HttpDetection[] = [];
// ─── Providers: Spring class prefix + method annotations ────────
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
const prefixByTypeId = collectTypePrefixes(tree);
const feignPrefixByInterfaceId = new Map<number, string>();
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
const interfaceNode = match.captures.interface;
if (!prefixNode || !interfaceNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id))
feignPrefixByInterfaceId.set(interfaceNode.id, prefix);
}
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
@ -241,8 +616,23 @@ 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) ?? '') : '';
if (!enclosingClass) continue;
const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
const fullPath = joinPath(prefix, rawPath);
out.push({
role: 'provider',
@ -273,8 +663,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 +671,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 +679,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 +717,45 @@ 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;
},
scanProject: scanSpringProject,
};

View file

@ -17,18 +17,22 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* named annotation arguments (`@GetMapping(value = "/x")` and
* `@GetMapping(path = "/x")`) are supported.
*
* **Consumers** (this PR) — three call-site patterns common in Kotlin
* **Consumers** — four call-site patterns common in Kotlin
* Spring projects:
*
* 1. `restTemplate.getForObject("/x", ...)` and friends
* 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop)
* 3. `Request.Builder().url("/x")` (OkHttp)
* 1. `restTemplate.getForObject("/x", ...)` and friends (#1855)
* 2. `webClient.get().uri("/x")` — short form (#1855)
* 3. `Request.Builder().url("/x")` — OkHttp (#1855)
* 4. `webClient.method(HttpMethod.X).uri("/y")` — long form (this PR)
*
* The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is
* intentionally deferred to a follow-up: it requires walk-up logic
* to recover the verb from a sibling `call_expression`, and we can
* land 80% of real-world Kotlin Spring consumer coverage with the
* three simpler patterns above.
* The long form puts the verb on a sibling `call_expression` two hops
* away from the path. Rather than introducing imperative walk-up logic,
* we use a single deeper tree-sitter query that matches the full chain
* structurally — see `WEB_CLIENT_LONG_PATTERNS` below. The verb is
* captured directly as the `simple_identifier` of `HttpMethod.X`, so
* variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
* are intentionally NOT picked up — those need a graph-aware resolver
* and are out of scope for source-scan.
*
* tree-sitter-kotlin (fwcd) AST shapes used here:
* class_declaration
@ -109,6 +113,16 @@ const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
patch: 'PATCH',
};
/**
* Allowed HTTP verbs for the WebClient long-form path
* `webClient.method(HttpMethod.X).uri("/y")`. Compiled once at module
* load (instead of inside the scan loop) per maintainer feedback on
* PR #1884. Mirrors the keys of `WEB_CLIENT_SHORT_TO_HTTP` above —
* keeping HEAD/OPTIONS/TRACE intentionally excluded for symmetry
* with the short form and the Java plugin.
*/
const WEB_CLIENT_LONG_VERB_RE = /^(GET|POST|PUT|DELETE|PATCH)$/;
/**
* Build the plugin only if the Kotlin grammar is available. Compiling
* the queries against a null grammar would throw at module load time
@ -265,8 +279,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
// - outer call's first value_argument is a string literal
//
// The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain
// uses an extra navigation hop and an enum field access — it's
// intentionally out of scope here (see file header).
// uses an extra navigation hop and an enum field access — handled
// by `WEB_CLIENT_LONG_PATTERNS` below, separately so each query is
// straightforward to reason about.
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
name: 'kotlin-web-client-short',
language,
@ -290,6 +305,59 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: Spring WebClient (long form) ───────────────────────────
// The fluent long form passes the verb as a `HttpMethod.X` enum field
// access through `.method(...)`, then carries the path on a separate
// `.uri(...)` hop further down the chain:
//
// webClient.method(HttpMethod.GET).uri("/x").retrieve().awaitBody<T>()
//
// Compared to the short form there are two extra structural hops:
// - the inner `.method(...)` `call_expression` has a `value_argument`
// whose payload is itself a `navigation_expression` (HttpMethod → .GET)
// - the outer `.uri(...)` is reached via one more
// `navigation_expression` wrapping that inner call
//
// We capture the verb at the `simple_identifier` under `HttpMethod`'s
// `navigation_suffix`. That `simple_identifier` is the literal field
// name (`GET`, `POST`, ...) used in source — Kotlin enum fields by
// convention are upper-case, matching `HttpMethod` from
// `org.springframework.http`. We forward the captured text as-is.
//
// Variable-bound verbs (`val verb = HttpMethod.PATCH; webClient.method(verb)...`)
// do NOT match — they fail the `(navigation_expression ...)` shape
// because the value_argument carries a bare `simple_identifier` instead
// of a `HttpMethod.X` field access. This is intentional: source-scan
// can't follow the binding without graph context. Pinned by an
// anti-overreach test in the consumer suite.
const WEB_CLIENT_LONG_PATTERNS = compilePatterns({
name: 'kotlin-web-client-long',
language,
patterns: [
{
meta: {},
query: `
(call_expression
(navigation_expression
(call_expression
(navigation_expression
(simple_identifier) @obj (#eq? @obj "webClient")
(navigation_suffix
(simple_identifier) @method_call (#eq? @method_call "method")))
(call_suffix
(value_arguments
. (value_argument
(navigation_expression
(simple_identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
(navigation_suffix (simple_identifier) @verb))))))
(navigation_suffix (simple_identifier) @uri (#eq? @uri "uri")))
(call_suffix
(value_arguments . (value_argument . (string_literal) @path))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
// Kotlin parses `Request.Builder()` as a `call_expression` whose
// callee is a `navigation_expression` (Request → .Builder), NOT as
@ -437,6 +505,33 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
});
}
// ─── Consumers: WebClient long form (.method(HttpMethod.X) → .uri) ─
for (const match of runCompiledPatterns(WEB_CLIENT_LONG_PATTERNS, tree)) {
const verbNode = match.captures.verb;
const pathNode = match.captures.path;
if (!verbNode || !pathNode) continue;
// The captured text is the literal `HttpMethod.X` field name.
// Spring's `org.springframework.http.HttpMethod` defines GET,
// POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE — we only
// emit for the five verbs we already handle elsewhere, so
// exotic ones are silently skipped (consistent with the
// short form's WEB_CLIENT_SHORT_TO_HTTP guard). The accepted
// verb regex is hoisted to module scope (see
// `WEB_CLIENT_LONG_VERB_RE` near the top of this file).
const verbText = verbNode.text;
if (!WEB_CLIENT_LONG_VERB_RE.test(verbText)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'spring-web-client',
method: verbText,
path,
name: null,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const pathNode = match.captures.path;

View file

@ -40,6 +40,16 @@ export interface HttpDetection {
confidence: number;
}
export interface HttpScanInput {
filePath: string;
tree: Parser.Tree;
}
export interface HttpFileDetections {
filePath: string;
detections: HttpDetection[];
}
/**
* One language-scoped HTTP plugin. The plugin owns the tree-sitter
* grammar and the `scan` function that translates a parsed tree into
@ -95,4 +105,10 @@ export interface HttpLanguagePlugin {
* single-file plugins can keep their unary `scan(tree)` shape.
*/
scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[];
/**
* Optional project-level scan hook for language rules that require
* multiple files, such as Java controllers inheriting Spring mappings
* from annotated interfaces.
*/
scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[];
}

View file

@ -6,7 +6,13 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
import {
getPluginForFile,
HTTP_SCAN_GLOB,
type HttpDetection,
type HttpLanguagePlugin,
type HttpScanInput,
} from './http-patterns/index.js';
/**
* Language-agnostic orchestrator for HTTP route (provider + consumer)
@ -160,6 +166,12 @@ export class HttpRouteExtractor implements ContractExtractor {
// both graph-assisted enrichment and source-scan emission.
const parser = new Parser();
const cachedDetections = new Map<string, HttpDetection[]>();
const cachedInputs = new Map<
string,
{ plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null
>();
const projectDetections = new Map<string, HttpDetection[]>();
let projectScanComplete = false;
// Per-plugin cross-file context (e.g. Python's FastAPI router →
// include_router(prefix=...) map). Built lazily on first
@ -189,32 +201,50 @@ export class HttpRouteExtractor implements ContractExtractor {
}
};
const getDetections = async (rel: string): Promise<HttpDetection[]> => {
const cached = cachedDetections.get(rel);
if (cached) return cached;
const getScanInput = async (
rel: string,
): Promise<{
plugin: HttpLanguagePlugin;
input: HttpScanInput;
repoContext: unknown;
} | null> => {
if (cachedInputs.has(rel)) return cachedInputs.get(rel) ?? null;
const plugin = getPluginForFile(rel);
if (!plugin) {
cachedDetections.set(rel, []);
return [];
cachedInputs.set(rel, null);
return null;
}
const repoContext = await ensureRepoContext(plugin);
const content = readSafe(repoPath, rel);
if (!content) {
cachedDetections.set(rel, []);
return [];
cachedInputs.set(rel, null);
return null;
}
try {
parser.setLanguage(plugin.language);
const tree = parseSourceSafe(parser, content);
const detections = plugin.scan(tree, repoContext, rel);
cachedDetections.set(rel, detections);
return detections;
const input = { filePath: rel, tree };
const item = { plugin, input, repoContext };
cachedInputs.set(rel, item);
return item;
} catch {
cachedDetections.set(rel, []);
return [];
cachedInputs.set(rel, null);
return null;
}
};
const getDetections = async (rel: string): Promise<HttpDetection[]> => {
const cached = cachedDetections.get(rel);
if (cached) return cached;
const scanInput = await getScanInput(rel);
const ownDetections = scanInput
? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel)
: [];
const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])];
cachedDetections.set(rel, detections);
return detections;
};
// Glob the source-scan file list at most once per extract() —
// both provider and consumer fallback paths share the same list.
let scannedFiles: string[] | null = null;
@ -224,20 +254,46 @@ export class HttpRouteExtractor implements ContractExtractor {
return scannedFiles;
};
const collectProjectDetections = async (files: string[]): Promise<void> => {
if (projectScanComplete) return;
projectScanComplete = true;
const byPlugin = new Map<HttpLanguagePlugin, HttpScanInput[]>();
for (const rel of files) {
const scanInput = await getScanInput(rel);
if (!scanInput?.plugin.scanProject) continue;
const items = byPlugin.get(scanInput.plugin) ?? [];
items.push(scanInput.input);
byPlugin.set(scanInput.plugin, items);
}
for (const [plugin, inputs] of byPlugin) {
const results = plugin.scanProject?.(inputs) ?? [];
for (const result of results) {
const existing = projectDetections.get(result.filePath) ?? [];
projectDetections.set(result.filePath, [...existing, ...result.detections]);
}
}
cachedDetections.clear();
};
const files = await getScannedFiles();
await collectProjectDetections(files);
const graphProviders =
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
// Source scan always runs to capture routes in languages/files not covered
// by graph edges; the glob and per-file parse results are cached above.
const providers = this.mergeGraphAndSourceContracts(
graphProviders,
await this.extractProvidersSourceScan(await getScannedFiles(), getDetections),
await this.extractProvidersSourceScan(files, getDetections),
);
const graphConsumers =
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
const consumers = this.mergeGraphAndSourceContracts(
graphConsumers,
await this.extractConsumersSourceScan(await getScannedFiles(), getDetections),
await this.extractConsumersSourceScan(files, getDetections),
);
return [...providers, ...consumers];

View file

@ -39,6 +39,56 @@ import {
interpretGoTypeBinding,
} from './go/index.js';
const GO_BUILT_INS: ReadonlySet<string> = new Set([
// built-in functions
'make',
'new',
'len',
'cap',
'append',
'copy',
'delete',
'close',
'panic',
'recover',
'print',
'println',
'complex',
'real',
'imag',
'clear',
'min',
'max',
// built-in types
'error',
'bool',
'string',
'int',
'int8',
'int16',
'int32',
'int64',
'uint',
'uint8',
'uint16',
'uint32',
'uint64',
'uintptr',
'float32',
'float64',
'complex64',
'complex128',
'byte',
'rune',
'any',
'comparable',
// built-in values
'true',
'false',
'nil',
'iota',
]);
export const goProvider = defineLanguage({
id: SupportedLanguages.Go,
extensions: ['.go'],
@ -92,6 +142,7 @@ export const goProvider = defineLanguage({
variableExtractor: createVariableExtractor(goVariableConfig),
classExtractor: createClassExtractor(goClassConfig),
heritageExtractor: createHeritageExtractor(goHeritageConfig),
builtInNames: GO_BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitGoScopeCaptures,

View file

@ -41,6 +41,8 @@ describe('HttpRouteExtractor', () => {
});
});
const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/');
describe('provider extraction — graph-first (Strategy A)', () => {
it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
const dir = path.join(tmpDir, 'graph-first');
@ -832,6 +834,181 @@ class UserController {
},
);
it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => {
const dir = path.join(tmpDir, 'spring-interface-only');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/DepartmentApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/departments")
public interface DepartmentApi {
@GetMapping("")
Object list();
@GetMapping("/{name}")
Object getByName(@PathVariable String name);
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers).toHaveLength(0);
});
it('inherits Spring interface route mappings when controller methods omit annotations', async () => {
const dir = path.join(tmpDir, 'spring-interface-inherited-methods');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/StatusApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/status")
public interface StatusApi {
@GetMapping("")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/StatusController.java'),
`
package com.example.controller;
import com.example.rest.StatusApi;
import org.springframework.web.bind.annotation.*;
@RestController
public class StatusController implements StatusApi {
@Override
public Object getStatus() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status');
expect(statusRoute).toBeDefined();
expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe(
'src/controller/StatusController.java',
);
expect(statusRoute!.symbolName).toBe('getStatus');
expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength(
0,
);
});
it('combines controller class mapping with inherited interface method mapping', async () => {
const dir = path.join(tmpDir, 'spring-interface-controller-prefix');
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/rest/UserApi.java'),
`
package com.example.rest;
import org.springframework.web.bind.annotation.*;
public interface UserApi {
@GetMapping("/users")
Object listUsers();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/UserController.java'),
`
package com.example.controller;
import com.example.rest.UserApi;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class UserController implements UserApi {
@Override
public Object listUsers() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const usersRoute = providers.find((c) => c.contractId === 'http::GET::/api/users');
expect(usersRoute).toBeDefined();
expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe(
'src/controller/UserController.java',
);
});
it('skips ambiguous inherited routes when interfaces share a simple name', async () => {
const dir = path.join(tmpDir, 'spring-interface-simple-name-collision');
fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true });
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/a/StatusApi.java'),
`
package com.example.a;
import org.springframework.web.bind.annotation.*;
public interface StatusApi {
@GetMapping("/a/status")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/b/StatusApi.java'),
`
package com.example.b;
import org.springframework.web.bind.annotation.*;
public interface StatusApi {
@GetMapping("/b/status")
Object getStatus();
}
`,
);
fs.writeFileSync(
path.join(dir, 'src/controller/StatusController.java'),
`
package com.example.controller;
import com.example.a.StatusApi;
import org.springframework.web.bind.annotation.*;
@RestController
public class StatusController implements StatusApi {
@Override
public Object getStatus() { return null; }
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined();
expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined();
expect(
providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')),
).toHaveLength(0);
});
it('extracts Express router.get patterns', async () => {
const dir = path.join(tmpDir, 'express');
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
@ -1364,7 +1541,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 +1555,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,19 +1568,289 @@ 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();
});
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──
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')).toBeUndefined();
});
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+long / OkHttp) ──
// Same shape as the Java consumer test above, but parsed by the
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three
// consumer flavors covered here (long-form WebClient
// `webClient.method(HttpMethod.X).uri(...)` is intentionally
// deferred to a follow-up — see kotlin.ts file header).
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four
// consumer flavors covered here: RestTemplate (#1855), WebClient
// short form (#1855), OkHttp (#1855), and WebClient long form
// (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) —
// see kotlin.ts file header for the full list.
//
// tree-sitter-kotlin is an optionalDependency. If the binding is
// unavailable, `getPluginForFile` returns undefined for `.kt` and
@ -1581,28 +2029,104 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo
},
);
itKotlinConsumer('extracts Kotlin WebClient long form GET', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-get');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongGetClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class LongGetClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/users").retrieve().awaitBody<User>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const route = consumers.find((c) => c.contractId === 'http::GET::/api/users');
expect(route).toBeDefined();
expect(route!.meta.framework).toBe('spring-web-client');
});
itKotlinConsumer('extracts Kotlin WebClient long form POST/PUT/DELETE/PATCH', async () => {
const dir = path.join(tmpDir, 'kotlin-web-client-long-verbs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LongVerbClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import org.springframework.web.reactive.function.client.awaitBodilessEntity
class LongVerbClient(private val webClient: WebClient) {
suspend fun run() {
webClient.method(HttpMethod.POST).uri("/api/orders").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.PUT).uri("/api/orders/1").retrieve().awaitBody<Order>()
webClient.method(HttpMethod.DELETE).uri("/api/orders/2").retrieve().awaitBodilessEntity()
webClient.method(HttpMethod.PATCH).uri("/api/orders/3").retrieve().awaitBody<Order>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
).toBeDefined();
// All four should be tagged as `spring-web-client` so polyglot
// repos coalesce on the same framework key as the short form.
// The fixture is fully deterministic — exactly 4 long-form calls,
// no short-form / RestTemplate / OkHttp calls mixed in — so an
// exact count is meaningful (DoD §2.7). If a future change
// accidentally emits a 5th consumer (e.g. duplicate query firing,
// or a regressed receiver constraint matching unrelated calls),
// this assertion catches it.
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
expect(wcConsumers).toHaveLength(4);
});
itKotlinConsumer(
'does NOT match Kotlin WebClient long form (deferred to follow-up)',
'short-form query does NOT also fire on Kotlin WebClient long form (no double-emit)',
async () => {
// Anti-overreach: confirm the short-form query does NOT
// accidentally fire on the long-form chain
// `webClient.method(HttpMethod.GET).uri(...)`. The long form
// is intentionally unsupported in this PR; if a future change
// to the short-form query starts capturing it we want a loud
// signal here. Long-form support will arrive in a follow-up
// with a dedicated query + verb walk-up helper.
const dir = path.join(tmpDir, 'kotlin-web-client-long');
// The long-form query handles `webClient.method(HttpMethod.X).uri(...)`,
// and the short-form query handles `webClient.get().uri(...)`. Both
// queries carry sibling `(navigation_suffix (simple_identifier) @verb)`
// constraints — short form requires the verb name itself
// (`get`/`post`/...), long form requires the literal name
// `method`. The two are disjoint.
//
// This test pins that disjointness: a single `.method(HttpMethod.GET)`
// call must emit ONE consumer, not two (one from each query).
const dir = path.join(tmpDir, 'kotlin-web-client-long-no-double');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'LegacyClient.kt'),
path.join(dir, 'src', 'NoDoubleClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class LegacyClient(private val webClient: WebClient) {
class NoDoubleClient(private val webClient: WebClient) {
suspend fun run() {
val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody<String>()
webClient.method(HttpMethod.GET).uri("/api/single").retrieve().awaitBody<String>()
}
}
`,
@ -1611,12 +2135,49 @@ class LegacyClient(private val webClient: WebClient) {
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
// No consumer should be emitted from this file by the
// current short-form query. Documented as a known limitation.
const fromLegacy = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('LegacyClient.kt'),
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('NoDoubleClient.kt'),
);
expect(fromLegacy).toHaveLength(0);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
},
);
itKotlinConsumer(
'does NOT match Kotlin WebClient long form with variable-bound verb',
async () => {
// Anti-overreach: source-scan can't follow `val verb = HttpMethod.X`
// back to the literal — that's a graph-aware concern. The long-form
// query requires `(navigation_expression HttpMethod . verb)` as the
// `value_argument` shape, so a bare `simple_identifier` (the
// variable name) fails to match. Pin this so a future relaxation
// of the value_argument shape cannot silently start guessing the
// verb from arbitrary identifiers.
const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'VariableVerbClient.kt'),
`package com.example
import org.springframework.http.HttpMethod
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
class VariableVerbClient(private val webClient: WebClient) {
suspend fun run() {
val verb = HttpMethod.PATCH
val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
const fromThisFile = consumers.filter((c) =>
c.symbolRef.filePath.endsWith('VariableVerbClient.kt'),
);
expect(fromThisFile).toHaveLength(0);
},
);