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
+70 -2
View File
@@ -775,11 +775,77 @@ function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
return edges;
}
/**
* MyBatis: link a Java mapper interface method to the XML statement that holds
* its SQL. The XML extractor (`src/extraction/mybatis-extractor.ts`) qualifies
* each `<select|insert|update|delete|sql id="X">` as `<namespace>::<id>` where
* `<namespace>` is the Java FQN of the mapper interface. A Java method's
* qualifiedName ends with `<ClassName>::<methodName>`, so we suffix-match the
* last two segments of the XML qualified name to find a unique Java method by
* `<ClassName>::<methodName>` (`ClassName` = last dotted segment of the XML
* namespace). Cross-mapper `<include refid="other.X">` references go through
* the normal qualified-name resolver — only the Java↔XML bridge is synthetic.
*
* Precision over recall: ambiguous mappers (multiple Java classes with the
* same simple name) are dropped. We need-not bridge by package because Java
* mapper interfaces are typically uniquely named within a project.
*/
function mybatisJavaXmlEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
// Index Java methods by `<ClassName>::<methodName>` for O(1) lookup.
const javaIndex = new Map<string, Node[]>();
for (const m of queries.getNodesByKind('method')) {
if (m.language !== 'java' && m.language !== 'kotlin') continue;
const parts = m.qualifiedName.split('::');
const last = parts[parts.length - 1];
const cls = parts[parts.length - 2];
if (!last || !cls) continue;
const key = `${cls}::${last}`;
const arr = javaIndex.get(key);
if (arr) arr.push(m); else javaIndex.set(key, [m]);
}
for (const xml of queries.getNodesByKind('method')) {
if (xml.language !== 'xml') continue;
// Qualified name: `<namespace>::<id>`. Extract the simple class name.
const colonIdx = xml.qualifiedName.lastIndexOf('::');
if (colonIdx < 0) continue;
const namespace = xml.qualifiedName.slice(0, colonIdx);
const id = xml.qualifiedName.slice(colonIdx + 2);
if (!namespace || !id) continue;
const dotIdx = namespace.lastIndexOf('.');
const className = dotIdx >= 0 ? namespace.slice(dotIdx + 1) : namespace;
const candidates = javaIndex.get(`${className}::${id}`);
if (!candidates || candidates.length === 0) continue;
// Drop ambiguous matches (multiple same-name classes); the user can
// disambiguate by adding the package-suffix match in a future enhancement.
if (candidates.length > 1) continue;
const java = candidates[0]!;
const key = `${java.id}>${xml.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: java.id,
target: xml.id,
kind: 'calls',
line: java.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'mybatis-java-xml',
via: `${className}.${id}`,
registeredAt: `${xml.filePath}:${xml.startLine}`,
},
});
}
return edges;
}
/**
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
* React re-render + JSX children + Vue templates + RN event channel +
* Fabric native-impl). Returns the count added. Never throws into
* indexing — callers wrap in try/catch.
* Fabric native-impl + MyBatis Java↔XML). Returns the count added. Never
* throws into indexing — callers wrap in try/catch.
*/
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
const fieldEdges = fieldChannelEdges(queries, ctx);
@@ -792,6 +858,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const ifaceEdges = interfaceOverrideEdges(queries);
const rnEventEdgesList = rnEventEdges(ctx);
const fabricNativeEdges = fabricNativeImplEdges(ctx);
const mybatisEdges = mybatisJavaXmlEdges(queries);
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -806,6 +873,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...ifaceEdges,
...rnEventEdgesList,
...fabricNativeEdges,
...mybatisEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
+276 -1
View File
@@ -10,7 +10,15 @@ import { stripCommentsForRegex } from '../strip-comments';
export const springResolver: FrameworkResolver = {
name: 'spring',
languages: ['java', 'kotlin'],
languages: ['java', 'kotlin', 'yaml', 'properties'],
claimsReference(name: string): boolean {
// `@ConfigurationProperties(prefix="app.cache")` emits a reference whose
// name carries the `:prefix` sentinel — there's no declared symbol with
// that exact spelling, so the resolver's name-existence pre-filter would
// drop it. Opt those through.
return name.endsWith(':prefix');
},
detect(context: ResolutionContext): boolean {
// Check for pom.xml with Spring
@@ -50,6 +58,66 @@ export const springResolver: FrameworkResolver = {
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Spring config-key references — `@Value("${key}")` (single leaf) and
// `@ConfigurationProperties(prefix="X")` (entire subtree, marked with the
// `:prefix` suffix in extractSpringValueBindings). Lookup goes through
// Spring's relaxed binding (kebab/camel/snake → canonical lowercase).
if (ref.referenceName.endsWith(':prefix')) {
const prefix = ref.referenceName.slice(0, -':prefix'.length);
const canonPrefix = canonicalConfigKey(prefix);
// Prefer an exact prefix match (one node = the prefix subtree). Without
// node-level subtree representation we map to the closest matching key.
const candidates = context.getNodesByKind('constant').filter(
(n) => (n.language === 'yaml' || n.language === 'properties')
&& canonicalConfigKey(n.qualifiedName).startsWith(canonPrefix),
);
if (candidates.length === 0) return null;
// Pick the SHORTEST canonical name — it's the closest binding point
// (`app.cache` over `app.cache.name.user-token` for prefix=`app.cache`).
const best = candidates.reduce((a, b) =>
canonicalConfigKey(a.qualifiedName).length <= canonicalConfigKey(b.qualifiedName).length ? a : b,
);
return { original: ref, targetNodeId: best.id, confidence: 0.85, resolvedBy: 'framework' };
}
if (ref.referenceName.includes('.') && ref.language !== 'java' && ref.language !== 'kotlin') {
// Spring config dotted key — only when the source language is Java/Kotlin
// (the bindings come from `@Value`). Skip non-Spring refs that happen to
// have dots in them.
}
if (
(ref.language === 'java' || ref.language === 'kotlin') &&
ref.referenceName.includes('.') &&
!ref.referenceName.includes('::') &&
// Exclude method-call style (single-dot, both sides lower-camel). Spring
// config keys are typically 3+ segments and contain kebabs/dashes; we
// can't filter perfectly but skipping single-dot keeps the lookup tight.
ref.referenceName.split('.').length >= 2
) {
const canonRef = canonicalConfigKey(ref.referenceName);
const candidates = context.getNodesByKind('constant').filter(
(n) => n.kind === 'constant'
&& (n.language === 'yaml' || n.language === 'properties')
&& canonicalConfigKey(n.qualifiedName) === canonRef,
);
if (candidates.length === 1) {
return { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.9, resolvedBy: 'framework' };
}
if (candidates.length > 1) {
// Multiple profile-specific files (application-dev.yml +
// application-prod.yml) can define the same key. Prefer the one with
// the shortest profile suffix (the base `application.yml` wins over
// profile variants when both exist), then by alphabetical path so the
// pick is deterministic across reindexes.
const score = (n: Node) => {
const base = n.filePath.split('/').pop() ?? '';
const isBase = /^(application|bootstrap)\.(yml|yaml|properties)$/i.test(base);
return (isBase ? 0 : 1) * 1000 + base.length;
};
const best = candidates.reduce((a, b) => (score(a) <= score(b) ? a : b));
return { original: ref, targetNodeId: best.id, confidence: 0.75, resolvedBy: 'framework' };
}
}
// Pattern 1: Service references (dependency injection)
if (ref.referenceName.endsWith('Service')) {
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
@@ -119,6 +187,13 @@ export const springResolver: FrameworkResolver = {
},
extract(filePath, content) {
// Spring config files (application.yml / application.properties /
// bootstrap.yml + per-profile variants) are extracted on the framework
// path, not in the language extractor, so the keys become first-class
// nodes a `@Value("${k}")` reference can resolve to.
if (isSpringConfigFile(filePath)) {
return extractSpringConfig(filePath, content);
}
// Spring Boot is used from both Java and Kotlin (identical @GetMapping etc.
// annotations); the difference is method syntax — Kotlin `fun name(...)` vs
// Java `public X name(...)` — handled in the method regex below.
@@ -210,10 +285,210 @@ export const springResolver: FrameworkResolver = {
});
}
// @Value("${key}") and @ConfigurationProperties(prefix="...") — bind
// Spring config-key references in Java/Kotlin source. The reference target
// is the corresponding YAML/properties leaf-key node emitted by
// extractSpringConfig; springResolver.resolve looks it up with relaxed
// binding (kebab/camel/snake collapse).
extractSpringValueBindings(filePath, safe, lang, now, nodes, references);
return { nodes, references };
},
};
/** Spring config file patterns: application(-profile)?.{yml,yaml,properties} +
* bootstrap variants. Matches the basename, not the path, so a project that
* vendors `application.yml` under `src/main/resources` and one under `src/test/
* resources` are both picked up. */
function isSpringConfigFile(filePath: string): boolean {
const base = filePath.split('/').pop() ?? '';
return /^(application|bootstrap)(-[\w.-]+)?\.(yml|yaml|properties)$/i.test(base);
}
/**
* Parse a Spring config file (YAML or .properties) and emit one `constant`
* node per LEAF key, with `qualifiedName` = the dotted path. Leaf keys are
* what `@Value("${k}")` references hit; intermediate keys aren't bound by
* Spring's `@Value` (a `@ConfigurationProperties` class binds a SUBTREE, and
* those references are resolved at lookup time by prefix-suffix matching).
*/
function extractSpringConfig(
filePath: string,
content: string,
): { nodes: Node[]; references: UnresolvedRef[] } {
const nodes: Node[] = [];
const isProperties = /\.properties$/i.test(filePath);
const lang = isProperties ? 'properties' : 'yaml';
const now = Date.now();
const emitLeaf = (dottedKey: string, line: number, valueText: string) => {
if (!dottedKey) return;
nodes.push({
id: `spring-config:${filePath}:${line}:${dottedKey}`,
kind: 'constant',
name: dottedKey.split('.').pop() ?? dottedKey,
qualifiedName: dottedKey,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: valueText.length,
language: lang,
signature: dottedKey,
docstring: valueText.slice(0, 200),
updatedAt: now,
});
};
if (isProperties) {
// Properties format: `k1.k2.k3 = value` (or `:` separator, or no value).
// Lines starting with `#`/`!` are comments. Backslash continuations are
// valid but rare; we don't try to join them (a continued value is still
// a value of the same key).
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? '';
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
const sep = (() => {
for (let j = 0; j < raw.length; j++) {
const ch = raw[j];
if (ch === '=' || ch === ':') return j;
if (ch === '\\' && raw[j + 1]) { j++; continue; }
}
return -1;
})();
if (sep < 0) continue;
const key = raw.slice(0, sep).trim();
const val = raw.slice(sep + 1).trim();
emitLeaf(key, i + 1, val);
}
return { nodes, references: [] };
}
// YAML: indent-based. We track a stack of (indent, key) so the dotted path
// is built by joining ancestor keys with `.`. A leaf is a line with a value
// on the same line (after `:`). List items, flow-style scalars, and `---`
// separators are ignored — they don't bind to `@Value` anyway.
const stack: Array<{ indent: number; key: string }> = [];
const yamlLines = content.split(/\r?\n/);
for (let i = 0; i < yamlLines.length; i++) {
const raw = yamlLines[i] ?? '';
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed === '---' || trimmed.startsWith('- ')) continue;
const indent = raw.length - raw.replace(/^[\t ]+/, '').length;
const colonIdx = (() => {
let inStr: string | null = null;
for (let j = 0; j < raw.length; j++) {
const ch = raw[j];
if (inStr) { if (ch === inStr && raw[j - 1] !== '\\') inStr = null; continue; }
if (ch === '"' || ch === "'") { inStr = ch; continue; }
if (ch === ':') return j;
}
return -1;
})();
if (colonIdx < 0) continue;
const key = raw.slice(indent, colonIdx).trim();
if (!key) continue;
const after = raw.slice(colonIdx + 1).trim();
while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) stack.pop();
const dotted = [...stack.map((s) => s.key), key].join('.');
if (after === '' || after.startsWith('#')) {
stack.push({ indent, key });
} else {
// A leaf with an inline value (or a flow-mapping like `{ a: 1 }` — we
// emit it as a leaf, not as a subtree; precision is fine for `@Value`).
const valStripped = after.replace(/^["']|["']$/g, '');
emitLeaf(dotted, i + 1, valStripped);
}
}
return { nodes, references: [] };
}
/** Append `@Value("${k}")` and `@ConfigurationProperties(prefix=...)`
* references discovered in `safe` (comments stripped) into the caller's
* `nodes`/`references` arrays. */
function extractSpringValueBindings(
filePath: string,
safe: string,
lang: 'java' | 'kotlin',
now: number,
nodes: Node[],
references: UnresolvedRef[],
): void {
const valueRe = /@Value\s*\(\s*["']\$\{([^}:]+)(?::[^}]*)?\}["']\s*\)/g;
let m: RegExpExecArray | null;
while ((m = valueRe.exec(safe)) !== null) {
const key = m[1]!.trim();
if (!key) continue;
const line = safe.slice(0, m.index).split('\n').length;
const bindNode: Node = {
id: `spring-value:${filePath}:${line}:${key}`,
kind: 'constant',
name: key,
qualifiedName: `${filePath}::@Value:${key}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: m[0].length,
language: lang,
signature: `@Value("${key}")`,
updatedAt: now,
};
nodes.push(bindNode);
references.push({
fromNodeId: bindNode.id,
referenceName: key,
referenceKind: 'references',
line,
column: 0,
filePath,
language: lang,
});
}
const cpRe = /@ConfigurationProperties\s*\(\s*(?:prefix\s*=\s*)?["']([^"']+)["']/g;
while ((m = cpRe.exec(safe)) !== null) {
const prefix = m[1]!.trim();
if (!prefix) continue;
const line = safe.slice(0, m.index).split('\n').length;
const bindNode: Node = {
id: `spring-cp:${filePath}:${line}:${prefix}`,
kind: 'constant',
name: prefix,
qualifiedName: `${filePath}::@ConfigurationProperties:${prefix}`,
filePath,
startLine: line,
endLine: line,
startColumn: 0,
endColumn: m[0].length,
language: lang,
signature: `@ConfigurationProperties("${prefix}")`,
updatedAt: now,
};
nodes.push(bindNode);
references.push({
fromNodeId: bindNode.id,
// Mark the reference with a `:prefix` suffix so springResolver.resolve
// knows to expand it into the SUBTREE rather than a single key.
referenceName: `${prefix}:prefix`,
referenceKind: 'references',
line,
column: 0,
filePath,
language: lang,
});
}
}
/** Spring's relaxed binding (`cache-list` ↔ `cacheList` ↔ `cache_list` ↔
* `CACHE_LIST`) collapses on lowercase + dash/underscore removal. We compare
* candidate keys to a reference in this canonical form. */
function canonicalConfigKey(key: string): string {
return key.toLowerCase().replace(/[-_]/g, '');
}
// Directory patterns
const SERVICE_DIRS = ['/service/', '/services/'];
const REPO_DIRS = ['/repository/', '/repositories/'];
+82
View File
@@ -262,6 +262,66 @@ function inferCppReceiverType(
return null;
}
/**
* Java/Kotlin: infer a receiver's declared type by walking field declarations
* in the class enclosing the call site. The field's `signature` is already in
* the form "<TypeName> <fieldName>" (set by tree-sitter.ts extractField), so we
* pull the type from there. Handles Spring `@Resource UserBO userbo;` /
* `@Autowired private UserService userService;` where the receiver field name
* doesn't match the class name by Java naming convention.
*
* Returns the bare type name (generics stripped, dotted package stripped) or
* null when no matching field is in the enclosing class.
*/
function inferJavaFieldReceiverType(
receiverName: string,
ref: UnresolvedRef,
context: ResolutionContext,
): string | null {
const inFile = context.getNodesInFile(ref.filePath);
if (inFile.length === 0) return null;
// Find the class enclosing the call line (tightest match by latest start).
let enclosing: Node | null = null;
for (const n of inFile) {
if (n.kind !== 'class' && n.kind !== 'interface') continue;
if (n.language !== ref.language) continue;
const end = n.endLine ?? n.startLine;
if (n.startLine <= ref.line && end >= ref.line) {
if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
}
}
if (!enclosing) return null;
const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
const field = inFile.find(
(n) =>
n.kind === 'field' &&
n.name === receiverName &&
n.language === ref.language &&
n.startLine >= enclosing.startLine &&
(n.endLine ?? n.startLine) <= enclosingEnd,
);
if (!field || !field.signature) return null;
// Signature shape: "<TypeName> <fieldName>" (extractField). Pull the type,
// strip generics + dotted package, drop array/varargs markers.
const beforeName = field.signature.slice(
0,
field.signature.lastIndexOf(field.name),
);
const typeRaw = beforeName.trim();
if (!typeRaw) return null;
const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim();
const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim();
const parts = typeNoArray.split(/[.\s]+/).filter(Boolean);
const lastPart = parts[parts.length - 1];
if (!lastPart) return null;
if (!/^[A-Z]/.test(lastPart)) return null; // primitives / lowercase → skip
return lastPart;
}
/**
* Try to resolve by method name on a class/object
*/
@@ -297,6 +357,28 @@ export function matchMethodCall(
}
}
// Java/Kotlin: receiver may be a field whose name doesn't match the type by
// Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
// the field in the enclosing class to get its declared type, then resolve
// the method on that type. Covers Spring `@Resource`/`@Autowired` field
// injection where the field type is the concrete bean class.
if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) {
const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context);
if (inferredType) {
const typedMatch = resolveMethodOnType(
inferredType,
methodName!,
ref,
context,
0.9,
'instance-method',
);
if (typedMatch) {
return typedMatch;
}
}
}
// Strategy 1: Direct class name match (existing logic)
const classCandidates = context.getNodesByName(objectOrClass!);