diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index 56107d8ee..6d70873c0 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -188,6 +188,16 @@ function makeContract( export interface ProtoServiceInfo { package: string; + /** + * Optional. Value of `option java_package = "..."` declared in the + * same `.proto` file, when present and different from `package`. + * Empty string when the option is absent or equals `package`. Used by + * `detectionToContract()` to translate a Java import path back to the + * proto package whenever the proto explicitly publishes its generated + * Java code under a different namespace (a common pattern in + * Google-style protobuf projects). + */ + javaPackage: string; serviceName: string; methods: string[]; protoPath: string; @@ -207,6 +217,19 @@ function extractProtoImports(content: string): string[] { return imports; } +/** + * Extract `option java_package = "..."` from a `.proto` file, if any. + * The Java code generator places generated `XxxGrpc.java` classes under + * this package (instead of the proto `package` declaration) when the + * option is set. Real-world projects (Google Cloud Java APIs, internal + * shaded SDKs) routinely use this to publish their Java artifacts under + * a corporate namespace different from the wire-protocol package. + */ +function extractJavaPackageOption(content: string): string { + const m = content.match(/^\s*option\s+java_package\s*=\s*"([\w.]+)"\s*;/m); + return m?.[1] ?? ''; +} + function longestSharedSegmentRun(aPath: string, bPath: string): number { const a = aPath.split('/').filter(Boolean); const b = bPath.split('/').filter(Boolean); @@ -228,8 +251,18 @@ function longestSharedSegmentRun(aPath: string, bPath: string): number { async function buildProtoContext(repoPath: string): Promise<{ packagesByProto: Map; servicesByName: Map; + /** + * Reverse index: `option java_package` value → ProtoServiceInfo[] + * declared in `.proto` files that ship under that Java namespace. + * Only populated when `java_package` is set AND differs from + * `package`. Lets `detectionToContract()` translate an import-derived + * Java package back to its source proto package whenever the proto + * is in the same repository. + */ + servicesByJavaPackage: Map; }> { const servicesByName = new Map(); + const servicesByJavaPackage = new Map(); // `.gitnexusignore` / `.gitignore` honoured via the shared IgnoreService — // see `filesystem-walker.ts` for the canonical pattern. Replaces a // hardcoded `[node_modules, .git, vendor]` array; those names plus the @@ -292,6 +325,13 @@ async function buildProtoContext(repoPath: string): Promise<{ const content = contents.get(normalizedRel); if (!content) continue; const pkg = resolvePackage(normalizedRel); + const javaPkgOption = extractJavaPackageOption(content); + // Only retain `javaPackage` when it actively diverges from `pkg`. + // When equal (or absent), the import-derived path produces the + // same FQN as the proto-derived path, so no translation is needed + // and we keep the field empty to avoid populating the reverse + // index with redundant entries. + const javaPackage = javaPkgOption && javaPkgOption !== pkg ? javaPkgOption : ''; const serviceBlocks = extractServiceBlocks(content); for (const block of serviceBlocks) { @@ -303,6 +343,7 @@ async function buildProtoContext(repoPath: string): Promise<{ } const info: ProtoServiceInfo = { package: pkg, + javaPackage, serviceName: block.name, methods, protoPath: normalizedRel, @@ -310,10 +351,16 @@ async function buildProtoContext(repoPath: string): Promise<{ const existing = servicesByName.get(block.name) ?? []; existing.push(info); servicesByName.set(block.name, existing); + + if (javaPackage) { + const byJava = servicesByJavaPackage.get(javaPackage) ?? []; + byJava.push(info); + servicesByJavaPackage.set(javaPackage, byJava); + } } } - return { packagesByProto, servicesByName }; + return { packagesByProto, servicesByName, servicesByJavaPackage }; } export async function buildProtoMap(repoPath: string): Promise> { @@ -377,6 +424,7 @@ export class GrpcExtractor implements ContractExtractor { const out: ExtractedContract[] = []; const protoContext = await buildProtoContext(repoPath); const protoMap = protoContext.servicesByName; + const javaPackageMap = protoContext.servicesByJavaPackage; // ─── Proto files — definitive provider source ───────────────── // When tree-sitter-proto is available, .proto files are handled by @@ -435,7 +483,7 @@ export class GrpcExtractor implements ContractExtractor { continue; } for (const d of detections) { - const contract = this.detectionToContract(d, rel, protoMap); + const contract = this.detectionToContract(d, rel, protoMap, javaPackageMap); if (contract) out.push(contract); } } @@ -449,12 +497,163 @@ export class GrpcExtractor implements ContractExtractor { * either a service-level (`grpc::pkg.Svc/*`) or method-level * (`grpc::pkg.Svc/Method`) contract id, and selecting confidence * based on whether the proto map had an entry. + * + * Resolution order for the package prefix: + * + * 1. **Java-package translation** (when detection + * supplied a `protoPackage` from a Java import). + * A `.proto` in the SAME repo may set `option + * java_package = "..."` to publish its generated + * Java classes under a namespace different from + * the proto `package`. Real-world projects (e.g. + * Google Cloud Java APIs) routinely do this. + * When the import-derived package matches that + * `java_package` value, translate back to the + * proto `package` so the resulting contract id + * is wire-correct rather than Java-namespace. + * + * 2. **Per-repo proto map check** (when the same + * service name has `.proto` candidates in this + * repo). The proto file is the authoritative + * source. If the proto's `package` agrees with + * the import's `protoPackage`, both paths produce + * the same FQN — emit it. If they DISAGREE (e.g. + * a typo'd Java import, or a mismatched + * java_package the reverse index didn't catch), + * trust the proto map and warn — the import + * MUST NOT silently overwrite an authoritative + * proto package. + * + * 3. **Import-derived FQN fallback** (when neither + * a `java_package` translation nor a proto map + * candidate exists in this repo). Typical for the + * "client-jar" pattern, where a consumer repo + * depends on a published stub jar and never + * carries the originating `.proto`. Use the + * import path verbatim as the proto package. Note + * the known limitation: when the published proto + * sets `option java_package` differing from + * `package`, the resulting FQN reflects the Java + * namespace rather than the proto namespace and + * will not match a provider repo's contract id — + * we cannot translate without sight of the proto. + * + * 4. **Per-repo proto map (no import)** — the legacy + * path. Used when the plugin didn't supply + * `protoPackage` (no import statement, wildcard + * import only, or non-Java languages that haven't + * been retrofitted yet). + * + * 5. **Short-name fallback** — when none of the + * above resolves a package, emit a service-only + * short-name contract id (`grpc::Svc/*`), + * preserving the pre-fix behaviour. */ private detectionToContract( d: GrpcDetection, filePath: string, protoMap: Map, + javaPackageMap: Map, ): ExtractedContract | null { + if (d.protoPackage) { + // Step 1: java_package translation. The import-derived package + // may be the `option java_package` value of a `.proto` in the + // SAME repo. Look it up and, if found for the same service name, + // use the underlying proto `package` to build a wire-correct + // contract id. + const javaCandidates = javaPackageMap.get(d.protoPackage) ?? []; + const javaTranslated = javaCandidates.find((p) => p.serviceName === d.serviceName); + if (javaTranslated) { + const cid = d.methodName + ? contractId(javaTranslated.package, d.serviceName, d.methodName) + : serviceContractId(javaTranslated.package, d.serviceName); + const meta: Record = { + service: d.serviceName, + source: d.source, + package: javaTranslated.package, + protoPackageSource: 'import-translated', + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, d.confidenceWithProto, meta); + } + + // Step 2: proto map cross-check. When this repo also carries a + // `.proto` defining the same short service name, the proto is + // authoritative and decides the package. The import is only used + // to disambiguate among same-short-name candidates when the + // resolution heuristic can't pick a unique winner on path alone. + const candidates = protoMap.get(d.serviceName) ?? []; + if (candidates.length > 0) { + const proto = resolveProtoConflict(d.serviceName, filePath, candidates); + if (proto === null) { + // Ambiguous proto resolution; resolveProtoConflict already warned. + return null; + } + const protoPkg = proto.package; + if (protoPkg === d.protoPackage) { + // Both paths agree. + const cid = d.methodName + ? contractId(protoPkg, d.serviceName, d.methodName) + : serviceContractId(protoPkg, d.serviceName); + const meta: Record = { + service: d.serviceName, + source: d.source, + package: protoPkg, + protoPackageSource: 'import', + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, d.confidenceWithProto, meta); + } + // Disagreement. Trust the proto file and emit a warning so + // operators can investigate the import. This protects against + // the symmetric Finding 2 case: a stale or typo'd Java import + // silently corrupting the contract id of a service whose + // `.proto` lives in the same repo. + logger.warn( + `[grpc-extractor] Java import package "${d.protoPackage}" for service ` + + `"${d.serviceName}" disagrees with local proto package "${protoPkg}" at ` + + `${filePath}; using proto package as authoritative source`, + ); + const cid = d.methodName + ? contractId(protoPkg, d.serviceName, d.methodName) + : serviceContractId(protoPkg, d.serviceName); + const meta: Record = { + service: d.serviceName, + source: d.source, + package: protoPkg, + protoPackageSource: 'proto-override', + importPackage: d.protoPackage, + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, d.confidenceWithProto, meta); + } + + // Step 3: import-derived fallback. No `.proto` in this repo + // names the service, and no `java_package` reverse-lookup + // matched. Emit the FQN with the import-derived package. This + // is the typical client-jar consumer path. + // + // Known limitation: when the published proto sets + // `option java_package` to a value that differs from + // `package`, this path produces a contract id that reflects + // the Java namespace, not the proto namespace, and will not + // match a provider repo. Resolving that case requires + // group-level proto knowledge, which is intentionally out of + // scope for this fix. + const cid = d.methodName + ? contractId(d.protoPackage, d.serviceName, d.methodName) + : serviceContractId(d.protoPackage, d.serviceName); + const meta: Record = { + service: d.serviceName, + source: d.source, + package: d.protoPackage, + protoPackageSource: 'import', + }; + if (d.methodName) meta.method = d.methodName; + return makeContract(cid, d.role, filePath, d.symbolName, d.confidenceWithProto, meta); + } + + // Steps 4 + 5: legacy per-repo proto map resolution (no import). const candidates = protoMap.get(d.serviceName) ?? []; const proto = resolveProtoConflict(d.serviceName, filePath, candidates); // If there were proto candidates but resolution was ambiguous, skip diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/java.ts b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts index bf1cf4816..eeacdb4c6 100644 --- a/gitnexus/src/core/group/extractors/grpc-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/grpc-patterns/java.ts @@ -78,6 +78,33 @@ const STUB_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// `import .;` — captures the proto package of the +// imported gRPC class (e.g. `cn.unipus.ucf.admin.proto.client.service` +// for `import cn.unipus.ucf.admin.proto.client.service.ContentRpcServiceGrpc`). +// Used by `scan` to build a per-file `XxxGrpc → fullPackage` map so +// consumer-side detections can carry a fully-qualified contract id +// even when the consumer repo does not contain any `.proto` files. +// +// `import static …` is excluded by tree-sitter shape: the `name:` +// field is only present on the non-static form. `import w.x.*;` is +// also excluded for the same reason — wildcard imports have an +// `asterisk` child instead of a named identifier. +const GRPC_CLASS_IMPORT_PATTERNS = compilePatterns({ + name: 'java-grpc-class-import', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (import_declaration + (scoped_identifier + scope: (_) @import_pkg + name: (identifier) @import_name (#match? @import_name "Grpc$"))) + `, + }, + ], +} satisfies LanguagePatterns>); + /** * Check whether a `class_declaration` node has a `@GrpcService` * annotation in its modifiers list. In tree-sitter-java, class-level @@ -118,6 +145,39 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { const out: GrpcDetection[] = []; const emittedClassIds = new Set(); + // ─── Build per-file gRPC class import map ─────────────────────── + // Maps `XxxGrpc` (short class name) → fully-qualified proto package + // (e.g. `cn.unipus.ucf.admin.proto.client.service`). Used below to + // tag both provider and consumer detections with a `protoPackage` + // so the orchestrator can build a fully-qualified contract id + // without depending on the current repo carrying any `.proto` + // files. This is the key fix for client-jar consumer repos. + // + // Same-short-name disambiguation: when two distinct `import` lines + // bring different `XxxGrpc` classes from different packages into + // the same file (rare for grpc — the second import would be a + // compile error in Java), the last one wins. Java's compiler + // forbids that case so we don't bother modelling it. + const grpcClassImports = new Map(); + for (const match of runCompiledPatterns(GRPC_CLASS_IMPORT_PATTERNS, tree)) { + const pkgNode = match.captures.import_pkg; + const nameNode = match.captures.import_name; + if (!pkgNode || !nameNode) continue; + grpcClassImports.set(nameNode.text, pkgNode.text); + } + + /** + * Resolve the fully-qualified proto package for a short service + * name in this file. Looks up `Grpc` in the import + * map; returns `undefined` when the class is referenced via a + * fully-qualified name on every call site (no import line) or + * when only a wildcard import is present. The orchestrator falls + * back to the per-repo proto map in that case, preserving the + * pre-fix behaviour. + */ + const protoPackageFor = (serviceName: string): string | undefined => + grpcClassImports.get(`${serviceName}Grpc`); + // ─── Providers: scoped form (`...Grpc.XxxImplBase`) ───────────── for (const match of runCompiledPatterns(SCOPED_IMPL_BASE_PATTERNS, tree)) { const classNode = match.captures.class; @@ -127,6 +187,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { if (!serviceName) continue; emittedClassIds.add(classNode.id); const annotated = hasGrpcServiceAnnotation(classNode); + const protoPackage = protoPackageFor(serviceName); out.push({ role: 'provider', serviceName, @@ -134,6 +195,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { source: annotated ? 'java_grpc_service' : 'java_impl_base', confidenceWithProto: 0.8, confidenceWithoutProto: 0.65, + ...(protoPackage ? { protoPackage } : {}), }); } @@ -147,6 +209,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { if (!serviceName) continue; emittedClassIds.add(classNode.id); const annotated = hasGrpcServiceAnnotation(classNode); + const protoPackage = protoPackageFor(serviceName); out.push({ role: 'provider', serviceName, @@ -154,6 +217,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { source: annotated ? 'java_grpc_service' : 'java_impl_base', confidenceWithProto: 0.8, confidenceWithoutProto: 0.65, + ...(protoPackage ? { protoPackage } : {}), }); } @@ -164,6 +228,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { const grpcMatch = GRPC_SUFFIX_RE.exec(grpcClsNode.text); if (!grpcMatch) continue; const serviceName = grpcMatch[1]; + const protoPackage = protoPackageFor(serviceName); out.push({ role: 'consumer', serviceName, @@ -171,6 +236,7 @@ export const JAVA_GRPC_PLUGIN: GrpcLanguagePlugin = { source: 'java_stub', confidenceWithProto: 0.75, confidenceWithoutProto: 0.55, + ...(protoPackage ? { protoPackage } : {}), }); } diff --git a/gitnexus/src/core/group/extractors/grpc-patterns/types.ts b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts index 606d9629b..dd94a4e93 100644 --- a/gitnexus/src/core/group/extractors/grpc-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/grpc-patterns/types.ts @@ -36,6 +36,18 @@ export interface GrpcDetection { confidenceWithProto: number; /** Confidence when the proto map has no entry. */ confidenceWithoutProto: number; + /** + * Optional. Fully-qualified proto package the detection's service + * belongs to (e.g. `cn.unipus.ucf.admin.proto.client.service`), + * derived directly from the source file's import statements when + * available. When set, the orchestrator uses this package to build + * the contract id INSTEAD of consulting the per-repo proto map — + * letting consumer repos that don't carry `.proto` files (the + * client-jar architecture used by most Java gRPC microservices) + * still emit a fully-qualified contract id that matches the + * provider repo's contract id verbatim. + */ + protoPackage?: string; } /** diff --git a/gitnexus/src/core/ingestion/cobol-processor.ts b/gitnexus/src/core/ingestion/cobol-processor.ts index 2e0551770..b7f3835b1 100644 --- a/gitnexus/src/core/ingestion/cobol-processor.ts +++ b/gitnexus/src/core/ingestion/cobol-processor.ts @@ -150,9 +150,24 @@ export const processCobol = ( const entry = copybookMap.get(name.toUpperCase()); return entry ? entry.path : null; }; + // Memoize preprocessed copybook content for the duration of this + // processCobol call. A single copybook is COPYed by many programs (and at + // many COPY sites within a program); without this cache + // preprocessCobolSource would re-run once per COPY site — + // O(programs × copybooks) preprocessing passes over the same content. + // Keyed by the resolved copybook path. REPLACING is applied later by the + // expander on the returned (pre-REPLACING) content (see + // cobol-copy-expander.ts readFile→applyReplacing), so caching the + // pre-REPLACING preprocessed text here is safe and per-call-scoped. + const preprocessedCopyCache = new Map(); const readCopy = (copyPath: string): string | null => { + const cached = preprocessedCopyCache.get(copyPath); + if (cached !== undefined) return cached; const content = copybookByPath.get(copyPath); - return content ? preprocessCobolSource(content) : null; + if (!content) return null; // preserves original falsy→null (missing/empty) + const preprocessed = preprocessCobolSource(content); + preprocessedCopyCache.set(copyPath, preprocessed); + return preprocessed; }; // Track module names for cross-program CALL resolution diff --git a/gitnexus/src/core/ingestion/languages/cobol/captures.ts b/gitnexus/src/core/ingestion/languages/cobol/captures.ts index 69a6c80b9..04906f7a5 100644 --- a/gitnexus/src/core/ingestion/languages/cobol/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cobol/captures.ts @@ -80,7 +80,11 @@ export function emitCobolScopeCaptures( : rangeOf(startLine, startCol, endLine, endCol); const grouped: Record = { - '@scope.module': capture('@scope.module', nameRange, name), + '@scope.module': capture( + '@scope.module', + rangeOf(startLine, startCol, endLine, endCol), + name, + ), '@declaration.program': capture( '@declaration.program', rangeOf(startLine, startCol, endLine, endCol), @@ -118,7 +122,11 @@ export function emitCobolScopeCaptures( : rangeOf(startLine, startCol, endLine, endCol); const grouped: Record = { - '@scope.module': capture('@scope.module', nameRange, prog.name), + '@scope.module': capture( + '@scope.module', + rangeOf(startLine, startCol, endLine, endCol), + prog.name, + ), '@declaration.program': capture( '@declaration.program', rangeOf(startLine, startCol, endLine, endCol), diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index e552a4600..cbed4dde1 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -81,6 +81,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = { preExtractedByPath.set(pf.filePath, pf); } + // Drop pre-extracted entries for standalone providers — these + // languages are skipped by the canonical guard below (line 164) + // and never consume preExtractedByPath, so holding onto their + // entries leaks memory until the cleanup loop at 262-264 which + // also never runs for skipped providers. + for (const [path] of preExtractedByPath) { + const lang = getLanguageFromFilename(path); + if (lang === null) continue; + const provider = SCOPE_RESOLVERS.get(lang); + if (provider?.languageProvider.parseStrategy === 'standalone') { + preExtractedByPath.delete(path); + } + } + let totalFiles = 0; let totalImports = 0; let totalRefs = 0; @@ -158,6 +172,14 @@ export const scopeResolutionPhase: PipelinePhase = { for (const [lang, provider] of SCOPE_RESOLVERS) { if (!isRegistryPrimary(lang)) continue; + // Standalone providers (COBOL, JCL) don't emit graph edges yet + // through the scope-resolution path. This is the canonical guard: + // runScopeResolution is never called for standalone providers, which + // keeps cobolPhase as the sole IMPORTS edge producer. Keep this guard + // in sync with any additional standalone providers added to + // SCOPE_RESOLVERS. + if (provider.languageProvider.parseStrategy === 'standalone') continue; + const langFiles = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang); if (langFiles.length === 0) continue; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 6ebc10782..a20d86d92 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -812,9 +812,34 @@ const processBatch = ( for (const [language, langFiles] of byLanguage) { const provider = getProvider(language); const queryString = provider.treeSitterQueries; - if (!queryString) continue; - - // Track if we need to handle tsx separately + if (!queryString) { + // Standalone providers (regex-based, no tree-sitter) that implement + // emitScopeCaptures feed into the scope-resolution pipeline via + // extractParsedFile directly — no tree-sitter involved. + if (provider.emitScopeCaptures) { + for (const file of langFiles) { + const parsedFile = extractParsedFile( + provider, + file.content, + file.path, + (message) => { + if (parentPort) { + parentPort.postMessage({ type: 'warning', message }); + } else { + logger.warn(message); + } + }, + undefined, // no cachedTree for standalone providers + ); + if (parsedFile !== undefined) { + result.parsedFiles.push(parsedFile); + result.fileCount++; + onFileProcessed?.(); + } + } + } + continue; + } const tsxFiles: ParseWorkerInput[] = []; const regularFiles: ParseWorkerInput[] = []; diff --git a/gitnexus/test/integration/cobol-pipeline-benchmark.test.ts b/gitnexus/test/integration/cobol-pipeline-benchmark.test.ts new file mode 100644 index 000000000..264579081 --- /dev/null +++ b/gitnexus/test/integration/cobol-pipeline-benchmark.test.ts @@ -0,0 +1,252 @@ +/** + * COBOL ingestion pipeline benchmark. + * + * Generates synthetic COBOL codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — scanning, + * preprocessing, COPY expansion, CALL resolution, and scope extraction. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/cobol-pipeline-benchmark.test.ts + * + * Results are identical under both REGISTRY_PRIMARY_COBOL modes because + * cobolPhase runs in both modes. Under =1, scope-resolution is skipped for + * COBOL (standalone guard at phase.ts:164), so node/edge counts come entirely + * from the legacy cobolPhase. + * + * IMPORTANT — this benchmark measures scaling in FILE COUNT, so per-file work + * must stay constant as fileCount grows. Each program therefore COPYs a fixed + * number of shared copybooks (COPYBOOKS_PER_PROGRAM), independent of fileCount. + * Do NOT make every program COPY all copybooks: copybookCount grows as + * floor(fileCount/5), so copy-all makes emitted data-item nodes — and thus + * total work — O(fileCount²), which measures copybook fan-out rather than + * file-count scaling. The pipeline itself is O(fileCount) (verified: with + * constant fan-out, node count and wall-clock scale exactly linearly); the + * node-ratio assertion below guards against reintroducing the O(n²) pattern. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + programCount: number; + paragraphCount: number; + copybookCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; +} + +function generateCobolFixture( + fileCount: number, + paragraphsPerProgram: number, +): { dir: string; programCount: number; paragraphCount: number; copybookCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cobol-bench-${fileCount}-`)); + const copybookDir = path.join(dir, 'copybooks'); + fs.mkdirSync(copybookDir, { recursive: true }); + + const programCount = fileCount; + const paragraphCount = fileCount * paragraphsPerProgram; + + // Generate shared copybooks (1 per 5 programs, at least 2) + const copybookCount = Math.max(2, Math.floor(fileCount / 5)); + const copybookNames: string[] = []; + for (let c = 0; c < copybookCount; c++) { + const name = `BENCH${String(c + 1).padStart(4, '0')}`; + copybookNames.push(name); + const copyContent = [ + ` 01 ${name}-RECORD.`, + ` 05 ${name}-KEY PIC X(10).`, + ` 05 ${name}-VALUE PIC 9(08).`, + ` 05 ${name}-FLAG PIC X(01).`, + '', + ].join('\n'); + fs.writeFileSync(path.join(copybookDir, `${name}.cpy`), copyContent); + } + + for (let f = 0; f < fileCount; f++) { + const programName = `PGM${String(f + 1).padStart(4, '0')}`; + const paragraphs: string[] = []; + + for (let p = 0; p < paragraphsPerProgram; p++) { + const paraName = `${String(p + 1).padStart(4, '0')}-PARA`; + + // Every paragraph has a PERFORM to the next paragraph (or wraps around) + const nextParaIdx = (p + 1) % paragraphsPerProgram; + const nextParaName = `${String(nextParaIdx + 1).padStart(4, '0')}-PARA`; + const performLine = ` PERFORM ${nextParaName}.`; + + // Cross-file CALL: every 3rd paragraph calls another program + const crossFileIdx = (f + p + 1) % fileCount; + const crossProgram = `PGM${String(crossFileIdx + 1).padStart(4, '0')}`; + const callLine = + p % 3 === 0 + ? ` CALL '${crossProgram}' USING ${copybookNames[p % copybookCount]}-KEY.` + : ''; + + // COPY in paragraphs adds preprocessing stress — non-idiomatic but + // exercises the preprocessor's expansion path per-paragraph. + const copyLine = ` COPY ${copybookNames[f % copybookCount]}.`; + + paragraphs.push( + ` ${paraName}.`, + copyLine, + performLine, + callLine, + ` DISPLAY '${programName} ${paraName}'.`, + '', + ); + } + + // Each program COPYs a CONSTANT number of shared copybooks (independent of + // fileCount) so per-file work stays O(1) and the benchmark measures true + // file-count scaling. Copybooks are chosen by program index so they remain + // shared across programs (fan-in), still exercising cross-program copybook + // reuse and multi-COPY-per-program expansion. (Copying ALL copybooks here + // would make per-file work — and emitted data-item nodes — grow with + // fileCount, i.e. O(fileCount²); see the file header.) + const COPYBOOKS_PER_PROGRAM = 3; + const wsCopybooks = [ + ...new Set( + Array.from( + { length: COPYBOOKS_PER_PROGRAM }, + (_, k) => copybookNames[(f + k) % copybookCount], + ), + ), + ]; + + const content = [ + ` IDENTIFICATION DIVISION.`, + ` PROGRAM-ID. ${programName}.`, + ` ENVIRONMENT DIVISION.`, + ` DATA DIVISION.`, + ` WORKING-STORAGE SECTION.`, + ...wsCopybooks.map((n) => ` COPY ${n}.`), + ` PROCEDURE DIVISION.`, + ...paragraphs, + ` STOP RUN.`, + ` END PROGRAM ${programName}.`, + '', + ].join('\n'); + + fs.writeFileSync(path.join(dir, `${programName}.cbl`), content); + } + + return { dir, programCount, paragraphCount, copybookCount }; +} + +async function runBenchmark( + fileCount: number, + paragraphsPerProgram: number, + budgetMs: number, +): Promise { + const { dir, programCount, paragraphCount, copybookCount } = generateCobolFixture( + fileCount, + paragraphsPerProgram, + ); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount, + programCount, + paragraphCount, + copybookCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log( + '┌──────────┬──────────┬────────────┬──────────┬───────────┬──────────┬───────┬───────┐', + ); + console.log( + '│ Files │ Programs │ Paragraphs │ Copybooks│ Time (ms) │ Heap MB │ Nodes │ Edges │', + ); + console.log( + '├──────────┼──────────┼────────────┼──────────┼───────────┼──────────┼───────┼───────┤', + ); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.programCount).padStart(8)} │ ${String(r.paragraphCount).padStart(10)} │ ${String(r.copybookCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log( + '└──────────┴──────────┴────────────┴──────────┴───────────┴──────────┴───────┴───────┘', + ); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} \u2192 ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('COBOL pipeline benchmark', () => { + it('scales with file count', async () => { + const scales = [100, 250, 500, 1000]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const paragraphsPerProgram = 3; + const result = await runBenchmark(fileCount, paragraphsPerProgram, 300_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('COBOL Pipeline', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + // Wall-clock is noisy (GC/CI load); keep a coarse upper bound here. + expect(timeRatio / fileRatio).toBeLessThan(4); + + // Deterministic regression guard: with constant per-program copybook + // fan-out the emitted node count is exactly linear in fileCount + // (ratio ≈ 1.0). If someone reintroduces O(fileCount²) work — e.g. by + // making every program COPY all copybooks — node growth jumps to ~2x + // per file-doubling and this fails. Node count is deterministic, so + // this is a non-flaky guard unlike the wall-clock check above. + const nodeRatio = results[i].nodeCount / results[i - 1].nodeCount; + expect(nodeRatio / fileRatio).toBeLessThan(1.3); + } + }, 600_000); +}); diff --git a/gitnexus/test/integration/resolvers/cobol-scope.test.ts b/gitnexus/test/integration/resolvers/cobol-scope.test.ts index bd57656c9..4854d3977 100644 --- a/gitnexus/test/integration/resolvers/cobol-scope.test.ts +++ b/gitnexus/test/integration/resolvers/cobol-scope.test.ts @@ -14,7 +14,7 @@ import path from 'path'; import fs from 'fs'; import { emitCobolScopeCaptures } from '../../../src/core/ingestion/languages/cobol/captures.js'; -const FIXTURES = path.resolve(process.cwd(), 'test/fixtures/cobol'); +const FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'cobol'); // --------------------------------------------------------------------------- // Helpers diff --git a/gitnexus/test/integration/resolvers/cobol.test.ts b/gitnexus/test/integration/resolvers/cobol.test.ts index cc47ac6b3..44111b00b 100644 --- a/gitnexus/test/integration/resolvers/cobol.test.ts +++ b/gitnexus/test/integration/resolvers/cobol.test.ts @@ -18,6 +18,12 @@ import { runPipelineFromRepo, type PipelineResult, } from './helpers.js'; +import { isRegistryPrimary } from '../../../src/core/ingestion/registry-primary-flag.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { cobolProvider } from '../../../src/core/ingestion/languages/cobol.js'; + +const isPrimary = isRegistryPrimary(SupportedLanguages.Cobol); describe('COBOL full system extraction', () => { let result: PipelineResult; @@ -715,4 +721,48 @@ describe('COBOL full system extraction', () => { expect(getRelationships(result, 'ACCESSES').length).toBe(25); }); }); + + // ===================================================================== + // SCOPE-RESOLUTION MODE: when REGISTRY_PRIMARY_COBOL=1, the scope- + // resolution pipeline produces captures from standalone providers. + // These tests verify that the scope-resolution output matches expected + // capture counts for the cobol-app fixture. + // ===================================================================== + + describe('scope-resolution mode', () => { + // Scope-resolution captures are only produced when registry-primary + // flips COBOL into the scope-resolution pipeline (REGISTRY_PRIMARY_COBOL=1). + // Under legacy mode (=0), the legacy cobolPhase produces graph edges + // tested above — scope-resolution captures are not expected. + + it('scope-resolution pipeline produces capture output when REGISTRY_PRIMARY_COBOL=1', () => { + if (!isPrimary) { + // Legacy mode (REGISTRY_PRIMARY_COBOL=0): scope-resolution phases + // are skipped (skipGraphPhases=true), so parsedFiles is not populated. + return; + } + // Registry-primary mode: standalone provider wiring in parse-worker + // produces scope captures via emitCobolScopeCaptures + expect(result.graph).not.toBeNull(); + expect(Object.keys(result.graph.nodes ?? {}).length).toBeGreaterThan(0); + }); + + it('extractParsedFile works for standalone COBOL provider', () => { + const source = ` + IDENTIFICATION DIVISION. + PROGRAM-ID. TESTPROG. + PROCEDURE DIVISION. + DISPLAY 'hello'. + STOP RUN. + END PROGRAM TESTPROG. + `; + const parsedFile = extractParsedFile(cobolProvider, source, 'TESTPROG.cbl', () => {}); + + expect(parsedFile).not.toBeNull(); + // Use toBe for strict equality — not.toBeNull() per DoD + expect(parsedFile!.scopes.length).toBeGreaterThan(0); + expect(typeof parsedFile!.moduleScope).toBe('string'); + expect(parsedFile!.moduleScope.length).toBeGreaterThan(0); + }); + }); }); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index 127dd6ab9..1a4a6ef47 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -17,6 +17,7 @@ import { serviceContractId, } from '../../../src/core/group/extractors/grpc-extractor.js'; import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js'; +import { buildProviderIndex, runWildcardMatch } from '../../../src/core/group/matching.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; import { _captureLogger } from '../../../src/core/logger.js'; @@ -384,6 +385,566 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { }); }); + // ─── Java client-jar / import-derived FQN ───────────────────────── + // The "client-jar" architecture is the dominant pattern for Java + // gRPC microservices: the service owner publishes a pre-compiled + // stub jar to a Maven repository, and consumer repos depend on the + // jar instead of carrying the originating `.proto` files. Examples: + // gRPC official quickstart, Alibaba HSF, ByteDance KiteX-Java, + // google-cloud-java SDK. + // + // Before this fix, the extractor only resolved a fully-qualified + // contract id (`grpc::./*`) when the consumer + // repo also carried a matching `.proto` file. Client-jar consumers + // had no proto, so they fell back to a short-name contract id + // (`grpc::/*`) that never matched the provider repo's + // package-qualified contract id — cross-repo grpc cross-link count + // dropped to zero on every realistic Java micro-service group. + // + // The fix derives the FQN directly from the consumer file's `import + // .;` statement, which is always present (without it + // the Java code wouldn't even compile). The package from the import + // is exactly the proto package, so the contract id matches the + // provider's verbatim — no `.proto` lookup needed. + describe('Java client-jar consumer (import-derived FQN)', () => { + it('test_consumer_with_import_emits_fqn_contract_id_without_local_proto', async () => { + // No .proto file in this repo — the consumer ONLY has the import. + writeFile( + 'src/main/java/AuthClient.java', + `package my.app; + +import io.grpc.ManagedChannel; +import com.acme.auth.proto.AuthServiceGrpc; + +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).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::com.acme.auth.proto.AuthService/*'); + // Confidence stays at the "with proto" tier: the import + // statement is at least as authoritative as a per-repo proto + // map, so consumers shouldn't be penalised for not carrying + // a redundant `.proto` file. + expect(consumers[0].confidence).toBe(0.75); + expect(consumers[0].meta.protoPackageSource).toBe('import'); + expect(consumers[0].meta.package).toBe('com.acme.auth.proto'); + }); + + it('test_provider_with_import_emits_fqn_contract_id_without_local_proto', async () => { + // Same idea on the provider side: a server impl class lives in + // a repo that does NOT carry the originating `.proto`. The + // import on `AuthServiceGrpc` is enough to derive the FQN. + writeFile( + 'src/main/java/AuthServerImpl.java', + `package my.server; + +import com.acme.auth.proto.AuthServiceGrpc; +import io.grpc.stub.StreamObserver; + +public class AuthServerImpl extends AuthServiceGrpc.AuthServiceImplBase { + @Override + public void login(LoginRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::com.acme.auth.proto.AuthService/*'); + expect(providers[0].confidence).toBe(0.8); + expect(providers[0].meta.protoPackageSource).toBe('import'); + }); + + it('test_same_short_name_different_packages_resolves_to_distinct_fqns', async () => { + // The motivating real-world case (unipus_cloud_framework): + // `ContentRpcService` is defined in TWO different proto packages + // by two different client modules. + // + // ucf-api-client/Service.proto → cn.unipus.ucf.api.proto.client.service.ContentRpcService + // ucf-admin-client/Service.proto → cn.unipus.ucf.admin.proto.client.service.ContentRpcService + // + // A short-name fallback would silently merge consumers of the + // two services into one bogus contract id; the import-derived + // FQN keeps them distinct. + writeFile( + 'src/main/java/ApiContentClient.java', + `package my.app.api; + +import io.grpc.ManagedChannel; +import cn.unipus.ucf.api.proto.client.service.ContentRpcServiceGrpc; + +public class ApiContentClient { + private final ContentRpcServiceGrpc.ContentRpcServiceBlockingStub stub; + public ApiContentClient(ManagedChannel ch) { + this.stub = ContentRpcServiceGrpc.newBlockingStub(ch); + } +}`, + ); + writeFile( + 'src/main/java/AdminContentClient.java', + `package my.app.admin; + +import io.grpc.ManagedChannel; +import cn.unipus.ucf.admin.proto.client.service.ContentRpcServiceGrpc; + +public class AdminContentClient { + private final ContentRpcServiceGrpc.ContentRpcServiceBlockingStub stub; + public AdminContentClient(ManagedChannel ch) { + this.stub = ContentRpcServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(2); + const ids = consumers.map((c) => c.contractId).sort(); + expect(ids).toEqual([ + 'grpc::cn.unipus.ucf.admin.proto.client.service.ContentRpcService/*', + 'grpc::cn.unipus.ucf.api.proto.client.service.ContentRpcService/*', + ]); + }); + + it('test_local_proto_overrides_unrelated_import_with_same_short_name', async () => { + // Symmetric to Finding 2: when the consumer repo carries its + // OWN `.proto` defining the same short service name, the proto + // is authoritative and wins over a Java import that points at a + // different package. Without this Step-2 cross-check, a typo'd + // or stale Java import (or genuinely unrelated same-name + // service in the same repo) would silently corrupt the + // contract id of the locally-defined service. + writeFile( + 'protos/local-other.proto', + `syntax = "proto3"; +package local.unrelated; + +service AuthService { + rpc Ping (PingRequest) returns (PingResponse); +}`, + ); + writeFile( + 'src/main/java/AuthClient.java', + `package my.app; + +import io.grpc.ManagedChannel; +import com.acme.auth.proto.AuthServiceGrpc; + +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).toHaveLength(1); + // Local proto wins. The disagreement is recorded so operators + // can investigate the divergent import. + expect(consumers[0].contractId).toBe('grpc::local.unrelated.AuthService/*'); + expect(consumers[0].meta.protoPackageSource).toBe('proto-override'); + expect(consumers[0].meta.importPackage).toBe('com.acme.auth.proto'); + }); + + it('test_consumer_without_import_falls_back_to_proto_map', async () => { + // No import line — perhaps a fully-qualified call site like + // `com.acme.auth.proto.AuthServiceGrpc.newBlockingStub(...)`, + // or a refactor that broke the import. The current STUB_PATTERNS + // captures only `(identifier) @grpc_cls`, so it skips the + // fully-qualified form. With no detection there's also nothing + // for the proto-map fallback to anchor onto. We assert the + // benign no-op (no false-positive emitted) — the proto-map + // fallback path is exercised by the dedicated test below. + writeFile( + 'src/main/java/AuthClient.java', + `package my.app; + +import io.grpc.ManagedChannel; + +public class AuthClient { + private final com.acme.auth.proto.AuthServiceGrpc.AuthServiceBlockingStub stub; + public AuthClient(ManagedChannel ch) { + this.stub = com.acme.auth.proto.AuthServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // STUB_PATTERNS only captures bare-identifier `XxxGrpc`, so the + // fully-qualified `com.acme.auth.proto.AuthServiceGrpc.newStub(...)` + // form is intentionally not matched. Pinning behaviour so the + // import-driven path doesn't accidentally introduce a regression. + expect(consumers).toHaveLength(0); + }); + + it('test_short_import_consumer_with_local_proto_still_uses_proto_map', async () => { + // Backward-compat: when the consumer repo HAS a matching + // `.proto` (the legacy path) AND the import is present, both + // paths agree — but we want to confirm the import-driven path + // takes precedence and emits the same FQN with the + // `protoPackageSource: 'import'` marker. + writeFile( + 'protos/auth.proto', + `syntax = "proto3"; +package com.acme.auth.proto; + +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + writeFile( + 'src/main/java/AuthClient.java', + `package my.app; + +import io.grpc.ManagedChannel; +import com.acme.auth.proto.AuthServiceGrpc; + +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).toHaveLength(1); + expect(consumers[0].contractId).toBe('grpc::com.acme.auth.proto.AuthService/*'); + // Marker confirms import path won, not the proto map. Both + // would have produced the same FQN, but only the import path + // is robust against client-jar consumers and same-short-name + // collisions. + expect(consumers[0].meta.protoPackageSource).toBe('import'); + }); + + it('test_static_and_wildcard_imports_are_ignored', async () => { + // `import static …` and `import w.x.*;` shouldn't pollute the + // import map. Pinned via the tree-sitter query shape (the + // `name:` field is only present on the non-static, non-wildcard + // form). When the only `XxxGrpc` reference comes through one + // of these unsupported import styles, the consumer detection + // emits nothing-import-derived and the legacy short-name + // fallback applies. + writeFile( + 'src/main/java/AuthClient.java', + `package my.app; + +import static com.acme.auth.proto.Constants.SOMETHING; +import com.acme.unrelated.*; +import io.grpc.ManagedChannel; + +public class AuthClient { + private final com.acme.auth.proto.AuthServiceGrpc.AuthServiceBlockingStub stub; + public AuthClient(ManagedChannel ch) { + this.stub = com.acme.auth.proto.AuthServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // STUB_PATTERNS doesn't match fully-qualified call forms; this + // pins that adding GRPC_CLASS_IMPORT_PATTERNS doesn't accidentally + // lift the static / wildcard imports into the FQN map (which + // would have created a phantom detection). + expect(consumers).toHaveLength(0); + }); + + it('test_provider_in_client_jar_consumer_repo_emits_provider_too', async () => { + // Same repo holds a SERVER impl whose only knowledge of the + // proto package is the import — no `.proto` is present. The + // provider detection should also use the import-derived FQN. + writeFile( + 'src/main/java/AuthServer.java', + `package my.server; + +import com.acme.auth.proto.AuthServiceGrpc; +import io.grpc.stub.StreamObserver; + +@GrpcService +public class AuthServer extends AuthServiceGrpc.AuthServiceImplBase { + @Override + public void login(LoginRequest req, StreamObserver obs) {} +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(providers).toHaveLength(1); + expect(providers[0].contractId).toBe('grpc::com.acme.auth.proto.AuthService/*'); + expect(providers[0].confidence).toBe(0.8); + expect(providers[0].meta.protoPackageSource).toBe('import'); + }); + + it('test_unipus_admin_and_api_consumers_in_one_repo_do_not_collide', async () => { + // End-to-end version of the same-short-name case: a single + // consumer repo imports BOTH `ContentRpcService` flavours from + // unipus_cloud_framework. Ensures the per-file import map is + // file-local (each file's import wins for that file's call sites) + // rather than blurring across the whole repo. + writeFile( + 'src/main/java/api/ApiContentClient.java', + `package my.app.api; + +import io.grpc.ManagedChannel; +import cn.unipus.ucf.api.proto.client.service.ContentRpcServiceGrpc; + +public class ApiContentClient { + public ApiContentClient(ManagedChannel ch) { + ContentRpcServiceGrpc.newBlockingStub(ch); + } +}`, + ); + writeFile( + 'src/main/java/admin/AdminContentClient.java', + `package my.app.admin; + +import io.grpc.ManagedChannel; +import cn.unipus.ucf.admin.proto.client.service.ContentRpcServiceGrpc; + +public class AdminContentClient { + public AdminContentClient(ManagedChannel ch) { + ContentRpcServiceGrpc.newBlockingStub(ch); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(2); + const ids = new Set(consumers.map((c) => c.contractId)); + expect(ids.has('grpc::cn.unipus.ucf.api.proto.client.service.ContentRpcService/*')).toBe( + true, + ); + expect(ids.has('grpc::cn.unipus.ucf.admin.proto.client.service.ContentRpcService/*')).toBe( + true, + ); + }); + }); + + // ─── Java `option java_package` divergence ──────────────────── + // Java protobuf projects frequently set + // `option java_package = "..."` to publish their generated Java + // classes under a namespace different from the proto `package` + // declaration. Google Cloud Java SDKs are the canonical example: + // proto `package google.cloud.speech.v1` + `option java_package = + // "com.google.cloud.speech.v1"`. Without specific handling, the + // import-derived FQN would reflect the Java namespace instead of + // the wire-protocol namespace and never match a provider's + // contract id. + // + // The cases below pin the four resolution branches in + // `detectionToContract`: + // + // 1. java_package translation (same-repo provider with the + // option set; consumer in the same repo imports via the + // java_package — the reverse index translates back to the + // proto package); + // 2. proto-map cross-check (local proto exists for the same + // service short name and AGREES with the import — both paths + // produce the same FQN, marker confirms import path took + // precedence); + // 2b. proto-map cross-check (local proto DISAGREES with the + // import — the proto wins authoritatively, the import package + // is recorded as `meta.importPackage` for diagnostics); + // 3. import-derived fallback known limitation (consumer repo + // carries no proto AND the published proto sets a divergent + // java_package — we cannot translate without the proto in + // reach, so the FQN reflects the Java namespace and will not + // match a provider repo. This is documented as a scope + // limitation; the test pins the limitation to catch any + // accidental change in behaviour). + describe('Java option java_package divergence', () => { + it('test_provider_proto_with_diverging_java_package_emits_proto_package_FQN', async () => { + // Provider side: proto declares both `package` and a + // different `option java_package`. The provider contract id + // must use the proto `package` — that's the wire identity any + // consumer (regardless of its language) will see at runtime. + writeFile( + 'proto/speech.proto', + `syntax = "proto3"; +package google.cloud.speech.v1; +option java_package = "com.google.cloud.speech.v1"; +service Speech { + rpc Recognize (RecognizeRequest) returns (RecognizeResponse); +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.role === 'provider'); + + const recognize = providers.find((c) => c.contractId.endsWith('Speech/Recognize')); + expect(recognize).toBeDefined(); + // Wire-protocol package, NOT the java_package value. + expect(recognize!.contractId).toBe('grpc::google.cloud.speech.v1.Speech/Recognize'); + }); + + it('test_consumer_with_java_package_translation_uses_proto_package', async () => { + // Same repo carries the proto with a divergent java_package + // AND a Java consumer that imports via the java_package. The + // reverse index built by `buildProtoContext` should translate + // the import back to the proto package so the consumer's + // contract id matches the provider's. + writeFile( + 'proto/speech.proto', + `syntax = "proto3"; +package google.cloud.speech.v1; +option java_package = "com.google.cloud.speech.v1"; +service Speech { + rpc Recognize (RecognizeRequest) returns (RecognizeResponse); +}`, + ); + writeFile( + 'src/main/java/SpeechClient.java', + `package my.app; + +import io.grpc.ManagedChannel; +import com.google.cloud.speech.v1.SpeechGrpc; + +public class SpeechClient { + public SpeechClient(ManagedChannel ch) { + SpeechGrpc.newBlockingStub(ch).recognize(null); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + // The reverse-index translation kicked in: + // import "com.google.cloud.speech.v1" + // ↓ (javaPackageMap lookup) + // proto pkg "google.cloud.speech.v1" ← used in contract id + expect(consumers[0].contractId).toBe('grpc::google.cloud.speech.v1.Speech/*'); + expect(consumers[0].meta.protoPackageSource).toBe('import-translated'); + expect(consumers[0].meta.package).toBe('google.cloud.speech.v1'); + }); + + it('test_consumer_without_local_proto_and_diverging_java_package_is_known_limitation', async () => { + // Client-jar consumer: zero `.proto` in this repo, and the + // published proto (somewhere else) uses a divergent + // java_package. We have no way to translate from + // java_package back to proto package without sight of the + // source proto. The current behaviour is to use the + // import-derived java_package literally; the resulting + // contract id will not match a provider's. This is a + // documented scope limitation — resolving it requires + // group-level proto knowledge that's out of scope for this + // change. The test pins the limitation so it cannot + // regress silently. + writeFile( + 'src/main/java/SpeechClient.java', + `package my.app; + +import io.grpc.ManagedChannel; +import com.google.cloud.speech.v1.SpeechGrpc; + +public class SpeechClient { + public SpeechClient(ManagedChannel ch) { + SpeechGrpc.newBlockingStub(ch).recognize(null); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + // Pinned limitation: the FQN reflects the Java namespace. + expect(consumers[0].contractId).toBe('grpc::com.google.cloud.speech.v1.Speech/*'); + expect(consumers[0].meta.protoPackageSource).toBe('import'); + }); + }); + + // ─── End-to-end wildcard match (Finding 3) ──────────────────── + // The 9 unit tests above pin contract-id shape; this block pins + // the next stage of the pipeline — `runWildcardMatch` against a + // provider index — so a regression in either contract-id format + // OR in the matcher's wildcard logic would fail here. Per DoD §2.7 + // ("tests cover the real changed path"), exercising the pipeline + // end to end is the production-readiness signal we need. + describe('Java client-jar consumer — end-to-end wildcard match', () => { + it('test_e2e_client_jar_consumer_FQN_creates_wildcard_cross_link', async () => { + // Two-repo group fixture, written into separate subdirectories + // of tmpDir so the per-repo `extract()` can run isolated. + const providerDir = path.join(tmpDir, 'provider-repo'); + const consumerDir = path.join(tmpDir, 'consumer-repo'); + fs.mkdirSync(path.join(providerDir, 'proto'), { recursive: true }); + fs.mkdirSync(path.join(consumerDir, 'src/main/java'), { recursive: true }); + + fs.writeFileSync( + path.join(providerDir, 'proto/auth.proto'), + `syntax = "proto3"; +package com.acme.auth.proto; +service AuthService { + rpc Login (LoginRequest) returns (LoginResponse); +}`, + ); + // Consumer repo carries NO `.proto` — typical client-jar pattern. + fs.writeFileSync( + path.join(consumerDir, 'src/main/java/AuthClient.java'), + `package my.app; + +import io.grpc.ManagedChannel; +import com.acme.auth.proto.AuthServiceGrpc; + +public class AuthClient { + public AuthClient(ManagedChannel ch) { + AuthServiceGrpc.newBlockingStub(ch).login(null); + } +}`, + ); + + const providerExtracted = await extractor.extract(null, providerDir, makeRepo(providerDir)); + const consumerExtracted = await extractor.extract(null, consumerDir, makeRepo(consumerDir)); + + // Stamp `repo` on the contracts so they look like StoredContract; + // matching.ts skips same-repo cross-links by comparing this field. + const stored = [ + ...providerExtracted.map((c) => ({ ...c, repo: 'provider' })), + ...consumerExtracted.map((c) => ({ ...c, repo: 'consumer' })), + ]; + + const providerIndex = buildProviderIndex(stored); + const consumerWildcards = stored.filter( + (c) => c.role === 'consumer' && c.contractId.endsWith('/*'), + ); + const result = runWildcardMatch(consumerWildcards, providerIndex); + + // The consumer's contract id is the package-qualified service + // wildcard (`grpc::com.acme.auth.proto.AuthService/*`); the + // provider emits a method-level id (`grpc::com.acme.auth.proto. + // AuthService/Login`). The wildcard matcher pairs them and + // produces exactly one cross-link. + expect(result.matched).toHaveLength(1); + const cross = result.matched[0]; + expect(cross.contractId).toBe('grpc::com.acme.auth.proto.AuthService/*'); + expect(cross.matchType).toBe('wildcard'); + expect(cross.from.repo).toBe('consumer'); + expect(cross.to.repo).toBe('provider'); + }); + }); + describe('Python detection', () => { it('test_extract_python_add_servicer_returns_provider', async () => { writeFile(