feat(group): match thrift service wildcards

This commit is contained in:
liyipeng06 2026-04-27 20:53:44 +08:00
parent e362370d28
commit b9ceb08cc1
4 changed files with 206 additions and 25 deletions

View file

@ -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::<lowercased-package-or-service>[/<method>]`.
case 'grpc':
case 'thrift': {
// Canonical form: `<type>::<lowercased-package-or-service>[/<method>]`.
//
// 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<string>();
@ -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<string, StoredContract[]>,
): 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<string>();
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 "<type>::" 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 "<type>::"
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}`);
});

View file

@ -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,
};

View file

@ -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> = {},
): 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]);
});
});

View file

@ -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[] = [
{