mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
feat(group): detect java thrift usage
This commit is contained in:
parent
544a852dd9
commit
d9a1ac3f41
5 changed files with 498 additions and 2 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
16
gitnexus/src/core/group/extractors/thrift-patterns/index.ts
Normal file
16
gitnexus/src/core/group/extractors/thrift-patterns/index.ts
Normal 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];
|
||||
}
|
||||
203
gitnexus/src/core/group/extractors/thrift-patterns/java.ts
Normal file
203
gitnexus/src/core/group/extractors/thrift-patterns/java.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
19
gitnexus/src/core/group/extractors/thrift-patterns/types.ts
Normal file
19
gitnexus/src/core/group/extractors/thrift-patterns/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue