From 7dae4fcc41d4a3be11ad2d22effc08d9e5c616ba Mon Sep 17 00:00:00 2001 From: JaysonAlbert Date: Fri, 29 May 2026 14:35:01 +0800 Subject: [PATCH] fix(group): attribute Spring interface routes to controllers (#1743) * fix(group): attribute Spring interface routes to controllers * test(group): normalize Spring route fixture paths --------- Co-authored-by: gfwangjie --- .../group/extractors/http-patterns/index.ts | 8 +- .../group/extractors/http-patterns/java.ts | 302 +++++++++++++++--- .../group/extractors/http-patterns/types.ts | 16 + .../group/extractors/http-route-extractor.ts | 86 ++++- .../unit/group/http-route-extractor.test.ts | 179 ++++++++++- 5 files changed, 532 insertions(+), 59 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/index.ts b/gitnexus/src/core/group/extractors/http-patterns/index.ts index 4cc758218..e5c03b68c 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/index.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/index.ts @@ -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 diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 030b6c9d1..48da46765 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -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 = { // 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>); + +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>); /** - * 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 { + const prefixByTypeId = new Map(); + 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 { + const routesByMethodId = new Map(); + 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 | 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(); + 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(); + 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(); - 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(); 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, }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index e1c85369d..fb4ab09cb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -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[]; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 37237242f..e14b9b701 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -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(); + const cachedInputs = new Map< + string, + { plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null + >(); + const projectDetections = new Map(); + 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 => { - 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 => { + 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 => { + if (projectScanComplete) return; + projectScanComplete = true; + const byPlugin = new Map(); + 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]; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8f4873f2e..297501a83 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -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 () => {