From d9a1ac3f415ee5a73a33176879e6049b0129358a Mon Sep 17 00:00:00 2001 From: liyipeng06 Date: Tue, 28 Apr 2026 09:53:36 +0800 Subject: [PATCH] feat(group): detect java thrift usage --- .../core/group/extractors/thrift-extractor.ts | 84 +++++++- .../group/extractors/thrift-patterns/index.ts | 16 ++ .../group/extractors/thrift-patterns/java.ts | 203 ++++++++++++++++++ .../group/extractors/thrift-patterns/types.ts | 19 ++ .../test/unit/group/thrift-extractor.test.ts | 178 +++++++++++++++ 5 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/types.ts diff --git a/gitnexus/src/core/group/extractors/thrift-extractor.ts b/gitnexus/src/core/group/extractors/thrift-extractor.ts index 1c4483866..e853fafec 100644 --- a/gitnexus/src/core/group/extractors/thrift-extractor.ts +++ b/gitnexus/src/core/group/extractors/thrift-extractor.ts @@ -1,7 +1,13 @@ import { glob } from 'glob'; +import Parser from 'tree-sitter'; import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js'; import type { ExtractedContract, RepoHandle } from '../types.js'; import { readSafe } from './fs-utils.js'; +import { + getPluginForFile, + THRIFT_SCAN_GLOB, + type ThriftDetection, +} from './thrift-patterns/index.js'; export interface ThriftServiceInfo { namespace: string; @@ -174,18 +180,20 @@ function extractMethods(sanitizedServiceBody: string): string[] { function makeContract( cid: string, + role: 'provider' | 'consumer', filePath: string, symbolName: string, + confidence: number, meta: Record, ): ExtractedContract { return { contractId: cid, type: 'thrift', - role: 'provider', + role, symbolUid: '', symbolRef: { filePath: normalizeThriftPath(filePath), name: symbolName }, symbolName, - confidence: 0.85, + confidence, meta: { ...meta, extractionStrategy: 'source_scan' }, }; } @@ -248,8 +256,10 @@ export class ThriftExtractor implements ContractExtractor { out.push( makeContract( thriftMethodContractId(info.namespace, info.serviceName, methodName), + 'provider', info.thriftPath, symbolName, + 0.85, { namespace: info.namespace, service: info.serviceName, @@ -262,9 +272,79 @@ export class ThriftExtractor implements ContractExtractor { } } + const sourceFiles = await glob(THRIFT_SCAN_GLOB, { + cwd: repoPath, + absolute: false, + nodir: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + }); + + const parser = new Parser(); + for (const rel of sourceFiles) { + const plugin = getPluginForFile(rel); + if (!plugin) continue; + const content = readSafe(repoPath, rel); + if (!content) continue; + + let detections: ThriftDetection[] = []; + try { + parser.setLanguage(plugin.language); + const tree = parser.parse(content); + detections = plugin.scan(tree); + } catch { + continue; + } + + for (const detection of detections) { + const contract = this.detectionToContract(detection, rel, context); + if (contract) out.push(contract); + } + } + return this.dedupe(out); } + private detectionToContract( + detection: ThriftDetection, + filePath: string, + context: ThriftContext, + ): ExtractedContract | null { + const candidates = context.servicesByName.get(detection.serviceName) ?? []; + if (candidates.length > 1) return null; + + const info = candidates[0]; + if (info) { + if (!info.methods.includes(detection.methodName)) return null; + return makeContract( + thriftMethodContractId(info.namespace, info.serviceName, detection.methodName), + detection.role, + filePath, + detection.symbolName, + detection.confidenceWithIdl, + { + namespace: info.namespace, + service: info.serviceName, + method: detection.methodName, + source: detection.source, + }, + ); + } + + if (detection.role !== 'consumer' || !detection.methodName) return null; + return makeContract( + thriftMethodContractId('', detection.serviceName, detection.methodName), + detection.role, + filePath, + detection.symbolName, + detection.confidenceWithoutIdl, + { + service: detection.serviceName, + method: detection.methodName, + source: 'java_thrift_consumer_weak', + }, + ); + } + private dedupe(items: ExtractedContract[]): ExtractedContract[] { const byKey = new Map(); for (const c of items) { diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/index.ts b/gitnexus/src/core/group/extractors/thrift-patterns/index.ts new file mode 100644 index 000000000..2df229bfa --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/index.ts @@ -0,0 +1,16 @@ +import * as path from 'node:path'; +import type { ThriftLanguagePlugin } from './types.js'; +import { JAVA_THRIFT_PLUGIN } from './java.js'; + +export type { ThriftDetection, ThriftLanguagePlugin, ThriftRole } from './types.js'; + +const REGISTRY: Record = { + '.java': JAVA_THRIFT_PLUGIN, +}; + +export const THRIFT_SCAN_GLOB = '**/*.java'; + +export function getPluginForFile(rel: string): ThriftLanguagePlugin | undefined { + const ext = path.extname(rel).toLowerCase(); + return REGISTRY[ext]; +} diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/java.ts b/gitnexus/src/core/group/extractors/thrift-patterns/java.ts new file mode 100644 index 000000000..14078ffe6 --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/java.ts @@ -0,0 +1,203 @@ +import Parser from 'tree-sitter'; +import Java from 'tree-sitter-java'; +import { + compilePatterns, + runCompiledPatterns, + type LanguagePatterns, +} from '../tree-sitter-scanner.js'; +import type { ThriftDetection, ThriftLanguagePlugin } from './types.js'; + +const GENERATED_MEMBER_TYPES = new Set(['Iface', 'Client']); +const SERVICE_TYPE_RE = /^[A-Z][A-Za-z0-9]*(?:Service|Management)$/; + +const VARIABLE_PATTERNS = compilePatterns({ + name: 'java-thrift-variables', + language: Java, + patterns: [ + { + meta: { scoped: true }, + query: ` + (field_declaration + (scoped_type_identifier + (type_identifier) @service + (type_identifier) @member) + (variable_declarator + (identifier) @var)) + `, + }, + { + meta: { scoped: true }, + query: ` + (local_variable_declaration + (scoped_type_identifier + (type_identifier) @service + (type_identifier) @member) + (variable_declarator + (identifier) @var)) + `, + }, + { + meta: { scoped: true }, + query: ` + (formal_parameter + (scoped_type_identifier + (type_identifier) @service + (type_identifier) @member) + (identifier) @var) + `, + }, + { + meta: { scoped: false }, + query: ` + (field_declaration + (type_identifier) @service + (variable_declarator + (identifier) @var)) + `, + }, + { + meta: { scoped: false }, + query: ` + (local_variable_declaration + (type_identifier) @service + (variable_declarator + (identifier) @var)) + `, + }, + { + meta: { scoped: false }, + query: ` + (formal_parameter + (type_identifier) @service + (identifier) @var) + `, + }, + ], +} satisfies LanguagePatterns<{ scoped: boolean }>); + +const CALL_PATTERNS = compilePatterns({ + name: 'java-thrift-method-calls', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (identifier) @receiver + name: (identifier) @method) + `, + }, + ], +} satisfies LanguagePatterns>); + +const PROVIDER_PATTERNS = compilePatterns({ + name: 'java-thrift-providers', + language: Java, + patterns: [ + { + meta: { scoped: true }, + query: ` + (class_declaration + name: (identifier) @class_name + (super_interfaces + (type_list + (scoped_type_identifier + (type_identifier) @service + (type_identifier) @member))) + body: (class_body) @body) @class + `, + }, + { + meta: { scoped: false }, + query: ` + (class_declaration + name: (identifier) @class_name + (super_interfaces + (type_list + (type_identifier) @service)) + body: (class_body) @body) @class + `, + }, + ], +} satisfies LanguagePatterns<{ scoped: boolean }>); + +function serviceFromType(serviceText: string, memberText: string | undefined): string | null { + if (memberText !== undefined) { + return GENERATED_MEMBER_TYPES.has(memberText) ? serviceText : null; + } + return SERVICE_TYPE_RE.test(serviceText) ? serviceText : null; +} + +function methodNamesInClassBody(body: Parser.SyntaxNode): string[] { + const names: string[] = []; + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (!child || child.type !== 'method_declaration') continue; + const name = child.childForFieldName('name'); + if (name?.text) names.push(name.text); + } + return names; +} + +export const JAVA_THRIFT_PLUGIN: ThriftLanguagePlugin = { + name: 'java-thrift', + language: Java, + scan(tree) { + const out: ThriftDetection[] = []; + const variables = new Map(); + + for (const match of runCompiledPatterns(VARIABLE_PATTERNS, tree)) { + const serviceNode = match.captures.service; + const varNode = match.captures.var; + if (!serviceNode || !varNode) continue; + const memberNode = match.meta.scoped ? match.captures.member : undefined; + const serviceName = serviceFromType(serviceNode.text, memberNode?.text); + if (!serviceName) continue; + variables.set(varNode.text, serviceName); + } + + for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) { + const receiver = match.captures.receiver?.text; + const methodName = match.captures.method?.text; + if (!receiver || !methodName) continue; + const serviceName = variables.get(receiver); + if (!serviceName) continue; + out.push({ + role: 'consumer', + serviceName, + methodName, + symbolName: `${receiver}.${methodName}`, + source: 'java_thrift_consumer', + confidenceWithIdl: 0.75, + confidenceWithoutIdl: 0.45, + }); + } + + const emittedProviders = new Set(); + for (const match of runCompiledPatterns(PROVIDER_PATTERNS, tree)) { + const serviceNode = match.captures.service; + const bodyNode = match.captures.body; + if (!serviceNode || !bodyNode) continue; + const memberNode = match.meta.scoped ? match.captures.member : undefined; + const serviceName = serviceFromType(serviceNode.text, memberNode?.text); + if (!serviceName) continue; + + for (const methodName of methodNamesInClassBody(bodyNode)) { + const key = `${serviceName}.${methodName}`; + if (emittedProviders.has(key)) continue; + emittedProviders.add(key); + out.push({ + role: 'provider', + serviceName, + methodName, + symbolName: `${serviceName}.${methodName}`, + source: 'java_thrift_provider', + confidenceWithIdl: 0.8, + confidenceWithoutIdl: 0, + }); + } + } + + return out; + }, +}; diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/types.ts b/gitnexus/src/core/group/extractors/thrift-patterns/types.ts new file mode 100644 index 000000000..0976ce86d --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/types.ts @@ -0,0 +1,19 @@ +import type Parser from 'tree-sitter'; + +export type ThriftRole = 'provider' | 'consumer'; + +export interface ThriftDetection { + role: ThriftRole; + serviceName: string; + methodName: string; + symbolName: string; + source: string; + confidenceWithIdl: number; + confidenceWithoutIdl: number; +} + +export interface ThriftLanguagePlugin { + name: string; + language: unknown; + scan(tree: Parser.Tree): ThriftDetection[]; +} diff --git a/gitnexus/test/unit/group/thrift-extractor.test.ts b/gitnexus/test/unit/group/thrift-extractor.test.ts index aa1a0d53f..aba45c892 100644 --- a/gitnexus/test/unit/group/thrift-extractor.test.ts +++ b/gitnexus/test/unit/group/thrift-extractor.test.ts @@ -184,6 +184,184 @@ service OrderService { expect(contracts).toEqual([]); }); + + it('test_extract_java_thrift_consumers_from_iface_client_and_service_fields', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private OrderService.Iface orderService; + private OrderService.Client orderClient; + private OrderService generatedOrderService; + + void submit(PlaceOrderRequest request) throws Exception { + orderService.PlaceOrder(request); + orderClient.PlaceOrder(request); + generatedOrderService.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts + .filter((c) => c.role === 'consumer') + .sort((a, b) => a.symbolName.localeCompare(b.symbolName)); + + expect(consumers).toHaveLength(3); + expect(consumers.map((c) => c.symbolName)).toEqual([ + 'generatedOrderService.PlaceOrder', + 'orderClient.PlaceOrder', + 'orderService.PlaceOrder', + ]); + for (const contract of consumers) { + expect(contract).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + confidence: 0.75, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_consumer', + }, + }); + expect(contract.symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java'); + } + }); + + it('test_extract_java_thrift_consumers_from_local_variables', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorker.java', + `package example; + +class BillingWorker { + void submit(OrderService.Iface iface, OrderService.Client client, OrderService service) throws Exception { + OrderService.Iface orderService = iface; + OrderService.Client orderClient = client; + OrderService generatedOrderService = service; + + orderService.PlaceOrder(new PlaceOrderRequest()); + orderClient.PlaceOrder(new PlaceOrderRequest()); + generatedOrderService.PlaceOrder(new PlaceOrderRequest()); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.map((c) => c.symbolName).sort()).toEqual([ + 'generatedOrderService.PlaceOrder', + 'orderClient.PlaceOrder', + 'orderService.PlaceOrder', + ]); + expect(new Set(consumers.map((c) => c.contractId))).toEqual( + new Set(['thrift::billing.v1.OrderService/PlaceOrder']), + ); + }); + + it('test_extract_java_thrift_providers_from_iface_and_service_implements', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/IfaceOrderHandler.java', + `package example; + +class IfaceOrderHandler implements OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + writeFile( + 'src/main/java/example/GeneratedOrderHandler.java', + `package example; + +class GeneratedOrderHandler implements OrderService { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts + .filter((c) => c.meta.source === 'java_thrift_provider') + .sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath)); + + expect(providers).toHaveLength(2); + for (const contract of providers) { + expect(contract).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolName: 'OrderService.PlaceOrder', + confidence: 0.8, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_provider', + }, + }); + } + }); + + it('test_extract_java_thrift_consumer_without_idl_emits_weak_method_contract', async () => { + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private OrderService.Iface orderService; + + void submit(PlaceOrderRequest request) throws Exception { + orderService.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toHaveLength(1); + expect(contracts[0]).toMatchObject({ + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: { + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_consumer_weak', + }, + }); + expect(contracts[0].symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java'); + }); }); describe('buildThriftContext', () => {