feat(group): detect java thrift usage

This commit is contained in:
liyipeng06 2026-04-28 09:53:36 +08:00
parent 544a852dd9
commit d9a1ac3f41
5 changed files with 498 additions and 2 deletions

View file

@ -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<string, unknown>,
): 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<string, ExtractedContract>();
for (const c of items) {

View file

@ -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<string, ThriftLanguagePlugin> = {
'.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];
}

View file

@ -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<Record<string, never>>);
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<string, string>();
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<string>();
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;
},
};

View file

@ -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[];
}

View file

@ -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', () => {