fix(group): derive grpc consumer FQN from java imports for client-jar consumers (#1889)

* fix(group): derive grpc consumer FQN from java imports so client-jar consumers don't fall back to short names

Java gRPC microservices commonly follow the "client-jar" pattern: 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. gRPC's official Java quickstart, Alibaba
HSF, ByteDance KiteX-Java and google-cloud-java all document this
shape.

Before this commit, `GrpcExtractor` resolved a fully-qualified
contract id (`grpc::<package>.<Service>/*`) only when the consumer
repo also carried a matching `.proto`. Client-jar consumers had no
proto, so they fell back to a short-name contract id
(`grpc::<Service>/*`) that never matched the provider's contract id.
Cross-repo grpc cross-link counts dropped to zero on every realistic
Java microservice group — including all of crsdp's `crsdp-backend →
unipus_cloud_framework` connections.

Fix: derive the proto package directly from each consumer file's
`import <pkg>.<XxxGrpc>;` statement. The package from the import is
exactly the proto package, so the contract id matches the provider's
verbatim — no `.proto` lookup needed in the consumer repo.

Implementation
--------------

* `grpc-patterns/types.ts` — `GrpcDetection` gains an optional
  `protoPackage` field. Plugins set it when the package can be
  derived from the source file alone.
* `grpc-patterns/java.ts` — adds `GRPC_CLASS_IMPORT_PATTERNS`, a
  tree-sitter query that captures every
  `import_declaration > scoped_identifier { scope, name }` pair where
  the imported name ends in `Grpc`. `import static …` and
  `import w.x.*;` are excluded by tree-sitter shape: the `name:` field
  is only present on the non-static, non-wildcard form. The plugin
  builds a per-file `XxxGrpc → fullPackage` map and tags every
  provider / consumer detection it emits.
* `grpc-extractor.ts` — `detectionToContract()` now resolves the
  contract id in three steps:
    1. detection-supplied `protoPackage` wins (skips the proto map
       entirely so an unrelated same-name service in the consumer
       repo can't blur the FQN);
    2. otherwise consult the legacy per-repo proto map;
    3. otherwise fall back to a short-name contract id, preserving
       pre-fix behaviour.
  Confidence stays at the "with proto" tier when the import path
  resolves: an import statement in real source is at least as
  authoritative as a per-repo proto map.

Same-short-name disambiguation
-------------------------------

The motivating case `unipus_cloud_framework` defines two distinct
`ContentRpcService` services in different proto packages
(`cn.unipus.ucf.api.proto.client.service.ContentRpcService` vs
`cn.unipus.ucf.admin.proto.client.service.ContentRpcService`). Two
consumer files importing the two flavours now emit two distinct FQNs;
neither could be told apart from the other under the legacy short-
name fallback.

Out of scope
------------

`import w.x.*;` (wildcard service imports) are left to the legacy
short-name fallback. Wildcard imports are discouraged by Google's
Java style guide and IntelliJ's defaults, and resolving them
unambiguously would require either group-level proto-package
catalogs or per-class disambiguation, both of which are larger
follow-ups. This commit only changes behaviour for the dominant
specific-import case.

Tests
-----

`test/unit/group/grpc-extractor.test.ts` adds a new "Java client-jar
consumer (import-derived FQN)" describe block with 9 cases covering
both the happy paths (consumer/provider FQN derivation, same-short-
name disambiguation, import-vs-local-proto precedence) and the
regression-protection paths (no import + no detection emitted, static
imports / wildcards ignored, mixed-file repos preserved).

End-to-end verification
-----------------------

Ran the patched cli on the real `crsdp-backend` (consumer, no
`.proto`) and `unipus_cloud_framework` (provider, has `.proto`)
repos. Synced as a two-repo group, every `XxxGrpc` referenced via a
specific import in `UcfAdminGrpcClientService.java` produced an FQN
contract id that exact-matched the provider repo's FQN — 9 grpc
cross-links surfaced where there were 0 before.

Verification
------------

* `npx tsc --noEmit`: pass
* `npx tsc` (dist rebuild): pass
* `test/unit/group/grpc-extractor.test.ts`: 60/60 pass (51 existing
  + 9 new)
* `test/unit/group/`: 30 files / 545 tests all green
* `npx prettier --check` on touched files: pass
* `npx eslint` on touched src files: 0 errors / 0 warnings

* fix(group): handle option java_package and proto-map disagreement in grpc detection

Addresses Claude bot review on PR #1889:

- Finding 1: parse `option java_package` when building proto context;
  add a reverse index so an import-derived package can be translated
  back to the proto package.
- Finding 2: when same-repo proto map has the service, use the proto
  package; warn and record `meta.importPackage` if the import disagrees.
- Finding 3: add an end-to-end wildcard match test (provider+consumer
  fixture, runs `buildProviderIndex`+`runWildcardMatch`).

Client-jar consumer + diverging `java_package` (no local proto)
remains a known limitation; pinned by a dedicated test.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
henry201605 2026-05-29 21:55:28 +08:00 committed by GitHub
parent 23bf594a70
commit 4bc8622642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 840 additions and 2 deletions

View file

@ -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<string, string>;
servicesByName: Map<string, ProtoServiceInfo[]>;
/**
* 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<string, ProtoServiceInfo[]>;
}> {
const servicesByName = new Map<string, ProtoServiceInfo[]>();
const servicesByJavaPackage = new Map<string, ProtoServiceInfo[]>();
// `.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<Map<string, ProtoServiceInfo[]>> {
@ -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<string, ProtoServiceInfo[]>,
javaPackageMap: Map<string, ProtoServiceInfo[]>,
): 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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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

View file

@ -78,6 +78,33 @@ const STUB_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// `import <pkg>.<XxxGrpc>;` — 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<Record<string, never>>);
/**
* 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<number>();
// ─── 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<string, string>();
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 `<serviceName>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 } : {}),
});
}

View file

@ -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;
}
/**

View file

@ -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::<package>.<Service>/*`) 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::<Service>/*`) 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
// <pkg>.<XxxGrpc>;` 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<LoginResponse> 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<LoginResponse> 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(