From b9ceb08cc1732365c56a52e7aff384315152d91a Mon Sep 17 00:00:00 2001 From: liyipeng06 Date: Mon, 27 Apr 2026 20:53:44 +0800 Subject: [PATCH] feat(group): match thrift service wildcards --- gitnexus/src/core/group/matching.ts | 51 +++++++----- gitnexus/src/core/group/sync.ts | 10 ++- gitnexus/test/unit/group/matching.test.ts | 94 +++++++++++++++++++++++ gitnexus/test/unit/group/sync.test.ts | 76 ++++++++++++++++++ 4 files changed, 206 insertions(+), 25 deletions(-) diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index ec793968b..44138da36 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -10,8 +10,8 @@ export interface WildcardMatchResult { remaining: StoredContract[]; } -function isGrpcWildcard(cid: string): boolean { - return cid.startsWith('grpc::') && cid.endsWith('/*'); +function isServiceWildcard(cid: string): boolean { + return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*'); } export function normalizeContractId(id: string): string { @@ -32,8 +32,9 @@ export function normalizeContractId(id: string): string { } return id; } - case 'grpc': { - // Canonical form: `grpc::[/]`. + case 'grpc': + case 'thrift': { + // Canonical form: `::[/]`. // // The package/service segment is lowercased because gRPC package // names are effectively case-insensitive across language bindings @@ -47,22 +48,23 @@ export function normalizeContractId(id: string): string { // as DISTINCT canonical forms: `grpc::userservice` does not match // `grpc::userservice/Login`. That's by design — callers that want // service-level manifest matching against method-level providers - // should use the gRPC wildcard form `grpc::UserService/*` which is + // should use the service wildcard form `grpc::UserService/*` or + // `thrift::UserService/*` which is // handled by runWildcardMatch below. const slashIdx = rest.indexOf('/'); if (slashIdx > 0) { const pkg = rest.substring(0, slashIdx).toLowerCase(); const method = rest.substring(slashIdx); - return `grpc::${pkg}${method}`; + return `${type}::${pkg}${method}`; } if (slashIdx === 0) { // Malformed "/method" with leading slash — keep as-is so two // equally malformed ids can still match each other. - return `grpc::${rest}`; + return `${type}::${rest}`; } // No slash: package/service only. Lowercase to match the package // segment produced by the pkg/method branch above. - return `grpc::${rest.toLowerCase()}`; + return `${type}::${rest.toLowerCase()}`; } case 'topic': return `topic::${rest.trim().toLowerCase()}`; @@ -109,8 +111,10 @@ export function runExactMatch( ): MatchResult { const index = providerIndex ?? buildProviderIndex(contracts); - // Skip gRPC wildcard consumers — they go to wildcard pass only - const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId)); + // Skip service wildcard consumers — they go to wildcard pass only + const consumers = contracts.filter( + (c) => c.role === 'consumer' && !isServiceWildcard(c.contractId), + ); const matched: CrossLink[] = []; const matchedConsumerIds = new Set(); @@ -154,14 +158,14 @@ export function runExactMatch( // normalUnmatched: contracts that weren't matched in exact pass const normalUnmatched = contracts.filter((c) => { - if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately + if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately const id = `${c.repo}::${c.contractId}`; return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); }); - // Re-add gRPC wildcard contracts — they were never in exact matching - const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId)); - const unmatched = [...normalUnmatched, ...grpcWildcards]; + // Re-add service wildcard contracts — they were never in exact matching + const serviceWildcards = contracts.filter((c) => isServiceWildcard(c.contractId)); + const unmatched = [...normalUnmatched, ...serviceWildcards]; return { matched, unmatched }; } @@ -171,21 +175,26 @@ export function runWildcardMatch( providerIndex: Map, ): WildcardMatchResult { const wildcardConsumers = unmatched.filter( - (c) => c.role === 'consumer' && isGrpcWildcard(c.contractId), + (c) => c.role === 'consumer' && isServiceWildcard(c.contractId), ); const matched: CrossLink[] = []; const matchedConsumerIds = new Set(); for (const consumer of wildcardConsumers) { const normalized = normalizeContractId(consumer.contractId); + const typeEnd = normalized.indexOf('::'); + const consumerType = normalized.slice(0, typeEnd); // "grpc::com.example.userservice/*" → "com.example.userservice" - // "grpc::userservice/*" → "userservice" - const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*" + // "thrift::userservice/*" → "userservice" + const fqService = normalized.slice(typeEnd + 2, -2); // strip "::" and "/*" for (const [key, providers] of providerIndex) { - // Only match against non-wildcard gRPC providers (method-level IDs) - if (!key.startsWith('grpc::') || key.endsWith('/*')) continue; - const afterPrefix = key.slice(6); // strip "grpc::" + // Only match against non-wildcard same-type providers (method-level IDs). + const keyTypeEnd = key.indexOf('::'); + if (keyTypeEnd < 0 || key.endsWith('/*')) continue; + const providerType = key.slice(0, keyTypeEnd); + if (providerType !== consumerType) continue; + const afterPrefix = key.slice(keyTypeEnd + 2); // strip "::" const slashIdx = afterPrefix.indexOf('/'); if (slashIdx < 0) continue; const providerFqService = afterPrefix.slice(0, slashIdx); @@ -229,7 +238,7 @@ export function runWildcardMatch( } const remaining = unmatched.filter((c) => { - if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true; + if (c.role !== 'consumer' || !isServiceWildcard(c.contractId)) return true; return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`); }); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index af7c3e686..f083a190e 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -8,7 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; -import { runExactMatch } from './matching.js'; +import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { writeContractRegistry } from './storage.js'; @@ -208,13 +208,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const { matched, unmatched } = runExactMatch(autoContracts); + const providerIndex = buildProviderIndex(autoContracts); + const { matched, unmatched } = runExactMatch(autoContracts, providerIndex); + const wildcard = runWildcardMatch(unmatched, providerIndex); // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a // manifest-declared link can also emit a matchType:'exact' CrossLink with the // same endpoints. Prefer the manifest version — it reflects operator intent // and carries matchType:'manifest' which downstream consumers may rely on. - const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]); + const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]); const allContracts: StoredContract[] = autoContracts; const registry: ContractRegistry = { @@ -233,7 +235,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis return { contracts: allContracts, crossLinks, - unmatched, + unmatched: wildcard.remaining, missingRepos, repoSnapshots, }; diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index c5713d909..11b5b24e8 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -22,6 +22,16 @@ describe('normalizeContractId', () => { ); }); + it('lowercases thrift package and service while preserving method case', () => { + expect(normalizeContractId('thrift::Billing.V1.OrderService/PlaceOrder')).toBe( + 'thrift::billing.v1.orderservice/PlaceOrder', + ); + }); + + it('preserves case for malformed thrift id with leading slash', () => { + expect(normalizeContractId('thrift::/PlaceOrder')).toBe('thrift::/PlaceOrder'); + }); + it('preserves case for malformed gRPC id with leading slash (no full-string lowercasing)', () => { expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing'); }); @@ -219,6 +229,26 @@ function makeGrpcContract( }; } +function makeThriftContract( + id: string, + role: 'provider' | 'consumer', + repo: string, + overrides: Partial = {}, +): StoredContract { + return { + contractId: id, + type: 'thrift', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.9, + meta: {}, + repo, + ...overrides, + }; +} + // --------------------------------------------------------------------------- // buildProviderIndex // --------------------------------------------------------------------------- @@ -258,6 +288,18 @@ describe('runExactMatch — gRPC wildcard handling', () => { expect(unmatched).toHaveLength(2); }); + it('test_runExactMatch_skips_thrift_wildcard_contracts', () => { + const contracts: StoredContract[] = [ + makeThriftContract('thrift::billing.v1.OrderService/*', 'consumer', 'frontend'), + makeThriftContract('thrift::billing.v1.OrderService/*', 'provider', 'backend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + + expect(matched).toHaveLength(0); + expect(unmatched).toHaveLength(2); + }); + it('test_runExactMatch_does_not_skip_http_wildcards', () => { const contracts: StoredContract[] = [ { @@ -402,4 +444,56 @@ describe('runWildcardMatch', () => { expect(matched).toHaveLength(1); expect(matched[0].contractId).toBe('grpc::com.example.UserService/*'); }); + + it('matches thrift fully-qualified service wildcard to a thrift provider method', () => { + const consumer = makeThriftContract( + 'thrift::billing.v1.OrderService/*', + 'consumer', + 'frontend', + ); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].type).toBe('thrift'); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + expect(remaining).toHaveLength(0); + }); + + it('matches bare thrift service wildcard to a package-qualified thrift provider', () => { + const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend'); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('thrift::OrderService/*'); + }); + + it('does not match a thrift wildcard to a gRPC provider', () => { + const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(0); + expect(remaining).toEqual([consumer]); + }); }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 5aa586c25..c42e35c8c 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -263,6 +263,82 @@ describe('syncGroup', () => { expect(result.crossLinks).toHaveLength(1); }); + it('runs thrift wildcard matching after exact matching and returns wildcard remaining', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + expect(result.unmatched).toEqual([provider]); + }); + + it('dedupes duplicate wildcard cross-links during sync', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const duplicateProvider: StoredContract = { + ...provider, + confidence: 0.7, + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, duplicateProvider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + }); + it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => { const links: GroupManifestLink[] = [ {