This commit is contained in:
mieshi-laoren 2026-09-25 21:55:23 +03:00 • committed by GitHub
commit 41feb26a00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 184 additions and 2 deletions

View file

@ -37,6 +37,7 @@ const DEFAULT_DETECT = {
topics: true,
includes: false,
workspace_deps: false,
sofa: false,
};
const DEFAULT_MATCHING = {

View file

@ -0,0 +1,136 @@
import { glob } from 'glob';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
/**
* SOFA framework extractor for RPC contract discovery from XML files.
*
* SOFA RPC (Alibaba SOFA framework) uses XML declarations:
* - `<sofa:service interface="com.xxx.YyyService">` + `<sofa:binding.tr/>` → provider
* - `<sofa:reference interface="com.xxx.YyyService">` + `<sofa:binding.tr/>` → consumer
*
* SOFAMQ topic extraction is handled by the Java tree-sitter topic extractor
* (topic-patterns/java.ts), which detects OpenMessaging API calls:
* - `consumer.subscribe("topic", "tag", listener)` → topic consumer
* - `new Message("topic", "tag", body)` → topic provider
*
* XML `sofa:consumer` + `sofa:channel` + `sofa:binding.msg_broker` declarations
* are NOT extracted because they represent distributed scheduled task dispatching
* (e.g. `TP_F_SC` channel), not real message pub/sub relationships.
*
* Because SOFA XML uses a distinctive namespace prefix, regex extraction is
* reliable and avoids the need for a full XML tree-sitter grammar.
*/
const SOFA_XML_GLOB = '**/*.xml';
// ─── Regex patterns for SOFA XML elements ───────────────────────────
/**
* Match <sofa:service interface="...">…</sofa:service> blocks.
* Captures the interface attribute and the full block content.
* Note: self-closing <sofa:service …/> is not matched; SOFA XML always
* uses the closing tag because it contains binding children.
*/
const SOFA_SERVICE_RE = /<sofa:service\s+[^>]*interface="([^"]+)"[^>]*>([\s\S]*?)<\/sofa:service>/g;
/**
* Match <sofa:reference interface="…" …> (may be self-closing or have body).
* Captures the interface attribute.
*/
const SOFA_REFERENCE_RE = /<sofa:reference\s+[^>]*interface="([^"]+)"[^>]*\/?>/g;
/**
* Detect TR (bolt) binding inside a sofa:service block.
*/
const BINDING_TR_RE = /sofa:binding\.tr/;
// ─── Helper: build an ExtractedContract ──────────────────────────────
function makeRpcContract(
interfaceName: string,
role: 'provider' | 'consumer',
filePath: string,
): ExtractedContract {
return {
contractId: `custom::${interfaceName}`,
type: 'custom',
role,
symbolUid: '',
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: interfaceName },
symbolName: interfaceName,
confidence: 0.85,
meta: {
framework: 'sofa-rpc',
interface: interfaceName,
extractionStrategy: 'regex_xml',
},
};
}
// ─── Extractor class ─────────────────────────────────────────────────
export class SofaExtractor implements ContractExtractor {
type = 'custom' as const;
async canExtract(_repo: RepoHandle): Promise<boolean> {
return true;
}
async extract(
_dbExecutor: CypherExecutor | null,
repoPath: string,
_repo: RepoHandle,
): Promise<ExtractedContract[]> {
const baseFilter = await createIgnoreFilter(repoPath);
const files = await glob(SOFA_XML_GLOB, {
cwd: repoPath,
ignore: baseFilter,
nodir: true,
});
const out: ExtractedContract[] = [];
for (const rel of files) {
const content = readSafe(repoPath, rel);
if (!content) continue;
// Skip files that don't contain any SOFA namespace declarations
if (!content.includes('sofa:')) continue;
// ── SOFA RPC: sofa:service (provider) ──
let m: RegExpExecArray | null;
SOFA_SERVICE_RE.lastIndex = 0;
while ((m = SOFA_SERVICE_RE.exec(content)) !== null) {
const interfaceName = m[1];
const body = m[2];
if (BINDING_TR_RE.test(body)) {
out.push(makeRpcContract(interfaceName, 'provider', rel));
}
}
// ── SOFA RPC: sofa:reference (consumer) ──
SOFA_REFERENCE_RE.lastIndex = 0;
while ((m = SOFA_REFERENCE_RE.exec(content)) !== null) {
const interfaceName = m[1];
out.push(makeRpcContract(interfaceName, 'consumer', rel));
}
}
return this.dedupe(out);
}
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
const seen = new Set<string>();
const out: ExtractedContract[] = [];
for (const c of items) {
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(c);
}
return out;
}
}

View file

@ -5,11 +5,14 @@ import type { TopicMeta } from './types.js';
/**
* Java topic extraction patterns.
*
* Detects Kafka and RabbitMQ (Spring conventions) producer/consumer APIs:
* Detects Kafka, RabbitMQ (Spring conventions) and SOFAMQ (OpenMessaging API)
* producer/consumer APIs:
* - `@KafkaListener(topics = "xxx")`
* - `@RabbitListener(queues = "xxx")`
* - `kafkaTemplate.send("xxx", ...)`
* - `rabbitTemplate.convertAndSend("xxx", ...)`
* - `consumer.subscribe("topic", "tag", listener)` (SOFAMQ consumer)
* - `new Message("topic", "tag", body)` (SOFAMQ producer)
*
* Every query MUST bind `@value` to the topic literal node.
*/
@ -77,6 +80,33 @@ const JAVA_TOPIC_SPEC: LanguagePatterns<TopicMeta> = {
arguments: (argument_list . (string_literal) @value))
`,
},
{
meta: {
role: 'consumer',
broker: 'sofamq',
confidence: 0.7,
symbolName: 'openMessaging.Consumer.subscribe',
},
query: `
(method_invocation
object: (identifier) @obj (#eq? @obj "consumer")
name: (identifier) @method (#eq? @method "subscribe")
arguments: (argument_list . (string_literal) @value))
`,
},
{
meta: {
role: 'provider',
broker: 'sofamq',
confidence: 0.7,
symbolName: 'openMessaging.Message.constructor',
},
query: `
(object_creation_expression
type: (type_identifier) @cls (#eq? @cls "Message")
arguments: (argument_list . (string_literal) @value))
`,
},
],
};

View file

@ -8,7 +8,7 @@
* query text directly — that's the whole point of the split.
*/
export type Broker = 'kafka' | 'rabbitmq' | 'nats';
export type Broker = 'kafka' | 'rabbitmq' | 'nats' | 'sofamq';
/**
* Per-pattern payload every topic plugin attaches to its query. Whatever

View file

@ -36,6 +36,7 @@ import { GraphqlExtractor } from './extractors/graphql-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { ThriftExtractor } from './extractors/thrift-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { SofaExtractor } from './extractors/sofa-extractor.js';
import { IncludeExtractor } from './extractors/include-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
@ -349,6 +350,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
const grpcEx = new GrpcExtractor();
const thriftEx = new ThriftExtractor();
const topicEx = new TopicExtractor();
const sofaEx = new SofaExtractor();
const includeEx = new IncludeExtractor();
for (const [groupPath, regName] of Object.entries(config.repos)) {
@ -452,6 +454,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
if (config.detect.sofa) {
const extracted = await sofaEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {
autoContracts.push({
...c,
repo: groupPath,
service: assignService(c.symbolRef.filePath, boundaries),
});
}
}
if (config.detect.includes) {
const extracted = await includeEx.extract(executor, handle.repoPath, handle);
for (const c of extracted) {

View file

@ -40,6 +40,8 @@ export interface DetectConfig {
topics: boolean;
includes: boolean;
workspace_deps: boolean;
/** SOFA framework (SOFA RPC + SOFAMQ) XML contract extraction. Opt-in. */
sofa: boolean;
}
export interface MatchingConfig {