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

This commit is contained in:
Gergő Magyar 2026-05-29 07:37:57 +01:00 • committed by GitHub
commit dab73c6e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 532 additions and 59 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,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,
};

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

@ -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 });
@ -1537,7 +1714,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 () => {