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) <noreply@anthropic.com>
This commit is contained in:
ivkond 2026-04-02 00:40:12 +03:00
parent 52277247fe
commit 4fa395f4b6
8 changed files with 2413 additions and 0 deletions

View file

@ -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<string, unknown>,
): 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<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
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<string>();
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;
}
}

View file

@ -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<string, string> = {
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<string, unknown>[],
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<boolean> {
return true;
}
async extract(
dbExecutor: CypherExecutor | null,
repoPath: string,
repo: RepoHandle,
): Promise<ExtractedContract[]> {
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<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
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<string, string> = {
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<ExtractedContract[]> {
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<string>();
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<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
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<ExtractedContract[]> {
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',
},
};
}
}

View file

@ -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<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
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<string>();
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;
}
}

View file

@ -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<ServiceBoundary[]> {
const boundaries: ServiceBoundary[] = [];
await walkForBoundaries(repoPath, repoPath, boundaries);
return boundaries;
}
async function walkForBoundaries(
dir: string,
repoRoot: string,
results: ServiceBoundary[],
): Promise<void> {
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<boolean> {
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;
}

View file

@ -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<LoginResponse> 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);
});
});
});

View file

@ -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<User> 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<User> 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'),
`<?php
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
`,
);
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::/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}');
}
});
});
});
});

View file

@ -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', '<project/>');
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();
});
});
});

View file

@ -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<String, String> 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<String, String> 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);
});
});
});