feat(java): trace Spring/MyBatis enterprise flow end-to-end (#389) (#468)

Closes three gaps that broke `trace(controller, mapper-xml)` on real Spring +
MyBatis projects:

1. **Field-injected concrete-bean trace.** Java `this.<field>.method()` is
   unwrapped at extraction (was surfaced as `this.<field>.method` and dropped
   through every name-matcher strategy). The receiver name is then looked up
   in the enclosing class's field declarations to get the declared type and
   resolve the method on it. Closes the controller→bean hop when the field
   name doesn't capitalize to the type (`userbo` → `UserBO`). General Java
   fix, not Spring-specific.

2. **MyBatis XML mapper as a first-class language.** New extractor parses
   `<mapper namespace="..."><select|insert|update|delete|sql id="X">` and
   emits method-shaped nodes qualified as `<namespace>::<id>`, plus
   `<include refid="X"/>` references to `<sql>` fragments. Non-mapper XML
   (pom, log4j, web.xml) → file node only. A new synthesizer
   (`mybatisJavaXmlEdges`) joins Java mapper methods to XML statements by
   suffix-matching qualified names. Ambiguous simple-name collisions dropped
   for precision.

3. **Spring `@Value`/`@ConfigurationProperties` → application config.**
   `application.{yml,yaml,properties}` + profile variants parse on the
   framework path; each leaf key becomes a `constant` node qualified by its
   dotted path. `@Value("${k}")` / `@Value("${k:default}")` and
   `@ConfigurationProperties(prefix="X")` emit binding nodes that resolve
   with Spring's relaxed binding (kebab↔camel↔snake).

Validated on macrozheng/mall-tiny: full chain
`UmsRoleController.listResource → UmsRoleService.listResource → impl →
UmsResourceMapper.getResourceListByRoleId → XML <select>` connects across 5
hops via static + synthesized edges. 11/11 @Value annotations resolved
(incl. `@ConfigurationProperties(prefix="secure.ignored")`); 6/6 custom-SQL
mapper methods bridge to XML.

Tests: 4 new integration tests in frameworks-integration.test.ts. Full
suite: 1005 passed.

Docs: CHANGELOG `[Unreleased]` entry + dynamic-dispatch-coverage-playbook
narrative + matrix row.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-26 16:34:30 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 55839edd8f
commit 2543ae565a
10 changed files with 1013 additions and 10 deletions
+13 -1
View File
@@ -10,7 +10,7 @@ import * as path from 'path';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'unknown'>;
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
/**
* WASM filename map — maps each language to its .wasm grammar file
@@ -95,6 +95,13 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.luau': 'luau',
'.m': 'objc',
'.mm': 'objc',
// XML: file-level tracking; the MyBatis extractor matches `<mapper namespace="...">`
// shape and emits SQL-statement nodes (other XML returns empty).
'.xml': 'xml',
// Spring config: `application.properties` / `application-*.properties`. Same
// shape as the `.yml` variants — the YAML/properties extractor emits one node
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
'.properties': 'properties',
};
/**
@@ -267,6 +274,8 @@ export function isLanguageSupported(language: Language): boolean {
if (language === 'liquid') return true; // custom regex extractor
if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver
if (language === 'twig') return true; // file-level tracking only
if (language === 'xml') return true; // MyBatis mapper extractor
if (language === 'properties') return true; // Spring config keys
if (language === 'unknown') return false;
return language in WASM_GRAMMAR_FILES;
}
@@ -277,6 +286,7 @@ export function isLanguageSupported(language: Language): boolean {
export function isGrammarLoaded(language: Language): boolean {
if (language === 'svelte' || language === 'vue' || language === 'liquid') return true;
if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed
if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed
return languageCache.has(language);
}
@@ -357,6 +367,8 @@ export function getLanguageDisplayName(language: Language): string {
objc: 'Objective-C',
yaml: 'YAML',
twig: 'Twig',
xml: 'XML',
properties: 'Java properties',
unknown: 'Unknown',
};
return names[language] || language;
+198
View File
@@ -0,0 +1,198 @@
import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types';
import { generateNodeId } from './tree-sitter-helpers';
/**
* MyBatisExtractor — parses MyBatis mapper XML files.
*
* MyBatis splits a DAO interface across two files: a Java interface (parsed by
* tree-sitter) declares the method, and an XML mapper file holds the SQL keyed
* by `<namespace>` (the fully-qualified Java type name) and `id` (the method
* name). Without the XML side in the graph, `trace(Controller, ...DAO.method)`
* dead-ends at the interface method — the SQL it actually runs is invisible,
* and "what does this query touch" / "where is this column written" can't be
* answered.
*
* This extractor emits one method-shaped node per `<select|insert|update|
* delete>` and per `<sql>` fragment, qualified as `<namespace>::<id>` so the
* MyBatis framework synthesizer (`src/resolution/frameworks/mybatis.ts`) can
* link the matching Java method → XML statement by suffix-matching qualified
* names. `<include refid="...">` inside a statement yields an unresolved
* reference to the SQL fragment, also keyed by `<namespace>::<refid>`.
*
* Non-mapper XML (Maven `pom.xml`, Spring beans XML, `web.xml`, log4j config,
* etc.) is detected by the absence of a `<mapper namespace="...">` root and
* returns just a file node — we still need the file row so the watcher can
* track it, but we emit no symbols.
*/
export class MyBatisExtractor {
private filePath: string;
private source: string;
private nodes: Node[] = [];
private edges: Edge[] = [];
private unresolvedReferences: UnresolvedReference[] = [];
private errors: ExtractionError[] = [];
private lineStarts: number[] = [];
constructor(filePath: string, source: string) {
this.filePath = filePath;
this.source = source;
this.computeLineStarts();
}
extract(): ExtractionResult {
const startTime = Date.now();
const fileNode = this.createFileNode();
try {
const mapperMatch = this.findMapperRoot();
if (mapperMatch) {
this.extractMapper(fileNode.id, mapperMatch.namespace, mapperMatch.bodyStart, mapperMatch.bodyEnd);
}
} catch (error) {
this.errors.push({
message: `MyBatis extraction error: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
code: 'parse_error',
});
}
return {
nodes: this.nodes,
edges: this.edges,
unresolvedReferences: this.unresolvedReferences,
errors: this.errors,
durationMs: Date.now() - startTime,
};
}
private createFileNode(): Node {
const lines = this.source.split('\n');
const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
const node: Node = {
id,
kind: 'file',
name: this.filePath.split('/').pop() || this.filePath,
qualifiedName: this.filePath,
filePath: this.filePath,
language: 'xml',
startLine: 1,
endLine: lines.length || 1,
startColumn: 0,
endColumn: lines[lines.length - 1]?.length ?? 0,
updatedAt: Date.now(),
};
this.nodes.push(node);
return node;
}
/**
* Find the `<mapper namespace="X">` opening tag. Returns the namespace and
* the byte offsets of the body (between the opening and closing tag) so
* statement extraction can be scoped to mapper contents.
*/
private findMapperRoot(): { namespace: string; bodyStart: number; bodyEnd: number } | null {
const open = /<mapper\b([^>]*)>/.exec(this.source);
if (!open) return null;
const attrs = open[1] ?? '';
const nsMatch = /\bnamespace\s*=\s*"([^"]+)"/.exec(attrs);
if (!nsMatch) return null;
const bodyStart = open.index + open[0].length;
const closeIdx = this.source.indexOf('</mapper>', bodyStart);
const bodyEnd = closeIdx >= 0 ? closeIdx : this.source.length;
return { namespace: nsMatch[1]!, bodyStart, bodyEnd };
}
private extractMapper(fileNodeId: string, namespace: string, bodyStart: number, bodyEnd: number): void {
const body = this.source.slice(bodyStart, bodyEnd);
// Match each top-level statement-shaped element. The body may have nested
// tags (`<if>`, `<foreach>`, `<include>`), so we scan with a regex that
// pairs an opening tag to its matching close — the simple form below works
// because MyBatis statement elements are not themselves nested.
const stmtRegex = /<(select|insert|update|delete|sql)\b([^>]*)>([\s\S]*?)<\/\1>/g;
let m: RegExpExecArray | null;
while ((m = stmtRegex.exec(body)) !== null) {
const elemType = m[1]!;
const attrs = m[2] ?? '';
const elemBody = m[3] ?? '';
const idMatch = /\bid\s*=\s*"([^"]+)"/.exec(attrs);
if (!idMatch) continue;
const id = idMatch[1]!;
const absoluteIndex = bodyStart + m.index;
const startLine = this.getLineNumber(absoluteIndex);
const endLine = this.getLineNumber(absoluteIndex + m[0].length);
const qualified = `${namespace}::${id}`;
const isSqlFragment = elemType === 'sql';
const nodeId = generateNodeId(this.filePath, 'method', qualified, startLine);
const node: Node = {
id: nodeId,
kind: 'method',
name: id,
qualifiedName: qualified,
filePath: this.filePath,
language: 'xml',
signature: this.buildSignature(elemType, attrs, isSqlFragment),
startLine,
endLine,
startColumn: 0,
endColumn: 0,
docstring: this.previewSql(elemBody),
updatedAt: Date.now(),
};
this.nodes.push(node);
this.edges.push({ source: fileNodeId, target: nodeId, kind: 'contains' });
// <include refid="X"/> → reference to the SQL fragment in this mapper
// (or in another mapper, when the refid is qualified — `ns.X`).
const includeRegex = /<include\b[^>]*\brefid\s*=\s*"([^"]+)"/g;
let inc: RegExpExecArray | null;
while ((inc = includeRegex.exec(elemBody)) !== null) {
const refid = inc[1]!;
const refQualified = refid.includes('.') ? refid.replace(/\./g, '::') : `${namespace}::${refid}`;
const includeOffset = absoluteIndex + (m[0].length - m[3]!.length - `</${elemType}>`.length) + inc.index;
const line = this.getLineNumber(includeOffset);
this.unresolvedReferences.push({
fromNodeId: nodeId,
referenceName: refQualified,
referenceKind: 'references',
line,
column: 0,
});
}
}
}
private buildSignature(elemType: string, attrs: string, isSqlFragment: boolean): string {
if (isSqlFragment) return '<sql>';
const verb = elemType.toUpperCase();
const result = /\bresultType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
const param = /\bparameterType\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
const parts = [verb];
if (param) parts.push(`param=${param}`);
if (result) parts.push(`result=${result}`);
return parts.join(' ');
}
private previewSql(body: string): string {
return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
}
private computeLineStarts(): void {
this.lineStarts = [0];
for (let i = 0; i < this.source.length; i++) {
if (this.source.charCodeAt(i) === 10) this.lineStarts.push(i + 1);
}
}
private getLineNumber(offset: number): number {
// Binary search
let lo = 0;
let hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if (this.lineStarts[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
}
+28 -5
View File
@@ -23,6 +23,7 @@ import { LiquidExtractor } from './liquid-extractor';
import { SvelteExtractor } from './svelte-extractor';
import { DfmExtractor } from './dfm-extractor';
import { VueExtractor } from './vue-extractor';
import { MyBatisExtractor } from './mybatis-extractor';
import {
getAllFrameworkResolvers,
getApplicableFrameworks,
@@ -1453,7 +1454,23 @@ export class TreeSitterExtractor {
if (nameField && objectField && (node.type === 'method_invocation' || node.type === 'member_call_expression' || node.type === 'scoped_call_expression')) {
// Method call with explicit receiver: receiver.method() / $receiver->method() / ClassName::method()
const methodName = getNodeText(nameField, this.source);
let receiverName = getNodeText(objectField, this.source);
// Java `this.userbo.toLogin2()` parses as method_invocation(object=field_access(this, userbo)).
// Without unwrapping, receiverName is `this.userbo` and the name-matcher's
// single-dot receiver regex fails. Pull out the immediate field after `this.`
// so the receiver is the field name (`userbo`), which the resolver can then
// look up in the enclosing class's field declarations.
let receiverName: string;
if (objectField.type === 'field_access') {
const inner = getChildByField(objectField, 'object');
const fld = getChildByField(objectField, 'field');
if (inner && fld && (inner.type === 'this' || inner.type === 'this_expression')) {
receiverName = getNodeText(fld, this.source);
} else {
receiverName = getNodeText(objectField, this.source);
}
} else {
receiverName = getNodeText(objectField, this.source);
}
// Strip PHP $ prefix from variable names
receiverName = receiverName.replace(/^\$/, '');
@@ -2687,10 +2704,16 @@ export function extractFromSource(
// Use custom extractor for Liquid
const extractor = new LiquidExtractor(filePath, source);
result = extractor.extract();
} else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig') {
// No symbol extraction — file is tracked at the file-record level only.
// Framework extractors (e.g. Drupal routing resolver) run below and may
// add route nodes / references for yaml files such as *.routing.yml.
} else if (detectedLanguage === 'xml') {
// Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a
// file node so the watcher tracks it without emitting symbols.
const extractor = new MyBatisExtractor(filePath, source);
result = extractor.extract();
} else if (detectedLanguage === 'yaml' || detectedLanguage === 'twig' || detectedLanguage === 'properties') {
// No symbol extraction at this stage — files are tracked at the file-record
// level only. Framework extractors (Drupal routing yml, Spring `@Value`
// resolution against application.yml/application.properties) run later and
// add per-file nodes/references when they apply.
result = { nodes: [], edges: [], unresolvedReferences: [], errors: [], durationMs: 0 };
} else if (
detectedLanguage === 'pascal' &&