mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
Merge branch 'main' into codex/group-http-mappings-refresh
This commit is contained in:
commit
f8d1b53914
6 changed files with 774 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ 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:
|
||||
|
|
@ -46,31 +51,85 @@ 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
|
||||
(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
|
||||
[
|
||||
(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
|
||||
]
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -315,9 +374,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
|
|||
} 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;
|
||||
|
|
@ -337,20 +396,6 @@ function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | nu
|
|||
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:
|
||||
|
|
@ -364,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,
|
||||
|
|
@ -371,14 +594,7 @@ 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 prefixNode = match.captures.prefix;
|
||||
const classNode = match.captures.class;
|
||||
if (!prefixNode || !classNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
|
||||
}
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
|
||||
|
|
@ -415,7 +631,8 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
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',
|
||||
|
|
@ -540,4 +757,5 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
|
||||
return out;
|
||||
},
|
||||
scanProject: scanSpringProject,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -1561,7 +1738,7 @@ interface NotFeignClient {
|
|||
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();
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => {
|
||||
|
|
@ -1691,12 +1868,13 @@ class HttpClients {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──
|
||||
// ─── Kotlin consumers (RestTemplate / WebClient short+long / OkHttp) ──
|
||||
// Same shape as the Java consumer test above, but parsed by the
|
||||
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three
|
||||
// consumer flavors covered here (long-form WebClient
|
||||
// `webClient.method(HttpMethod.X).uri(...)` is intentionally
|
||||
// deferred to a follow-up — see kotlin.ts file header).
|
||||
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four
|
||||
// consumer flavors covered here: RestTemplate (#1855), WebClient
|
||||
// short form (#1855), OkHttp (#1855), and WebClient long form
|
||||
// (`webClient.method(HttpMethod.X).uri(...)`, this PR / #1884) —
|
||||
// see kotlin.ts file header for the full list.
|
||||
//
|
||||
// tree-sitter-kotlin is an optionalDependency. If the binding is
|
||||
// unavailable, `getPluginForFile` returns undefined for `.kt` and
|
||||
|
|
@ -1875,28 +2053,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>()
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
@ -1905,12 +2159,49 @@ class LegacyClient(private val webClient: WebClient) {
|
|||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// No consumer should be emitted from this file by the
|
||||
// current short-form query. Documented as a known limitation.
|
||||
const fromLegacy = consumers.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('LegacyClient.kt'),
|
||||
const fromThisFile = consumers.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('NoDoubleClient.kt'),
|
||||
);
|
||||
expect(fromLegacy).toHaveLength(0);
|
||||
expect(fromThisFile).toHaveLength(1);
|
||||
expect(fromThisFile[0].contractId).toBe('http::GET::/api/single');
|
||||
},
|
||||
);
|
||||
|
||||
itKotlinConsumer(
|
||||
'does NOT match Kotlin WebClient long form with variable-bound verb',
|
||||
async () => {
|
||||
// Anti-overreach: source-scan can't follow `val verb = HttpMethod.X`
|
||||
// back to the literal — that's a graph-aware concern. The long-form
|
||||
// query requires `(navigation_expression HttpMethod . verb)` as the
|
||||
// `value_argument` shape, so a bare `simple_identifier` (the
|
||||
// variable name) fails to match. Pin this so a future relaxation
|
||||
// of the value_argument shape cannot silently start guessing the
|
||||
// verb from arbitrary identifiers.
|
||||
const dir = path.join(tmpDir, 'kotlin-web-client-long-var-verb');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'VariableVerbClient.kt'),
|
||||
`package com.example
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.reactive.function.client.awaitBody
|
||||
|
||||
class VariableVerbClient(private val webClient: WebClient) {
|
||||
suspend fun run() {
|
||||
val verb = HttpMethod.PATCH
|
||||
val r = webClient.method(verb).uri("/api/dynamic").retrieve().awaitBody<String>()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const fromThisFile = consumers.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('VariableVerbClient.kt'),
|
||||
);
|
||||
expect(fromThisFile).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue