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/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(