feat(group): add thrift contract type

This commit is contained in:
liyipeng06 2026-04-27 20:38:08 +08:00
parent c1e890d2a9
commit dff02c19e9
6 changed files with 104 additions and 7 deletions

View file

@ -4,12 +4,13 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom'];
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'thrift', 'topic', 'lib', 'custom'];
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
const DEFAULT_DETECT = {
http: true,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,

View file

@ -177,7 +177,7 @@ export class ManifestExtractor {
// NOTE: All lookups use EXACT equality on the relevant name field and
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
// for fuzzy matching (plus an unconditional IDL file fallback for gRPC)
// which produced silent false positives: e.g. manifest "/orders" would
// match "/suborders", and a gRPC manifest entry in a repo with any
// .proto file would attach to a random proto symbol.
@ -225,16 +225,16 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'grpc') {
} else if (link.type === 'grpc' || link.type === 'thrift') {
// Contract is "Service/Method" or just "Service" (or package.Service
// variants). Prefer matching by method name when present, otherwise
// by service name. NO .proto path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one proto file.
// by service name. NO IDL path fallback — that's guaranteed to
// return a wrong symbol in any repo with more than one IDL file.
// Label filters scope lookups: methods → Function|Method, services
// → Class|Interface (no label match = no silent wrong hits on
// File/Variable nodes that happen to share the name).
const parts = link.contract.split('/');
const serviceName = parts[0]?.trim() ?? '';
const serviceName = parts[0]?.trim().split('.').pop() ?? '';
const methodName = parts[1]?.trim() ?? '';
if (methodName) {
rows = await executor(
@ -329,6 +329,8 @@ export class ManifestExtractor {
}
case 'grpc':
return `grpc::${contract}`;
case 'thrift':
return `thrift::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':

View file

@ -1,4 +1,4 @@
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
@ -24,6 +24,7 @@ export interface GroupManifestLink {
export interface DetectConfig {
http: boolean;
grpc: boolean;
thrift: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;

View file

@ -62,6 +62,36 @@ repos:
expect(config.matching.bm25_threshold).toBe(0.7);
});
it('defaults thrift detection to true', () => {
const minimal = `
version: 1
name: test
repos:
app: my-app
`;
const config = parseGroupConfig(minimal);
expect(config.detect.thrift).toBe(true);
});
it('parses thrift manifest links', () => {
const yaml = `
version: 1
name: test
repos:
gateway: gateway-repo
orders: orders-repo
links:
- from: gateway
to: orders
type: thrift
contract: billing.v1.OrderService/PlaceOrder
role: consumer
`;
const config = parseGroupConfig(yaml);
expect(config.links[0].type).toBe('thrift');
expect(config.links[0].contract).toBe('billing.v1.OrderService/PlaceOrder');
});
it('throws on missing required fields', () => {
expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i);
expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i);

View file

@ -578,6 +578,33 @@ describe('ManifestExtractor', () => {
expect(lowerContractId).toBe(upperContractId);
});
it('builds thrift manifest contracts with synthetic uids when unresolved', async () => {
const extractor = new ManifestExtractor();
const result = await extractor.extractFromManifest([
{
from: 'gateway',
to: 'orders',
type: 'thrift',
contract: 'billing.v1.OrderService/PlaceOrder',
role: 'consumer',
},
]);
expect(result.contracts).toHaveLength(2);
expect(result.contracts.map((c) => c.contractId)).toEqual([
'thrift::billing.v1.OrderService/PlaceOrder',
'thrift::billing.v1.OrderService/PlaceOrder',
]);
expect(result.crossLinks).toHaveLength(1);
expect(result.crossLinks[0].type).toBe('thrift');
expect(result.crossLinks[0].from.symbolUid).toBe(
'manifest::gateway::thrift::billing.v1.OrderService/PlaceOrder',
);
expect(result.crossLinks[0].to.symbolUid).toBe(
'manifest::orders::thrift::billing.v1.OrderService/PlaceOrder',
);
});
it('returns empty for no links', async () => {
const result = await extractor.extractFromManifest([]);
expect(result.contracts).toHaveLength(0);

View file

@ -21,6 +21,7 @@ describe('Group types', () => {
detect: {
http: true,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
@ -63,6 +64,41 @@ describe('Group types', () => {
});
});
it('ExtractedContract accepts thrift contract type', () => {
const contract: ExtractedContract = {
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
type: 'thrift',
role: 'provider',
symbolUid: 'uid-thrift',
symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' },
symbolName: 'OrderService.PlaceOrder',
confidence: 0.9,
meta: {},
};
expect(contract.type).toBe('thrift');
});
it('DetectConfig includes thrift toggle', () => {
const config: GroupConfig = {
version: 1,
name: 'company',
description: 'All company microservices',
repos: { orders: 'orders-repo' },
links: [],
packages: {},
detect: {
http: true,
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
};
expect(config.detect.thrift).toBe(true);
});
it('CrossLink stores match metadata', () => {
const link: CrossLink = {
from: {