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
+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' &&