diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 8dfb242bf..e1ab77e8b 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -226,7 +226,7 @@ export class HttpRouteExtractor implements ContractExtractor { } private async extractProvidersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', { + const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py,go}', { cwd: repoPath, ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], nodir: true, @@ -236,7 +236,9 @@ export class HttpRouteExtractor implements ContractExtractor { const content = readSafe(repoPath, rel); if (!content) continue; out.push(...this.scanSpringProviders(content, rel)); + out.push(...this.scanNestProviders(content, rel)); out.push(...this.scanExpressProviders(content, rel)); + out.push(...this.scanGoProviders(content, rel)); out.push(...this.scanLaravelProviders(content, rel)); out.push(...this.scanFastApiProviders(content, rel)); } @@ -312,6 +314,48 @@ export class HttpRouteExtractor implements ContractExtractor { return out; } + private scanNestProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const controllerMatch = content.match(/@Controller\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/); + const controllerPrefix = controllerMatch ? controllerMatch[1].replace(/\/+$/, '') : ''; + const re = /@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]?([^'"`)]*)['"`]?\s*\)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + const routePath = String(m[2] || ''); + const fullPath = controllerPrefix + ? `${controllerPrefix}/${routePath.replace(/^\/+/, '')}` + : routePath; + const pathNorm = normalizeHttpPath(fullPath.startsWith('/') ? fullPath : `/${fullPath}`); + const sub = content.slice(m.index); + const nameMatch = sub.match( + /(?:public|protected|private)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/, + ); + const name = nameMatch ? nameMatch[1] : m[0]; + out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8)); + } + return out; + } + + private scanGoProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const frameworkRe = + /(?:^|\W)\w+\.(GET|POST|PUT|DELETE|PATCH)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)/gim; + let m: RegExpExecArray | null; + while ((m = frameworkRe.exec(content)) !== null) { + out.push(this.makeProvider(filePath, m[1].toUpperCase(), normalizeHttpPath(m[2]), m[3], 0.8)); + } + + const handleFuncRe = + /(?:http|\w+)\.HandleFunc\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)\s*\)(?:\s*\.\s*Methods\s*\(\s*['"](\w+)['"]\s*\))?/gim; + while ((m = handleFuncRe.exec(content)) !== null) { + const method = (m[3] || 'GET').toUpperCase(); + out.push(this.makeProvider(filePath, method, normalizeHttpPath(m[1]), m[2], 0.8)); + } + + return out; + } + private makeProvider( filePath: string, method: string, @@ -407,7 +451,7 @@ export class HttpRouteExtractor implements ContractExtractor { } private async extractConsumersSourceScan(repoPath: string): Promise { - const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', { + const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte,py,java,go}', { cwd: repoPath, ignore: ['**/node_modules/**', '**/.git/**'], nodir: true, @@ -418,6 +462,9 @@ export class HttpRouteExtractor implements ContractExtractor { if (!content) continue; out.push(...this.scanFetchConsumers(content, rel)); out.push(...this.scanAxiosConsumers(content, rel)); + out.push(...this.scanPythonRequestsConsumers(content, rel)); + out.push(...this.scanJavaConsumers(content, rel)); + out.push(...this.scanGoConsumers(content, rel)); } return this.dedupeContracts(out); } @@ -439,18 +486,111 @@ export class HttpRouteExtractor implements ContractExtractor { return url.replace(/\$\{[^}]+\}/g, '{param}'); } + private normalizeConsumerPath(url: string): string { + const templated = this.templateToPattern(url.trim()); + let pathOnly = templated; + if (/^https?:\/\//i.test(templated)) { + try { + pathOnly = new URL(templated).pathname; + } catch { + pathOnly = templated.replace(/^https?:\/\/[^/]+/i, ''); + } + } + + const normalized = normalizeHttpPath(pathOnly || '/'); + const segments = normalized.split('/').filter(Boolean).map((segment) => { + if (/^\d+$/.test(segment)) return '{param}'; + return segment; + }); + return `/${segments.join('/')}`.replace(/\/+$/, '') || '/'; + } + private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] { const out: ExtractedContract[] = []; const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi; let m: RegExpExecArray | null; while ((m = re.exec(content)) !== null) { const method = m[1].toUpperCase(); - const pathNorm = normalizeHttpPath(this.templateToPattern(m[2])); + const pathNorm = this.normalizeConsumerPath(m[2]); out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); } return out; } + private scanPythonRequestsConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const methodRe = /requests\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = methodRe.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7)); + } + + const genericRe = + /requests\.request\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/gi; + while ((m = genericRe.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7)); + } + + return out; + } + + private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const restTemplateMethods: Array<[RegExp, string]> = [ + [/restTemplate\.getFor(?:Object|Entity)\s*\(\s*['"]([^'"]+)['"]/gi, 'GET'], + [/restTemplate\.postFor(?:Object|Entity)\s*\(\s*['"]([^'"]+)['"]/gi, 'POST'], + [/restTemplate\.put\s*\(\s*['"]([^'"]+)['"]/gi, 'PUT'], + [/restTemplate\.delete\s*\(\s*['"]([^'"]+)['"]/gi, 'DELETE'], + [/restTemplate\.patchForObject\s*\(\s*['"]([^'"]+)['"]/gi, 'PATCH'], + ]; + for (const [re, method] of restTemplateMethods) { + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, method, this.normalizeConsumerPath(m[1]), 0.7)); + } + } + + const webClientMethodRe = + /webClient\.method\s*\(\s*HttpMethod\.(GET|POST|PUT|DELETE|PATCH)\s*,\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = webClientMethodRe.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7)); + } + + const okHttpRe = + /new\s+Request\.Builder\s*\(\)\s*\.url\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*method\s*\(\s*['"](\w+)['"])?/gim; + while ((m = okHttpRe.exec(content)) !== null) { + out.push( + this.makeConsumer(filePath, (m[2] || 'GET').toUpperCase(), this.normalizeConsumerPath(m[1]), 0.7), + ); + } + + return out; + } + + private scanGoConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const httpMethodRe = /\bhttp\.(Get|Post|Head)\s*\(\s*['"]([^'"]+)['"]/gi; + let m: RegExpExecArray | null; + while ((m = httpMethodRe.exec(content)) !== null) { + const method = m[1].toUpperCase() === 'HEAD' ? 'GET' : m[1].toUpperCase(); + out.push(this.makeConsumer(filePath, method, this.normalizeConsumerPath(m[2]), 0.7)); + } + + const newRequestRe = + /\bhttp\.NewRequest\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/gi; + while ((m = newRequestRe.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7)); + } + + const restyRe = /\b\w+\.R\(\)\.(Get|Post|Put|Delete|Patch)\s*\(\s*['"]([^'"]+)['"]/gi; + while ((m = restyRe.exec(content)) !== null) { + out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7)); + } + + return out; + } + private makeConsumer( filePath: string, method: string, diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 2290c806a..c9db110f2 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -157,6 +157,89 @@ export default router; providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Go Gin and Echo route registrations', async () => { + const dir = path.join(tmpDir, 'go-frameworks'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func createOrder(c *gin.Context) {} +func listOrders(c echo.Context) error { return nil } + +func main() { + r := gin.Default() + r.POST("/api/orders/:id", createOrder) + + e := echo.New() + e.GET("/api/orders", listOrders) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'); + expect(ginRoute).toBeDefined(); + expect(ginRoute?.symbolName).toBe('createOrder'); + + const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders'); + expect(echoRoute).toBeDefined(); + expect(echoRoute?.symbolName).toBe('listOrders'); + }); + + it('extracts stdlib HandleFunc providers', async () => { + const dir = path.join(tmpDir, 'go-stdlib-provider'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'server.go'), + ` +package main + +func healthHandler(w http.ResponseWriter, r *http.Request) {} + +func main() { + http.HandleFunc("/api/health", healthHandler) +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health'); + expect(healthRoute).toBeDefined(); + expect(healthRoute?.symbolName).toBe('healthHandler'); + }); + + it('extracts NestJS controller decorators', async () => { + const dir = path.join(tmpDir, 'nestjs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'orders.controller.ts'), + ` +import { Controller, Patch } from '@nestjs/common'; + +@Controller('orders') +export class OrdersController { + @Patch(':id') + updateOrder() { + return {}; + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}'); + expect(patchRoute).toBeDefined(); + expect(patchRoute?.symbolName).toBe('updateOrder'); + }); }); describe('consumer extraction — fetch patterns', () => { @@ -206,6 +289,93 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), ).toBeDefined(); }); + + it('extracts Python requests calls', async () => { + const dir = path.join(tmpDir, 'python-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import requests + +def create_order(): + return requests.post("https://svc.local/api/orders/42", json={"id": 42}) +`, + ); + + 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/{param}'), + ).toBeDefined(); + }); + + it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { + const dir = path.join(tmpDir, 'java-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ApiClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import okhttp3.Request; + +class ApiClient { + void run(RestTemplate restTemplate, WebClient webClient) { + restTemplate.getForObject("/api/users/{id}", String.class, 42); + webClient.method(HttpMethod.PATCH, "/api/users/42"); + new Request.Builder().url("/api/orders/42").build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts Go stdlib and resty calls', async () => { + const dir = path.join(tmpDir, 'go-consumer'); + fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'cmd', 'client.go'), + ` +package main + +import ( + "net/http" + + "github.com/go-resty/resty/v2" +) + +func main() { + http.Get("/api/health") + client := resty.New() + client.R().Delete("/api/orders/42") +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/api/health')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + }); }); describe('provider extraction — Laravel', () => {