diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index bd803981c..7ee3e2dcf 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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, diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 83f5cab5e..869537fd2 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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': diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 793d3d0ad..ce18fd8d5 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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; diff --git a/gitnexus/test/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts index 50878ce7d..7af6094f9 100644 --- a/gitnexus/test/unit/group/config-parser.test.ts +++ b/gitnexus/test/unit/group/config-parser.test.ts @@ -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); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index 59725dc86..fb74eeeb6 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -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); diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts index df952e598..025ed4bbb 100644 --- a/gitnexus/test/unit/group/types.test.ts +++ b/gitnexus/test/unit/group/types.test.ts @@ -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: {