mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge branch 'main' into feat/query-exclude-tests-by-default
This commit is contained in:
commit
2c7c5b1ecf
12 changed files with 1220 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -80,7 +80,11 @@ export function emitCobolScopeCaptures(
|
|||
: rangeOf(startLine, startCol, endLine, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@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<string, Capture> = {
|
||||
'@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),
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.Ruby,
|
||||
SupportedLanguages.Cobol,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -116,6 +116,20 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
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<ScopeResolutionOutput> = {
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
252
gitnexus/test/integration/cobol-pipeline-benchmark.test.ts
Normal file
252
gitnexus/test/integration/cobol-pipeline-benchmark.test.ts
Normal file
|
|
@ -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<BenchResult> {
|
||||
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<never>((_, 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue