diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 920d499aa..5d452fd1f 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -55,6 +55,7 @@ const METHOD_ANNOTATION_TO_HTTP: Record = { interface SpringRouteBinding { method: string; path: string; + ownerPrefix?: string; } interface SpringMethodInfo { @@ -395,6 +396,25 @@ function joinPath(prefix: string, methodPath: string): string { return `/${cleanPrefix}/${cleanSub}`; } +function joinInheritedSpringPath( + controllerPrefix: string, + inheritedPath: string, + inheritedOwnerPrefix = '', +): string { + const joined = joinPath(controllerPrefix, inheritedPath); + const cleanPrefix = controllerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanOwnerPrefix = inheritedOwnerPrefix.replace(/^\/+/, '').replace(/\/+$/, ''); + const cleanInherited = inheritedPath.replace(/^\/+/, ''); + if (!cleanPrefix) return joined; + if ( + cleanPrefix === cleanOwnerPrefix && + (cleanInherited === cleanPrefix || cleanInherited.startsWith(`${cleanPrefix}/`)) + ) { + return `/${cleanInherited}`; + } + return joined; +} + function getNodeName(node: Parser.SyntaxNode): string | null { return node.childForFieldName('name')?.text ?? null; } @@ -634,6 +654,7 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[ const routes = method.routes.map((route) => ({ method: route.method, path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path, + ownerPrefix: type.classPrefix, })); if (routes.length > 0) methodMap.set(method.name, routes); } @@ -651,7 +672,7 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[ const routes = routeMap.get(method.name) ?? []; return routes.map((route) => ({ method: route.method, - path: joinPath(type.classPrefix, route.path), + path: joinInheritedSpringPath(type.classPrefix, route.path, route.ownerPrefix), })); }); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 18b2bebcd..52e6f8a49 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -952,6 +952,153 @@ public class UserController implements UserApi { ); }); + it('does not duplicate inherited Spring prefixes already present on the controller', async () => { + const dir = path.join(tmpDir, 'spring-inherited-prefix-dedup'); + 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/DataReleaseFacade.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/open/ai") +public interface DataReleaseFacade { + @GetMapping("/query") + Object query(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/BaseFacadeService.java'), + ` +package com.example.controller; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/open/ai") +public abstract class BaseFacadeService { +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'), + ` +package com.example.controller; +import com.example.rest.DataReleaseFacade; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/open/ai") +public class DataReleaseFacadeImpl extends BaseFacadeService implements DataReleaseFacade { + @Override + public Object query() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const queryRoute = providers.find((c) => c.contractId === 'http::GET::/open/ai/query'); + expect(queryRoute).toBeDefined(); + expect(toPosixPath(queryRoute!.symbolRef.filePath)).toBe( + 'src/controller/DataReleaseFacadeImpl.java', + ); + expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/open/ai/query')).toBe( + undefined, + ); + }); + + it('still combines distinct inherited Spring prefixes that share a leading segment', async () => { + const dir = path.join(tmpDir, 'spring-interface-shared-leading-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/DataReleaseApi.java'), + ` +package com.example.rest; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/open/ai") +public interface DataReleaseApi { + @GetMapping("/query") + Object query(); +} +`, + ); + + fs.writeFileSync( + path.join(dir, 'src/controller/DataReleaseFacadeImpl.java'), + ` +package com.example.controller; +import com.example.rest.DataReleaseApi; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/open") +public class DataReleaseFacadeImpl implements DataReleaseApi { + @Override + public Object query() { 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::/open/open/ai/query'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/open/ai/query')).toBeUndefined(); + }); + + it('keeps a controller prefix when a prefix-less interface method starts with the same path', async () => { + const dir = path.join(tmpDir, 'spring-interface-method-prefix-overlap'); + 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/{id}") + Object getUser(); +} +`, + ); + + 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("/users") +public class UserController implements UserApi { + @Override + public Object getUser() { 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::/users/users/{param}'), + ).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/users/{param}')).toBeUndefined(); + }); + 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 });