mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
feat(group): match thrift service wildcards
This commit is contained in:
parent
e362370d28
commit
b9ceb08cc1
4 changed files with 206 additions and 25 deletions
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue