From 4fa395f4b60393a16e949429ac13ebf8850f5fab Mon Sep 17 00:00:00 2001 From: ivkond Date: Thu, 2 Apr 2026 00:40:12 +0300 Subject: [PATCH] feat(group): add service boundary detection and contract extractors Service communication detection for microservice monorepos: - ServiceBoundaryDetector: auto-detects service boundaries via markers (package.json, go.mod, Dockerfile, pom.xml, Cargo.toml, build.gradle, pyproject.toml, etc.) - HttpRouteExtractor: graph-assisted (Strategy A) with source-scan fallback (Strategy B) for Spring, Express, Laravel, FastAPI providers and fetch/axios consumers - GrpcExtractor: parses .proto files, detects Go/Java/Python/TS gRPC servers (RegisterXxxServer, @GrpcService, add_XxxServicer_to_server, @GrpcMethod) and clients (NewXxxClient, newBlockingStub, XxxStub) - TopicExtractor: Kafka (@KafkaListener, producer.send), RabbitMQ (@RabbitListener, channel.publish/consume), NATS (nc.Subscribe/Publish) across Java, Node, Go, and Python Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/group/extractors/grpc-extractor.ts | 332 ++++++++++++ .../group/extractors/http-route-extractor.ts | 475 ++++++++++++++++++ .../core/group/extractors/topic-extractor.ts | 277 ++++++++++ .../core/group/service-boundary-detector.ts | 160 ++++++ .../test/unit/group/grpc-extractor.test.ts | 275 ++++++++++ .../unit/group/http-route-extractor.test.ts | 366 ++++++++++++++ .../group/service-boundary-detector.test.ts | 215 ++++++++ .../test/unit/group/topic-extractor.test.ts | 313 ++++++++++++ 8 files changed, 2413 insertions(+) create mode 100644 gitnexus/src/core/group/extractors/grpc-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/http-route-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/topic-extractor.ts create mode 100644 gitnexus/src/core/group/service-boundary-detector.ts create mode 100644 gitnexus/test/unit/group/grpc-extractor.test.ts create mode 100644 gitnexus/test/unit/group/http-route-extractor.test.ts create mode 100644 gitnexus/test/unit/group/service-boundary-detector.test.ts create mode 100644 gitnexus/test/unit/group/topic-extractor.test.ts diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts new file mode 100644 index 000000000..465d1f2bb --- /dev/null +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -0,0 +1,332 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function contractId(pkg: string, service: string, method: string): string { + const prefix = pkg ? `${pkg}.${service}` : service; + return `grpc::${prefix}/${method}`; +} + +function serviceOnlyContractId(serviceName: string): string { + return `grpc::${serviceName}/*`; +} + +function makeContract( + cid: string, + role: 'provider' | 'consumer', + filePath: string, + symbolName: string, + confidence: number, + meta: Record, +): ExtractedContract { + return { + contractId: cid, + type: 'grpc', + role, + symbolUid: '', + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, + symbolName, + confidence, + meta: { ...meta, extractionStrategy: 'source_scan' }, + }; +} + +export class GrpcExtractor implements ContractExtractor { + type = 'grpc' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + _dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + const out: ExtractedContract[] = []; + + // Proto files — definitive provider source + const protoFiles = await glob('**/*.proto', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'], + nodir: true, + }); + for (const rel of protoFiles) { + const content = readSafe(repoPath, rel); + if (content) out.push(...this.parseProtoFile(content, rel)); + } + + // Source files — server/client detection + const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + for (const rel of sourceFiles) { + const content = readSafe(repoPath, rel); + if (!content) continue; + const ext = path.extname(rel).toLowerCase(); + + if (ext === '.go') { + out.push(...this.scanGoProviders(content, rel)); + out.push(...this.scanGoConsumers(content, rel)); + } else if (ext === '.java') { + out.push(...this.scanJavaProviders(content, rel)); + out.push(...this.scanJavaConsumers(content, rel)); + } else if (ext === '.py') { + out.push(...this.scanPythonProviders(content, rel)); + out.push(...this.scanPythonConsumers(content, rel)); + } else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { + out.push(...this.scanTsProviders(content, rel)); + } + } + + return this.dedupe(out); + } + + private parseProtoFile(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m); + const pkg = pkgMatch ? pkgMatch[1] : ''; + + const serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs; + let svcMatch: RegExpExecArray | null; + while ((svcMatch = serviceRe.exec(content)) !== null) { + const serviceName = svcMatch[1]; + const body = svcMatch[2]; + + const rpcRe = /rpc\s+(\w+)\s*\(/g; + let rpcMatch: RegExpExecArray | null; + while ((rpcMatch = rpcRe.exec(body)) !== null) { + const methodName = rpcMatch[1]; + const cid = contractId(pkg, serviceName, methodName); + out.push( + makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, { + package: pkg, + service: serviceName, + method: methodName, + source: 'proto', + }), + ); + } + } + + return out; + } + + private scanGoProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + // pb.RegisterXxxServer( + const registerRe = /\w+\.Register(\w+)Server\s*\(/g; + let m: RegExpExecArray | null; + while ((m = registerRe.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `Register${serviceName}Server`, + 0.8, + { service: serviceName, source: 'go_register' }, + ), + ); + } + + // pb.UnimplementedXxxServer + const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g; + while ((m = unimplRe.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `Unimplemented${serviceName}Server`, + 0.8, + { service: serviceName, source: 'go_unimplemented' }, + ), + ); + } + + return out; + } + + private scanGoConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /\w+\.New(\w+)Client\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'consumer', + filePath, + `New${serviceName}Client`, + 0.7, + { service: serviceName, source: 'go_client' }, + ), + ); + } + return out; + } + + private scanJavaProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + // @GrpcService + if (content.includes('@GrpcService')) { + const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/; + const m = content.match(implBaseRe); + if (m) { + out.push( + makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, { + service: m[1], + source: 'java_grpc_service', + }), + ); + } else { + // Try extracting service name from class name + const classRe = + /class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/; + const cm = content.match(classRe); + if (cm) { + const svcName = cm[2].replace(/Grpc$/, ''); + out.push( + makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, { + service: svcName, + source: 'java_grpc_service', + }), + ); + } + } + } + + // extends XxxImplBase (without @GrpcService) + if (!content.includes('@GrpcService')) { + const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/; + const m = content.match(implRe); + if (m) { + const svcName = m[2] || m[1].replace(/Grpc$/, ''); + out.push( + makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, { + service: svcName, + source: 'java_impl_base', + }), + ); + } + } + + return out; + } + + private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // XxxGrpc.newBlockingStub( or XxxGrpc.newStub( + const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'consumer', + filePath, + `${serviceName}Stub`, + 0.7, + { service: serviceName, source: 'java_stub' }, + ), + ); + } + return out; + } + + private scanPythonProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // add_XxxServicer_to_server( + const re = /add_(\w+?)Servicer_to_server\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + out.push( + makeContract( + serviceOnlyContractId(serviceName), + 'provider', + filePath, + `add_${serviceName}Servicer_to_server`, + 0.8, + { service: serviceName, source: 'python_servicer' }, + ), + ); + } + return out; + } + + private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // XxxStub( + const re = /(\w+)Stub\s*\(/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const name = m[1]; + // Filter out common false positives + if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue; + out.push( + makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, { + service: name, + source: 'python_stub', + }), + ); + } + return out; + } + + private scanTsProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + // @GrpcMethod('ServiceName', 'MethodName') + const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const serviceName = m[1]; + const methodName = m[2]; + const cid = contractId('', serviceName, methodName); + out.push( + makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, { + service: serviceName, + method: methodName, + source: 'ts_grpc_method', + }), + ); + } + return out; + } + + private dedupe(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } +} diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts new file mode 100644 index 000000000..8dfb242bf --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -0,0 +1,475 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +const HANDLES_ROUTE_QUERY = ` +MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) +RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath, + route.name AS routePath, route.id AS routeId, + route.responseKeys AS responseKeys, + r.reason AS routeSource`; + +const FETCHES_QUERY = ` +MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route) +RETURN callerFile.id AS fileId, callerFile.filePath AS filePath, + route.name AS routePath, route.id AS routeId, + r.reason AS fetchReason`; + +const CONTAINS_QUERY = ` +MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym) +WHERE sym.startLine IS NOT NULL +RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels +ORDER BY sym.startLine`; + +export function normalizeHttpPath(p: string): string { + let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, ''); + s = s.replace(/:\w+/g, '{param}'); + s = s.replace(/\{[^}]+\}/g, '{param}'); + s = s.replace(/\[[^\]]+\]/g, '{param}'); + return s; +} + +function methodFromRouteReason(reason: string): string | null { + const r = reason || ''; + if (/GetMapping|decorator-Get/i.test(r)) return 'GET'; + if (/PostMapping|decorator-Post/i.test(r)) return 'POST'; + if (/PutMapping|decorator-Put/i.test(r)) return 'PUT'; + if (/DeleteMapping|decorator-Delete/i.test(r)) return 'DELETE'; + if (/PatchMapping|decorator-Patch/i.test(r)) return 'PATCH'; + return null; +} + +function contractIdFor(method: string, pathNorm: string): string { + return `http::${method.toUpperCase()}::${pathNorm}`; +} + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function pickJavaHandlerName( + content: string, + routePath: string, + httpMethod: string, +): string | null { + const tail = routePath.split('/').filter(Boolean).pop() || ''; + const mapNames: Record = { + GET: 'GetMapping', + POST: 'PostMapping', + PUT: 'PutMapping', + DELETE: 'DeleteMapping', + PATCH: 'PatchMapping', + }; + const ann = mapNames[httpMethod] || 'GetMapping'; + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.includes(`@${ann}`)) continue; + if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail)) + continue; + for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) { + const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); + if (m) return m[1]; + } + } + return null; +} + +function pickSymbolUid( + rows: Record[], + preferredName: string | null, +): { uid: string; name: string; filePath: string } { + const norm = (x: unknown) => String(x ?? ''); + const labeled = rows.filter((r) => { + const labels = r.labels ?? r[3]; + const s = JSON.stringify(labels); + return s.includes('Method') || s.includes('Function'); + }); + const pool = labeled.length > 0 ? labeled : rows; + if (preferredName) { + const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName); + if (hit) { + return { + uid: norm(hit.uid ?? hit[0]), + name: norm(hit.name ?? hit[1]), + filePath: norm(hit.filePath ?? hit[2]), + }; + } + } + const first = pool[0] || rows[0]; + return { + uid: norm(first?.uid ?? first?.[0]), + name: norm(first?.name ?? first?.[1]), + filePath: norm(first?.filePath ?? first?.[2]), + }; +} + +export class HttpRouteExtractor implements ContractExtractor { + type = 'http' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + dbExecutor: CypherExecutor | null, + repoPath: string, + repo: RepoHandle, + ): Promise { + const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : []; + const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath); + + const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : []; + const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath); + + return [...providers, ...consumers]; + } + + private async extractProvidersGraph( + db: CypherExecutor, + repoPath: string, + ): Promise { + const out: ExtractedContract[] = []; + let rows: Record[]; + try { + rows = await db(HANDLES_ROUTE_QUERY); + } catch { + return []; + } + + for (const row of rows) { + const filePath = String(row.filePath ?? ''); + const routePath = String(row.routePath ?? ''); + const routeSource = String(row.routeSource ?? row.routeReason ?? ''); + let method = methodFromRouteReason(routeSource); + const content = readSafe(repoPath, filePath); + if (!method && content) { + method = this.inferMethodFromFileScan(content, routePath, 'provider'); + } + if (!method) method = 'GET'; + + const pathNorm = normalizeHttpPath(routePath); + const cid = contractIdFor(method, pathNorm); + const handlerName = + content && routePath ? pickJavaHandlerName(content, routePath, method) : null; + + let symbolUid = ''; + let symbolName = path.basename(filePath) || 'handler'; + let symPath = filePath; + const fileId = row.fileId ?? row[0]; + if (fileId) { + try { + const syms = await db(CONTAINS_QUERY, { fileId }); + if (syms.length > 0) { + const picked = pickSymbolUid(syms, handlerName); + symbolUid = picked.uid; + symbolName = picked.name; + symPath = picked.filePath || filePath; + } + } catch { + /* ignore */ + } + } + + out.push({ + contractId: cid, + type: 'http', + role: 'provider', + symbolUid, + symbolRef: { filePath: symPath, name: symbolName }, + symbolName, + confidence: 0.9, + meta: { + method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'graph_assisted', + routeSource, + }, + }); + } + return out; + } + + private inferMethodFromFileScan( + content: string, + routePath: string, + _role: string, + ): string | null { + const tail = routePath.split('/').filter(Boolean).pop() || ''; + for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) { + const mapNames: Record = { + GET: 'GetMapping', + POST: 'PostMapping', + PUT: 'PutMapping', + DELETE: 'DeleteMapping', + PATCH: 'PatchMapping', + }; + if ( + content.includes(`@${mapNames[m]}`) && + (content.includes(tail) || routePath.includes(tail)) + ) { + return m; + } + } + return null; + } + + private async extractProvidersSourceScan(repoPath: string): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanSpringProviders(content, rel)); + out.push(...this.scanExpressProviders(content, rel)); + out.push(...this.scanLaravelProviders(content, rel)); + out.push(...this.scanFastApiProviders(content, rel)); + } + return this.dedupeContracts(out); + } + + private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } + + private scanSpringProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + let classPrefix = ''; + const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/); + if (classRm) classPrefix = classRm[1].replace(/\/+$/, ''); + + const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const method = m[1].toUpperCase(); + let p = m[2]; + if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`; + const pathNorm = normalizeHttpPath(p); + const sub = content.slice(m.index); + const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/); + const name = nameM ? nameM[1] : m[0]; + out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8)); + } + return out; + } + + private scanExpressProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /(?:router|app)\.(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(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); + } + return out; + } + + private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /Route::(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(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8)); + } + return out; + } + + private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = /@app\.(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(m[2]); + out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8)); + } + return out; + } + + private makeProvider( + filePath: string, + method: string, + pathNorm: string, + name: string, + confidence: number, + ): ExtractedContract { + const cid = contractIdFor(method, pathNorm); + return { + contractId: cid, + type: 'http', + role: 'provider', + symbolUid: '', + symbolRef: { filePath, name }, + symbolName: name, + confidence, + meta: { + method, + path: pathNorm, + pathSegments: pathNorm.split('/').filter(Boolean), + extractionStrategy: 'source_scan', + }, + }; + } + + private async extractConsumersGraph( + db: CypherExecutor, + repoPath: string, + ): Promise { + const out: ExtractedContract[] = []; + let rows: Record[]; + try { + rows = await db(FETCHES_QUERY); + } catch { + return []; + } + for (const row of rows) { + const filePath = String(row.filePath ?? ''); + const routePath = String(row.routePath ?? ''); + const pathNorm = normalizeHttpPath(routePath); + let method = 'GET'; + const content = readSafe(repoPath, filePath); + if (content) { + const inferred = this.inferFetchMethod(content, pathNorm); + if (inferred) method = inferred; + } + const cid = contractIdFor(method, pathNorm); + let symbolUid = ''; + let symbolName = 'fetch'; + let symPath = filePath; + const fileId = row.fileId ?? row[0]; + if (fileId) { + try { + const syms = await db(CONTAINS_QUERY, { fileId }); + if (syms.length > 0) { + const picked = pickSymbolUid(syms, null); + symbolUid = picked.uid; + symbolName = picked.name; + symPath = picked.filePath || filePath; + } + } catch { + /* ignore */ + } + } + out.push({ + contractId: cid, + type: 'http', + role: 'consumer', + symbolUid, + symbolRef: { filePath: symPath, name: symbolName }, + symbolName, + confidence: 0.9, + meta: { + method, + path: pathNorm, + extractionStrategy: 'graph_assisted', + fetchReason: String(row.fetchReason ?? ''), + }, + }); + } + return out; + } + + private inferFetchMethod(content: string, pathNorm: string): string | null { + const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const fetchRe = new RegExp( + `fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`, + 'i', + ); + const m = content.match(fetchRe); + if (m) return m[2].toUpperCase(); + return null; + } + + private async extractConsumersSourceScan(repoPath: string): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**'], + nodir: true, + }); + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanFetchConsumers(content, rel)); + out.push(...this.scanAxiosConsumers(content, rel)); + } + return this.dedupeContracts(out); + } + + private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + const re = + /fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const pathNorm = normalizeHttpPath(this.templateToPattern(m[1])); + const method = (m[2] || 'GET').toUpperCase(); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + } + return out; + } + + private templateToPattern(url: string): string { + return url.replace(/\$\{[^}]+\}/g, '{param}'); + } + + 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])); + out.push(this.makeConsumer(filePath, method, pathNorm, 0.7)); + } + return out; + } + + private makeConsumer( + filePath: string, + method: string, + pathNorm: string, + confidence: number, + ): ExtractedContract { + return { + contractId: contractIdFor(method, pathNorm), + type: 'http', + role: 'consumer', + symbolUid: '', + symbolRef: { filePath, name: 'fetch' }, + symbolName: 'fetch', + confidence, + meta: { + method, + path: pathNorm, + extractionStrategy: 'source_scan', + }, + }; + } +} diff --git a/gitnexus/src/core/group/extractors/topic-extractor.ts b/gitnexus/src/core/group/extractors/topic-extractor.ts new file mode 100644 index 000000000..c27b419bb --- /dev/null +++ b/gitnexus/src/core/group/extractors/topic-extractor.ts @@ -0,0 +1,277 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; +import type { ExtractedContract, RepoHandle } from '../types.js'; + +type Broker = 'kafka' | 'rabbitmq' | 'nats'; + +function readSafe(repoPath: string, rel: string): string | null { + const abs = path.resolve(repoPath, rel); + const base = path.resolve(repoPath); + const relToBase = path.relative(base, abs); + if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null; + try { + return fs.readFileSync(abs, 'utf-8'); + } catch { + return null; + } +} + +function makeContract( + topicName: string, + role: 'provider' | 'consumer', + filePath: string, + symbolName: string, + confidence: number, + broker: Broker, +): ExtractedContract { + return { + contractId: `topic::${topicName}`, + type: 'topic', + role, + symbolUid: '', + symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName }, + symbolName, + confidence, + meta: { + broker, + topicName, + extractionStrategy: 'source_scan', + }, + }; +} + +interface PatternDef { + regex: RegExp; + role: 'provider' | 'consumer'; + broker: Broker; + confidence: number; + topicGroup: number; + symbolName: string; +} + +// --- Kafka patterns --- +const KAFKA_PATTERNS: PatternDef[] = [ + // Java: @KafkaListener(topics = "xxx") + { + regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'kafkaListener', + }, + // Java: kafkaTemplate.send("xxx" + { + regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi, + role: 'provider', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'kafkaTemplate.send', + }, + // Node: producer.send({ topic: 'xxx' + { + regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'producer.send', + }, + // Node: consumer.subscribe({ topic: 'xxx' + { + regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.8, + topicGroup: 1, + symbolName: 'consumer.subscribe', + }, + // Go: consumer.ConsumePartition("xxx" + { + regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'ConsumePartition', + }, + // Python: KafkaConsumer('xxx' + { + regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'KafkaConsumer', + }, + // Python: producer.send('xxx' or producer.produce('xxx' + { + regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'kafka', + confidence: 0.7, + topicGroup: 1, + symbolName: 'producer.send', + }, +]; + +// --- RabbitMQ patterns --- +const RABBITMQ_PATTERNS: PatternDef[] = [ + // Java: @RabbitListener(queues = "xxx") + { + regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'rabbitListener', + }, + // Java: rabbitTemplate.convertAndSend("xxx" + { + regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'rabbitTemplate.convertAndSend', + }, + // Node: channel.consume("xxx" + { + regex: /channel\.consume\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.consume', + }, + // Node: channel.publish("xxx" + { + regex: /channel\.publish\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.publish', + }, + // Node: channel.sendToQueue("xxx" + { + regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.8, + topicGroup: 1, + symbolName: 'channel.sendToQueue', + }, + // Python: channel.basic_consume(queue='xxx' + { + regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g, + role: 'consumer', + broker: 'rabbitmq', + confidence: 0.7, + topicGroup: 1, + symbolName: 'basic_consume', + }, + // Python: channel.basic_publish(exchange='xxx' + { + regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g, + role: 'provider', + broker: 'rabbitmq', + confidence: 0.7, + topicGroup: 1, + symbolName: 'basic_publish', + }, +]; + +// --- NATS patterns --- +const NATS_PATTERNS: PatternDef[] = [ + // Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx" + { + regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g, + role: 'consumer', + broker: 'nats', + confidence: 0.8, + topicGroup: 1, + symbolName: 'nc.Subscribe', + }, + // Go/Node: nc.Publish("xxx" or nc.publish("xxx" + { + regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g, + role: 'provider', + broker: 'nats', + confidence: 0.8, + topicGroup: 1, + symbolName: 'nc.Publish', + }, +]; + +const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS]; + +export class TopicExtractor implements ContractExtractor { + type = 'topic' as const; + + async canExtract(_repo: RepoHandle): Promise { + return true; + } + + async extract( + _dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + nodir: true, + }); + + const out: ExtractedContract[] = []; + for (const rel of files) { + const content = readSafe(repoPath, rel); + if (!content) continue; + out.push(...this.scanFile(content, rel)); + } + + return this.dedupe(out); + } + + private scanFile(content: string, filePath: string): ExtractedContract[] { + const out: ExtractedContract[] = []; + + for (const pattern of ALL_PATTERNS) { + // Reset regex state for each file + const re = new RegExp(pattern.regex.source, pattern.regex.flags); + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const topicName = m[pattern.topicGroup]; + if (!topicName) continue; + out.push( + makeContract( + topicName, + pattern.role, + filePath, + pattern.symbolName, + pattern.confidence, + pattern.broker, + ), + ); + } + } + + return out; + } + + private dedupe(items: ExtractedContract[]): ExtractedContract[] { + const seen = new Set(); + const out: ExtractedContract[] = []; + for (const c of items) { + const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`; + if (seen.has(k)) continue; + seen.add(k); + out.push(c); + } + return out; + } +} diff --git a/gitnexus/src/core/group/service-boundary-detector.ts b/gitnexus/src/core/group/service-boundary-detector.ts new file mode 100644 index 000000000..4b42073d3 --- /dev/null +++ b/gitnexus/src/core/group/service-boundary-detector.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface ServiceBoundary { + servicePath: string; + serviceName: string; + markers: string[]; + confidence: number; +} + +const SERVICE_MARKERS = [ + 'package.json', + 'go.mod', + 'Dockerfile', + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'Cargo.toml', + 'pyproject.toml', + 'requirements.txt', + 'mix.exs', +] as const; + +const SOURCE_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.go', + '.java', + '.kt', + '.kts', + '.py', + '.pyi', + '.rs', + '.c', + '.cpp', + '.h', + '.hpp', + '.cs', + '.rb', + '.php', + '.swift', + '.dart', + '.ex', + '.exs', + '.erl', + '.proto', +]); + +export async function detectServiceBoundaries(repoPath: string): Promise { + const boundaries: ServiceBoundary[] = []; + await walkForBoundaries(repoPath, repoPath, boundaries); + return boundaries; +} + +async function walkForBoundaries( + dir: string, + repoRoot: string, + results: ServiceBoundary[], +): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + const isRoot = path.resolve(dir) === path.resolve(repoRoot); + + const foundMarkers: string[] = []; + let hasSourceFiles = false; + const subdirs: string[] = []; + + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + + if (entry.isDirectory()) { + subdirs.push(path.join(dir, entry.name)); + } else if (entry.isFile()) { + if (SERVICE_MARKERS.includes(entry.name as (typeof SERVICE_MARKERS)[number])) { + foundMarkers.push(entry.name); + } + const ext = path.extname(entry.name).toLowerCase(); + if (SOURCE_EXTENSIONS.has(ext)) { + hasSourceFiles = true; + } + } + } + + // Check subdirectories for source files if not found at this level + if (!hasSourceFiles && foundMarkers.length > 0) { + hasSourceFiles = await hasSourceFilesInSubdirs(subdirs); + } + + if (!isRoot && foundMarkers.length >= 1 && hasSourceFiles) { + const relativePath = path.relative(repoRoot, dir).replace(/\\/g, '/'); + const serviceName = path.basename(dir); + const confidence = computeConfidence(foundMarkers.length); + + results.push({ + servicePath: relativePath, + serviceName, + markers: foundMarkers, + confidence, + }); + } + + // Recurse into subdirectories + for (const subdir of subdirs) { + await walkForBoundaries(subdir, repoRoot, results); + } +} + +async function hasSourceFilesInSubdirs(subdirs: string[]): Promise { + for (const subdir of subdirs) { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(subdir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (SOURCE_EXTENSIONS.has(ext)) return true; + } + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { + const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]); + if (deeper) return true; + } + } + } + return false; +} + +function computeConfidence(markerCount: number): number { + if (markerCount >= 3) return 1.0; + if (markerCount === 2) return 0.9; + return 0.75; +} + +export function assignService(filePath: string, boundaries: ServiceBoundary[]): string | undefined { + const normalized = filePath.replace(/\\/g, '/'); + + let bestMatch: ServiceBoundary | undefined; + let bestLength = 0; + + for (const boundary of boundaries) { + const prefix = boundary.servicePath + '/'; + if (normalized.startsWith(prefix) && boundary.servicePath.length > bestLength) { + bestMatch = boundary; + bestLength = boundary.servicePath.length; + } + } + + return bestMatch?.servicePath; +} diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts new file mode 100644 index 000000000..c8a3c18e2 --- /dev/null +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('GrpcExtractor', () => { + let tmpDir: string; + let extractor: GrpcExtractor; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-grpc-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + extractor = new GrpcExtractor(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content: string): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/app', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + describe('proto file parsing', () => { + it('test_extract_proto_service_single_rpc_returns_provider', async () => { + writeFile( + 'proto/auth.proto', + `syntax = "proto3"; +package auth; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::auth.AuthService/Login'); + expect(providers[0].confidence).toBe(0.85); + expect(providers[0].symbolRef.filePath).toBe('proto/auth.proto'); + }); + + it('test_extract_proto_service_multiple_rpcs_returns_all', async () => { + writeFile( + 'api/user.proto', + `syntax = "proto3"; +package hr.user.v1; +service UserService { + rpc GetUser (GetUserRequest) returns (UserResponse); + rpc ListUsers (ListUsersRequest) returns (ListUsersResponse); + rpc DeleteUser (DeleteUserRequest) returns (Empty); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(3); + const ids = providers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::hr.user.v1.UserService/DeleteUser', + 'grpc::hr.user.v1.UserService/GetUser', + 'grpc::hr.user.v1.UserService/ListUsers', + ]); + }); + + it('test_extract_proto_without_package_uses_service_only', async () => { + writeFile( + 'service.proto', + `syntax = "proto3"; +service HealthCheck { + rpc Check (HealthRequest) returns (HealthResponse); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(1); + expect(contracts[0].contractId).toBe('grpc::HealthCheck/Check'); + }); + }); + + describe('Go server detection', () => { + it('test_extract_go_register_server_returns_provider', async () => { + writeFile( + 'cmd/server/main.go', + `package main + +import pb "example.com/proto/auth" + +func main() { + srv := grpc.NewServer() + pb.RegisterAuthServiceServer(srv, &authServer{}) + srv.Serve(lis) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('grpc::'); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_go_unimplemented_server_returns_provider', async () => { + writeFile( + 'internal/server.go', + `package server + +type authServer struct { + pb.UnimplementedAuthServiceServer +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + }); + }); + + describe('Go client detection', () => { + it('test_extract_go_new_client_returns_consumer', async () => { + writeFile( + 'internal/client.go', + `package client + +import pb "example.com/proto/auth" + +func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient { + return pb.NewAuthServiceClient(conn) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('Java detection', () => { + it('test_extract_java_grpc_service_annotation_returns_provider', async () => { + writeFile( + 'src/main/java/AuthGrpcService.java', + `@GrpcService +public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { + @Override + public void login(LoginRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_java_blocking_stub_returns_consumer', async () => { + writeFile( + 'src/main/java/AuthClient.java', + `public class AuthClient { + private final AuthServiceGrpc.AuthServiceBlockingStub stub; + public AuthClient(ManagedChannel ch) { + this.stub = AuthServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('Python detection', () => { + it('test_extract_python_add_servicer_returns_provider', async () => { + writeFile( + 'server.py', + `import grpc +from proto import auth_pb2_grpc + +def serve(): + server = grpc.server(futures.ThreadPoolExecutor()) + auth_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(), server) + server.start()`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].confidence).toBe(0.8); + }); + + it('test_extract_python_stub_returns_consumer', async () => { + writeFile( + 'client.py', + `import grpc +from proto import auth_pb2_grpc + +channel = grpc.insecure_channel('localhost:50051') +stub = auth_pb2_grpc.AuthServiceStub(channel)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].contractId).toContain('AuthService'); + expect(consumers[0].confidence).toBe(0.7); + }); + }); + + describe('TypeScript/Node detection', () => { + it('test_extract_ts_grpc_method_decorator_returns_provider', async () => { + writeFile( + 'src/auth.controller.ts', + `import { GrpcMethod } from '@nestjs/microservices'; + +export class AuthController { + @GrpcMethod('AuthService', 'Login') + login(data: LoginRequest): LoginResponse { + return {}; + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(1); + expect(providers[0].contractId).toContain('AuthService'); + expect(providers[0].contractId).toContain('Login'); + expect(providers[0].confidence).toBe(0.8); + }); + }); + + describe('edge cases', () => { + it('test_extract_empty_repo_returns_empty', async () => { + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_repo_without_grpc_returns_empty', async () => { + writeFile('src/index.ts', 'console.log("hello")'); + writeFile('package.json', '{}'); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts new file mode 100644 index 000000000..2290c806a --- /dev/null +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('HttpRouteExtractor', () => { + const tmpDir = path.join(os.tmpdir(), `gitnexus-http-extract-${Date.now()}`); + let extractor: HttpRouteExtractor; + + beforeEach(() => { + extractor = new HttpRouteExtractor(); + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/backend', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + 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'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +@RestController +@RequestMapping("/api/v2") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } + + @PostMapping("/users") + public User create(@RequestBody User user) { return service.save(user); } +} +`, + ); + + const mockDbExecutor = async (query: string) => { + if (query.includes('HANDLES_ROUTE')) { + return [ + { + fileId: 'file-uid-ctrl', + filePath: 'src/controller/UserController.java', + routePath: '/api/v2/users', + routeId: 'route-uid-users', + responseKeys: null, + routeSource: 'decorator-GetMapping', + }, + ]; + } + if (query.includes('CONTAINS')) { + return [ + { + uid: 'uid-ctrl-list', + name: 'list', + filePath: 'src/controller/UserController.java', + labels: ['Method'], + }, + { + uid: 'uid-ctrl-create', + name: 'create', + filePath: 'src/controller/UserController.java', + labels: ['Method'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const getRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(getRoute).toBeDefined(); + expect(getRoute!.confidence).toBe(0.9); + expect(getRoute!.symbolUid).not.toBe('file-uid-ctrl'); + }); + }); + + describe('provider extraction — source-scan fallback (Strategy B)', () => { + it('extracts Spring @GetMapping annotation', async () => { + const dir = path.join(tmpDir, 'spring'); + fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/controller/UserController.java'), + ` +package com.example; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v2") +public class UserController { + @GetMapping("/users") + public List list() { return service.findAll(); } + + @PostMapping("/users") + public User create(@RequestBody User user) { return service.save(user); } + + @GetMapping("/users/{id}") + public User getById(@PathVariable Long id) { return service.findById(id); } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + + const listRoute = providers.find((c) => c.contractId === 'http::GET::/api/v2/users'); + expect(listRoute).toBeDefined(); + expect(listRoute!.meta.method).toBe('GET'); + expect(listRoute!.meta.path).toBe('/api/v2/users'); + + const createRoute = providers.find((c) => c.contractId === 'http::POST::/api/v2/users'); + expect(createRoute).toBeDefined(); + + const getByIdRoute = providers.find( + (c) => c.contractId === 'http::GET::/api/v2/users/{param}', + ); + expect(getByIdRoute).toBeDefined(); + }); + + it('extracts Express router.get patterns', async () => { + const dir = path.join(tmpDir, 'express'); + fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/routes/users.ts'), + ` +import { Router } from 'express'; +const router = Router(); + +router.get('/api/users', async (req, res) => { res.json([]); }); +router.post('/api/users', async (req, res) => { res.json({}); }); +router.delete('/api/users/:id', async (req, res) => { res.sendStatus(204); }); + +export default router; +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + expect( + providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + }); + }); + + describe('consumer extraction — fetch patterns', () => { + it('extracts fetch() calls', async () => { + const dir = path.join(tmpDir, 'frontend'); + fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/api/users.ts'), + ` +export async function fetchUsers() { + const res = await fetch('/api/users'); + return res.json(); +} + +export async function createUser(data: any) { + const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) }); + return res.json(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(2); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined(); + }); + + it('extracts axios calls', async () => { + const dir = path.join(tmpDir, 'axios-fe'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/api.ts'), + ` +import axios from 'axios'; +export const getUsers = () => axios.get('/api/users'); +export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); +`, + ); + + 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')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'), + ).toBeDefined(); + }); + }); + + describe('provider extraction — Laravel', () => { + it('extracts Laravel Route::get patterns', async () => { + const dir = path.join(tmpDir, 'laravel'); + fs.mkdirSync(path.join(dir, 'routes'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'routes/api.php'), + ` c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(3); + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::DELETE::/users/{param}')).toBeDefined(); + }); + }); + + describe('provider extraction — FastAPI', () => { + it('extracts FastAPI @app.get decorator patterns', async () => { + const dir = path.join(tmpDir, 'fastapi'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/main.py'), + `from fastapi import FastAPI +app = FastAPI() + +@app.get("/users") +async def list_users(): + return [] + +@app.post("/users") +async def create_user(user: UserCreate): + return user +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers.length).toBeGreaterThanOrEqual(2); + expect(providers.find((c) => c.contractId === 'http::GET::/users')).toBeDefined(); + expect(providers.find((c) => c.contractId === 'http::POST::/users')).toBeDefined(); + }); + }); + + describe('consumer extraction — graph-first (Strategy A)', () => { + it('extracts consumers from FETCHES graph edges', async () => { + const dir = path.join(tmpDir, 'graph-consumers'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/api.ts'), 'export const api = {};'); + + const mockDbExecutor = async (query: string) => { + if (query.includes('HANDLES_ROUTE')) return []; + if (query.includes('FETCHES')) { + return [ + { + fileId: 'file-uid-api', + filePath: 'src/api.ts', + routePath: '/api/users', + routeId: 'route-uid-users', + fetchReason: 'fetch-url-match', + }, + ]; + } + if (query.includes('CONTAINS')) { + return [ + { + uid: 'uid-fn-fetch', + name: 'fetchUsers', + filePath: 'src/api.ts', + labels: ['Function'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.length).toBeGreaterThanOrEqual(1); + expect(consumers[0].confidence).toBe(0.9); + expect(consumers[0].symbolName).toBe('fetchUsers'); + }); + }); + + describe('edge cases', () => { + it('returns empty for repo with no matching files', async () => { + const dir = path.join(tmpDir, 'empty-repo'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), '# Hello'); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect(contracts).toHaveLength(0); + }); + + it('handles graph queries that throw gracefully', async () => { + const dir = path.join(tmpDir, 'graph-error'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`); + + const throwingExecutor = async () => { + throw new Error('DB unavailable'); + }; + + const contracts = await extractor.extract(throwingExecutor, dir, makeRepo(dir)); + // Should fall back to source scan + const providers = contracts.filter((c) => c.role === 'provider'); + expect(providers.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('path normalization', () => { + it('strips trailing slash', async () => { + const dir = path.join(tmpDir, 'trailing'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/router.ts'), + ` +router.get('/api/users/', handler); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const provider = contracts.find((c) => c.role === 'provider'); + expect(provider?.meta.path).toBe('/api/users'); + }); + + it('normalizes path params from multiple syntaxes', async () => { + const dir = path.join(tmpDir, 'params'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/router.ts'), + ` +router.get('/api/users/:id', handler1); +router.get('/api/posts/{postId}', handler2); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + contracts.forEach((c) => { + expect(c.meta.path).not.toContain(':id'); + expect(c.meta.path).not.toContain('{postId}'); + if (typeof c.meta.path === 'string' && c.meta.path.includes('users/')) { + expect(c.meta.path).toContain('{param}'); + } + }); + }); + }); +}); diff --git a/gitnexus/test/unit/group/service-boundary-detector.test.ts b/gitnexus/test/unit/group/service-boundary-detector.test.ts new file mode 100644 index 000000000..4898b05eb --- /dev/null +++ b/gitnexus/test/unit/group/service-boundary-detector.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + detectServiceBoundaries, + assignService, + type ServiceBoundary, +} from '../../../src/core/group/service-boundary-detector.js'; + +describe('ServiceBoundaryDetector', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-sbd-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content = ''): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + describe('detectServiceBoundaries', () => { + it('test_detect_services_with_package_json_and_dockerfile_returns_boundaries', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/Dockerfile', 'FROM node:20'); + writeFile('services/auth/src/index.ts', 'export default {}'); + writeFile('services/orders/package.json', '{}'); + writeFile('services/orders/src/main.ts', 'console.log("ok")'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(2); + const names = boundaries.map((b) => b.serviceName).sort(); + expect(names).toEqual(['auth', 'orders']); + }); + + it('test_detect_root_package_json_excluded_from_boundaries', async () => { + writeFile('package.json', '{}'); + writeFile('src/index.ts', 'export default {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(0); + }); + + it('test_detect_go_mod_marker_returns_boundary', async () => { + writeFile('services/api/go.mod', 'module example.com/api'); + writeFile('services/api/main.go', 'package main'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('api'); + expect(boundaries[0].markers).toContain('go.mod'); + }); + + it('test_detect_pom_xml_marker_returns_boundary', async () => { + writeFile('microservices/billing/pom.xml', ''); + writeFile('microservices/billing/src/Main.java', 'class Main {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('billing'); + expect(boundaries[0].markers).toContain('pom.xml'); + }); + + it('test_detect_dockerfile_marker_returns_boundary', async () => { + writeFile('apps/worker/Dockerfile', 'FROM python:3.12'); + writeFile('apps/worker/requirements.txt', 'flask'); + writeFile('apps/worker/app.py', 'print("worker")'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('worker'); + expect(boundaries[0].markers).toContain('Dockerfile'); + expect(boundaries[0].markers).toContain('requirements.txt'); + }); + + it('test_detect_multiple_markers_increases_confidence', async () => { + writeFile('services/auth/package.json', '{}'); + writeFile('services/auth/Dockerfile', 'FROM node:20'); + writeFile('services/auth/src/index.ts', ''); + writeFile('services/api/package.json', '{}'); + writeFile('services/api/src/index.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + const auth = boundaries.find((b) => b.serviceName === 'auth')!; + const api = boundaries.find((b) => b.serviceName === 'api')!; + + expect(auth.confidence).toBeGreaterThan(api.confidence); + }); + + it('test_detect_nested_services_returns_deepest_match', async () => { + writeFile('platform/services/auth/package.json', '{}'); + writeFile('platform/services/auth/src/index.ts', ''); + writeFile('platform/package.json', '{}'); + writeFile('platform/src/shared.ts', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + const paths = boundaries.map((b) => b.servicePath).sort(); + + // Both detected; assignService will use the deepest match + expect(paths.length).toBeGreaterThanOrEqual(1); + expect(paths).toContain('platform/services/auth'); + }); + + it('test_detect_cargo_toml_marker_returns_boundary', async () => { + writeFile('crates/parser/Cargo.toml', '[package]'); + writeFile('crates/parser/src/lib.rs', 'pub fn parse() {}'); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('parser'); + expect(boundaries[0].markers).toContain('Cargo.toml'); + }); + + it('test_detect_build_gradle_marker_returns_boundary', async () => { + writeFile('modules/gateway/build.gradle', 'apply plugin: "java"'); + writeFile('modules/gateway/src/Main.java', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].serviceName).toBe('gateway'); + expect(boundaries[0].markers).toContain('build.gradle'); + }); + + it('test_detect_empty_repo_returns_empty', async () => { + const boundaries = await detectServiceBoundaries(tmpDir); + expect(boundaries).toHaveLength(0); + }); + + it('test_detect_pyproject_toml_marker_returns_boundary', async () => { + writeFile('services/ml/pyproject.toml', '[project]'); + writeFile('services/ml/src/model.py', ''); + + const boundaries = await detectServiceBoundaries(tmpDir); + + expect(boundaries).toHaveLength(1); + expect(boundaries[0].markers).toContain('pyproject.toml'); + }); + }); + + describe('assignService', () => { + it('test_assign_file_to_correct_service', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + { + servicePath: 'services/orders', + serviceName: 'orders', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('services/auth/src/index.ts', boundaries)).toBe('services/auth'); + expect(assignService('services/orders/src/main.ts', boundaries)).toBe('services/orders'); + }); + + it('test_assign_file_outside_services_returns_undefined', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('libs/shared/utils.ts', boundaries)).toBeUndefined(); + expect(assignService('README.md', boundaries)).toBeUndefined(); + }); + + it('test_assign_nested_file_uses_deepest_boundary', () => { + const boundaries: ServiceBoundary[] = [ + { + servicePath: 'platform', + serviceName: 'platform', + markers: ['package.json'], + confidence: 0.7, + }, + { + servicePath: 'platform/services/auth', + serviceName: 'auth', + markers: ['package.json'], + confidence: 0.8, + }, + ]; + + expect(assignService('platform/services/auth/src/index.ts', boundaries)).toBe( + 'platform/services/auth', + ); + expect(assignService('platform/shared/utils.ts', boundaries)).toBe('platform'); + }); + + it('test_assign_with_empty_boundaries_returns_undefined', () => { + expect(assignService('src/index.ts', [])).toBeUndefined(); + }); + }); +}); diff --git a/gitnexus/test/unit/group/topic-extractor.test.ts b/gitnexus/test/unit/group/topic-extractor.test.ts new file mode 100644 index 000000000..c6a1161a0 --- /dev/null +++ b/gitnexus/test/unit/group/topic-extractor.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { TopicExtractor } from '../../../src/core/group/extractors/topic-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('TopicExtractor', () => { + let tmpDir: string; + let extractor: TopicExtractor; + + beforeEach(() => { + tmpDir = path.join(os.tmpdir(), `gitnexus-topic-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + extractor = new TopicExtractor(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content: string): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/app', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + describe('Kafka — Java', () => { + it('test_extract_kafka_listener_returns_consumer', async () => { + writeFile( + 'src/EventHandler.java', + `@KafkaListener(topics = "user.created") +public void handleUserCreated(ConsumerRecord record) { + // process +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::user.created'); + expect(consumers[0].confidence).toBe(0.8); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafka_template_send_returns_producer', async () => { + writeFile( + 'src/EventPublisher.java', + `public class EventPublisher { + @Autowired KafkaTemplate template; + public void publish() { + kafkaTemplate.send("user.created", payload); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::user.created'); + expect(producers[0].meta.broker).toBe('kafka'); + }); + }); + + describe('Kafka — Node', () => { + it('test_extract_kafkajs_subscribe_returns_consumer', async () => { + writeFile( + 'src/consumer.ts', + `await consumer.subscribe({ topic: 'order.placed', fromBeginning: true }); +await consumer.run({ eachMessage: async ({ message }) => {} });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::order.placed'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + + it('test_extract_kafkajs_producer_send_returns_producer', async () => { + writeFile( + 'src/producer.ts', + `await producer.send({ topic: 'order.placed', messages: [{ value: JSON.stringify(order) }] });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::order.placed'); + }); + }); + + describe('RabbitMQ — Java', () => { + it('test_extract_rabbit_listener_returns_consumer', async () => { + writeFile( + 'src/OrderListener.java', + `@RabbitListener(queues = "order-queue") +public void processOrder(OrderMessage msg) {}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::order-queue'); + expect(consumers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_rabbit_template_send_returns_producer', async () => { + writeFile( + 'src/Publisher.java', + `rabbitTemplate.convertAndSend("order-exchange", "order.new", payload);`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::order-exchange'); + expect(producers[0].meta.broker).toBe('rabbitmq'); + }); + }); + + describe('RabbitMQ — Node', () => { + it('test_extract_amqplib_consume_returns_consumer', async () => { + writeFile( + 'src/worker.ts', + `channel.consume("task-queue", (msg) => { + console.log(msg.content.toString()); +});`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::task-queue'); + expect(consumers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_amqplib_publish_returns_producer', async () => { + writeFile( + 'src/publisher.ts', + `channel.publish("events", "user.signup", Buffer.from(JSON.stringify(data)));`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::events'); + expect(producers[0].meta.broker).toBe('rabbitmq'); + }); + + it('test_extract_amqplib_sendToQueue_returns_producer', async () => { + writeFile('src/sender.ts', `channel.sendToQueue("job-queue", Buffer.from(msg));`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::job-queue'); + }); + }); + + describe('NATS', () => { + it('test_extract_nats_subscribe_go_returns_consumer', async () => { + writeFile( + 'cmd/sub.go', + `package main +nc, _ := nats.Connect(nats.DefaultURL) +nc.Subscribe("updates.weather", func(m *nats.Msg) { + fmt.Println(string(m.Data)) +})`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::updates.weather'); + expect(consumers[0].meta.broker).toBe('nats'); + }); + + it('test_extract_nats_publish_go_returns_producer', async () => { + writeFile( + 'cmd/pub.go', + `package main +nc, _ := nats.Connect(nats.DefaultURL) +nc.Publish("updates.weather", []byte("sunny"))`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::updates.weather'); + }); + + it('test_extract_nats_subscribe_node_returns_consumer', async () => { + writeFile( + 'src/sub.ts', + `const sub = nc.subscribe("events.order"); +for await (const msg of sub) { process(msg); }`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::events.order'); + }); + + it('test_extract_nats_publish_node_returns_producer', async () => { + writeFile('src/pub.ts', `nc.publish("events.order", sc.encode(JSON.stringify(order)));`); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::events.order'); + }); + }); + + describe('Kafka — Go', () => { + it('test_extract_sarama_consume_returns_consumer', async () => { + writeFile( + 'internal/consumer.go', + `package consumer +partConsumer, _ := consumer.ConsumePartition("inventory.update", 0, sarama.OffsetNewest)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::inventory.update'); + expect(consumers[0].meta.broker).toBe('kafka'); + }); + }); + + describe('Kafka — Python', () => { + it('test_extract_kafka_python_subscribe_returns_consumer', async () => { + writeFile( + 'app/consumer.py', + `from kafka import KafkaConsumer +consumer = KafkaConsumer('payment.processed', bootstrap_servers=['localhost:9092'])`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0].contractId).toBe('topic::payment.processed'); + }); + + it('test_extract_kafka_python_producer_send_returns_producer', async () => { + writeFile( + 'app/producer.py', + `from kafka import KafkaProducer +producer = KafkaProducer(bootstrap_servers=['localhost:9092']) +producer.send('payment.processed', value=msg)`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const producers = contracts.filter((c) => c.role === 'provider'); + + expect(producers).toHaveLength(1); + expect(producers[0].contractId).toBe('topic::payment.processed'); + }); + }); + + describe('edge cases', () => { + it('test_extract_empty_repo_returns_empty', async () => { + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_repo_without_queues_returns_empty', async () => { + writeFile('src/index.ts', 'console.log("hello")'); + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + expect(contracts).toHaveLength(0); + }); + + it('test_extract_multiple_topics_in_one_file', async () => { + writeFile( + 'src/events.ts', + `await producer.send({ topic: 'user.created', messages: [] }); +await producer.send({ topic: 'user.deleted', messages: [] }); +await consumer.subscribe({ topic: 'order.placed' });`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toHaveLength(3); + const producers = contracts.filter((c) => c.role === 'provider'); + const consumers = contracts.filter((c) => c.role === 'consumer'); + expect(producers).toHaveLength(2); + expect(consumers).toHaveLength(1); + }); + }); +});