mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-10 22:43:40 +00:00
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:
parent
52277247fe
commit
4fa395f4b6
8 changed files with 2413 additions and 0 deletions
332
gitnexus/src/core/group/extractors/grpc-extractor.ts
Normal file
332
gitnexus/src/core/group/extractors/grpc-extractor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
475
gitnexus/src/core/group/extractors/http-route-extractor.ts
Normal file
475
gitnexus/src/core/group/extractors/http-route-extractor.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
277
gitnexus/src/core/group/extractors/topic-extractor.ts
Normal file
277
gitnexus/src/core/group/extractors/topic-extractor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
160
gitnexus/src/core/group/service-boundary-detector.ts
Normal file
160
gitnexus/src/core/group/service-boundary-detector.ts
Normal 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;
|
||||
}
|
||||
275
gitnexus/test/unit/group/grpc-extractor.test.ts
Normal file
275
gitnexus/test/unit/group/grpc-extractor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
gitnexus/test/unit/group/http-route-extractor.test.ts
Normal file
366
gitnexus/test/unit/group/http-route-extractor.test.ts
Normal 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}');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
215
gitnexus/test/unit/group/service-boundary-detector.test.ts
Normal file
215
gitnexus/test/unit/group/service-boundary-detector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
313
gitnexus/test/unit/group/topic-extractor.test.ts
Normal file
313
gitnexus/test/unit/group/topic-extractor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue