Selaa lähdekoodia

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>
Colby Mchenry 3 viikkoa sitten
vanhempi
sitoutus
2543ae565a

+ 6 - 0
CHANGELOG.md

@@ -9,6 +9,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### Added
+- **Enterprise Spring / MyBatis flow now traces end-to-end (#389).** Three gaps that previously forced agents back to grep on large Spring/MyBatis codebases are closed:
+  - **MyBatis XML mapper indexing + Java↔XML bridge.** `*.xml` files containing `<mapper namespace="...">` are now first-class: each `<select|insert|update|delete id="X">` and `<sql id="X">` becomes a method-shaped node qualified as `<namespace>::<id>`, and a new synthesizer (`mybatis-java-xml`) links the matching Java mapper interface method → its XML statement with a `calls` edge. `<include refid="...">` to a `<sql>` fragment in the same mapper also resolves. Non-mapper XML (`pom.xml`, `web.xml`, `log4j.xml`, etc.) emits just a file node — no symbol noise. Validated on macrozheng/mall-tiny: all 6 custom-SQL Java mapper methods reach their XML counterparts; `trace(UmsRoleController.listResource, UmsResourceMapper::getResourceListByRoleId-xml)` connects in 4 hops across controller → service-iface → impl → mapper-iface → XML.
+  - **Spring `@Value`/`@ConfigurationProperties` config-key linkage.** `application.{yml,yaml,properties}` (+ profile variants `application-dev.yml`, `bootstrap.yml`, etc.) is parsed during indexing, with one `constant` node per leaf key qualified by its dotted path (`app.cache.name.user-token`). `@Value("${app.cache.name.user-token}")` and `@ConfigurationProperties(prefix = "app.cache")` references in Java/Kotlin emit binding nodes that resolve to the matching key (or, for `@ConfigurationProperties`, a key under the prefix). Spring's **relaxed binding** applies (kebab `cache-list` ↔ camel `cacheList` ↔ snake `cache_list` ↔ `CACHE_LIST`), so a Java `@Value("${app.retryCount}")` finds `app.retry-count` in `application.properties`. `${key:default}` form is supported; the default is stripped before lookup.
+  - **Field-injected concrete-bean trace.** A Spring controller's `@Resource(name="userBO") private UserBO userbo;` followed by `this.userbo.toLogin2(...)` now resolves through to `UserBO.toLogin2` even when the field type is a concrete class whose name doesn't match the field by Java naming convention (`userbo` → `UserBO`). The fix is two layered changes in the language layer (Java only): (a) the call extractor unwraps `this.<field>` receivers (previously surfaced as `this.userbo.toLogin2` and dropped through every name-matcher strategy); (b) the resolver looks up the receiver name in the enclosing class's field declarations and uses the declared type to resolve the method. This generalizes beyond Spring — any Java code using `this.field.method()` now resolves correctly.
+
 ### Fixed
 - **`codegraph_files` now returns the whole project when an agent passes `path="/"`, `"."`, `"./"`, `""`, or a Windows-style `"\\"` — instead of "No files found matching the criteria."** Indexed file paths are stored as project-relative POSIX (e.g. `src/foo.ts`), but the path filter used a plain `startsWith`, so a leading slash or any of the other root-ish shapes an agent might guess matched nothing and pushed the agent back to Read/Glob — the exact opencode + Gemini Flash regression reported on Windows 11. Subdirectory filters are now equally forgiving: `"/src"`, `"./src"`, `"src/"`, `"src\\components"`, etc. all resolve correctly. Sibling-prefix bleed (`"src"` was previously matching `src-utils/...`) is also fixed — the filter now requires either an exact match or a `<filter>/` boundary. Closes #426.
 - **File watcher no longer marks edited files as fresh when another process holds the index lock.** When a second writer (concurrent `codegraph index`, a git hook, another MCP daemon) held `.codegraph/codegraph.lock`, `CodeGraph.sync()` returned a zero-shape no-op instead of throwing. The file watcher took that as a successful sync and cleared `pendingFiles` — so the per-file staleness signal MCP tools surface to agents (issue #403) dropped immediately, even though the edit was never indexed. `CodeGraph.watch()` now converts that no-op into a typed `LockUnavailableError` thrown into the watcher; the existing retry path preserves `pendingFiles` and reschedules until the lock becomes available. The error is logged at debug only (no `onSyncError` callback) so a long-running external indexer doesn't spam stderr every debounce cycle. Closes #449.

+ 304 - 0
__tests__/frameworks-integration.test.ts

@@ -302,3 +302,307 @@ describe('C++ end-to-end — virtual override synthesis', () => {
     cg.close();
   });
 });
+
+describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  // Mirrors the issue's Spring MVC pattern:
+  //   UserAction(@Resource UserBO userbo).toLogin2() -> this.userbo.toLogin2()
+  //     -> UserBO.toLogin2() -> userService.toLogin() -> UserService.toLogin (iface)
+  //     -> UserServiceImpl.toLogin() via interface→impl synthesis.
+  // Without the extractor `this.` strip + field-typed receiver lookup, the very
+  // first hop (controller -> bean) was missing entirely, breaking trace.
+  it('connects controller -> @Resource bean -> interface -> impl end-to-end', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-bean-'));
+    const javaDir = path.join(tmpDir, 'src/main/java/com/example/user');
+    fs.mkdirSync(path.join(javaDir, 'action'), { recursive: true });
+    fs.mkdirSync(path.join(javaDir, 'bo'), { recursive: true });
+    fs.mkdirSync(path.join(javaDir, 'service'), { recursive: true });
+    fs.mkdirSync(path.join(javaDir, 'service/impl'), { recursive: true });
+    fs.writeFileSync(
+      path.join(tmpDir, 'pom.xml'),
+      '<project><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency></dependencies></project>\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'action/UserAction.java'),
+      'package com.example.user.action;\n' +
+        'import com.example.user.bo.UserBO;\n' +
+        'import javax.annotation.Resource;\n' +
+        '@org.springframework.stereotype.Controller\n' +
+        'public class UserAction {\n' +
+        '  @Resource(name = "userBO") private UserBO userbo;\n' +
+        '  public void toLogin2() { this.userbo.toLogin2(); }\n' +
+        '}\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'bo/UserBO.java'),
+      'package com.example.user.bo;\n' +
+        'import com.example.user.service.UserService;\n' +
+        'import javax.annotation.Resource;\n' +
+        '@org.springframework.stereotype.Component("userBO")\n' +
+        'public class UserBO {\n' +
+        '  @Resource private UserService userService;\n' +
+        '  public void toLogin2() { userService.toLogin(); }\n' +
+        '}\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'service/UserService.java'),
+      'package com.example.user.service;\n' +
+        'public interface UserService { void toLogin(); }\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'service/impl/UserServiceImpl.java'),
+      'package com.example.user.service.impl;\n' +
+        'import com.example.user.service.UserService;\n' +
+        '@org.springframework.stereotype.Service("userService")\n' +
+        'public class UserServiceImpl implements UserService {\n' +
+        '  public void toLogin() { }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const find = (cls: string, name: string) =>
+      methods.find((m) => m.name === name && m.filePath.endsWith(`${cls}.java`));
+
+    const action = find('UserAction', 'toLogin2');
+    const bo = find('UserBO', 'toLogin2');
+    const svc = find('UserService', 'toLogin');
+    const impl = find('UserServiceImpl', 'toLogin');
+    expect(action).toBeDefined();
+    expect(bo).toBeDefined();
+    expect(svc).toBeDefined();
+    expect(impl).toBeDefined();
+
+    // UserAction.toLogin2 -> UserBO.toLogin2 (the regressed hop — `this.userbo`
+    // receiver was emitted verbatim and the field-type lookup didn't exist).
+    const actionToBo = cg.getOutgoingEdges(action!.id).find((e) => e.target === bo!.id);
+    expect(actionToBo, 'controller `this.userbo.toLogin2()` should reach UserBO.toLogin2').toBeDefined();
+    expect(actionToBo!.kind).toBe('calls');
+
+    // UserBO.toLogin2 -> UserService.toLogin (plain identifier receiver, works pre-fix).
+    const boToSvc = cg.getOutgoingEdges(bo!.id).find((e) => e.target === svc!.id);
+    expect(boToSvc).toBeDefined();
+
+    // UserService.toLogin -> UserServiceImpl.toLogin (interface->impl synth).
+    const svcToImpl = cg.getOutgoingEdges(svc!.id).find((e) => e.target === impl!.id);
+    expect(svcToImpl).toBeDefined();
+
+    cg.close();
+  });
+
+  it('bridges a Java mapper interface method to its MyBatis XML statement (incl. SQL fragments)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-mybatis-'));
+    const javaDir = path.join(tmpDir, 'src/main/java/com/example/dao');
+    const xmlDir = path.join(tmpDir, 'src/main/resources/mappers');
+    fs.mkdirSync(javaDir, { recursive: true });
+    fs.mkdirSync(xmlDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(tmpDir, 'pom.xml'),
+      '<project><dependencies><dependency><groupId>org.mybatis</groupId><artifactId>mybatis</artifactId></dependency></dependencies></project>\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'UserDAOMapper.java'),
+      'package com.example.dao;\n' +
+        'public interface UserDAOMapper {\n' +
+        '  Object getById(int id);\n' +
+        '  int updateUser(Object u);\n' +
+        '}\n'
+    );
+    fs.writeFileSync(
+      path.join(xmlDir, 'UserDAOMapper.xml'),
+      '<?xml version="1.0" encoding="UTF-8"?>\n' +
+        '<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">\n' +
+        '<mapper namespace="com.example.dao.UserDAOMapper">\n' +
+        '  <sql id="userCols">id, name, email</sql>\n' +
+        '  <select id="getById" parameterType="int" resultType="User">\n' +
+        '    SELECT <include refid="userCols"/> FROM users WHERE id = #{id}\n' +
+        '  </select>\n' +
+        '  <update id="updateUser" parameterType="User">\n' +
+        '    UPDATE users SET name=#{name}, email=#{email} WHERE id=#{id}\n' +
+        '  </update>\n' +
+        '</mapper>\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const getByIdJava = methods.find((m) => m.name === 'getById' && m.language === 'java');
+    const getByIdXml = methods.find((m) => m.name === 'getById' && m.language === 'xml');
+    const updateJava = methods.find((m) => m.name === 'updateUser' && m.language === 'java');
+    const updateXml = methods.find((m) => m.name === 'updateUser' && m.language === 'xml');
+    const sqlFrag = methods.find((m) => m.name === 'userCols' && m.language === 'xml');
+    expect(getByIdJava).toBeDefined();
+    expect(getByIdXml).toBeDefined();
+    expect(updateJava).toBeDefined();
+    expect(updateXml).toBeDefined();
+    expect(sqlFrag).toBeDefined();
+
+    // XML statement qualified name must be `<namespace>::<id>` so the
+    // synthesizer can match against the Java method's `<Class>::<method>`
+    // suffix — this is the load-bearing contract between extractor + synthesis.
+    expect(getByIdXml!.qualifiedName).toBe('com.example.dao.UserDAOMapper::getById');
+
+    // Bridge: Java mapper method -> XML statement, kind 'calls'.
+    const j2xGet = cg.getOutgoingEdges(getByIdJava!.id).find((e) => e.target === getByIdXml!.id);
+    expect(j2xGet, 'Java getById should reach the XML <select id="getById">').toBeDefined();
+    expect(j2xGet!.kind).toBe('calls');
+    const j2xUpd = cg.getOutgoingEdges(updateJava!.id).find((e) => e.target === updateXml!.id);
+    expect(j2xUpd, 'Java updateUser should reach the XML <update id="updateUser">').toBeDefined();
+
+    // <include refid="userCols"/> inside <select> -> <sql id="userCols"> in same mapper.
+    const incEdge = cg.getOutgoingEdges(getByIdXml!.id).find((e) => e.target === sqlFrag!.id);
+    expect(incEdge, '<include refid="userCols"/> should reach the <sql> fragment').toBeDefined();
+
+    cg.close();
+  });
+
+  it('binds @Value / @ConfigurationProperties to YAML + .properties keys (incl. relaxed binding)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-spring-config-'));
+    const javaDir = path.join(tmpDir, 'src/main/java/com/example');
+    const resDir = path.join(tmpDir, 'src/main/resources');
+    fs.mkdirSync(javaDir, { recursive: true });
+    fs.mkdirSync(resDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(tmpDir, 'pom.xml'),
+      '<project><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency></dependencies></project>\n'
+    );
+    fs.writeFileSync(
+      path.join(resDir, 'application.yml'),
+      'app:\n' +
+        '  cache:\n' +
+        '    name:\n' +
+        '      user-token: "example-service:auth:token"\n' +
+        '    enabled: true\n' +
+        'db:\n' +
+        '  url: "jdbc:mysql://localhost/x"\n'
+    );
+    fs.writeFileSync(
+      path.join(resDir, 'application.properties'),
+      'app.retry-count=3\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'CacheConfig.java'),
+      'package com.example;\n' +
+        'import org.springframework.beans.factory.annotation.Value;\n' +
+        'public class CacheConfig {\n' +
+        '  @Value("${app.cache.name.user-token}") private String tokenCacheName;\n' +
+        '  @Value("${app.cache.enabled:true}") private boolean enabled;\n' +
+        '  // relaxed binding: java camelCase, properties kebab-case\n' +
+        '  @Value("${app.retryCount}") private int retry;\n' +
+        '}\n'
+    );
+    fs.writeFileSync(
+      path.join(javaDir, 'CacheProperties.java'),
+      'package com.example;\n' +
+        'import org.springframework.boot.context.properties.ConfigurationProperties;\n' +
+        '@ConfigurationProperties(prefix = "app.cache")\n' +
+        'public class CacheProperties { private boolean enabled; }\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    // YAML/properties leaf keys: one constant node per dotted path.
+    const cfgKeys = cg
+      .getNodesByKind('constant')
+      .filter((n) => n.language === 'yaml' || n.language === 'properties');
+    const cfgByQn = (qn: string) => cfgKeys.find((n) => n.qualifiedName === qn);
+    expect(cfgByQn('app.cache.name.user-token')).toBeDefined();
+    expect(cfgByQn('app.cache.enabled')).toBeDefined();
+    expect(cfgByQn('db.url')).toBeDefined();
+    expect(cfgByQn('app.retry-count')).toBeDefined();
+
+    // @Value("${app.cache.name.user-token}") -> the YAML leaf key.
+    const valueBindings = cg
+      .getNodesByKind('constant')
+      .filter((n) => n.id.startsWith('spring-value:'));
+    const userToken = valueBindings.find((n) => n.name === 'app.cache.name.user-token');
+    expect(userToken).toBeDefined();
+    const userTokenEdges = cg.getOutgoingEdges(userToken!.id);
+    const userTokenTarget = userTokenEdges.find((e) =>
+      cfgKeys.some((c) => c.id === e.target && c.qualifiedName === 'app.cache.name.user-token'),
+    );
+    expect(userTokenTarget, '@Value should reference the YAML leaf key').toBeDefined();
+
+    // Default-value form `${k:default}` — strip the `:default` and bind the key.
+    const enabledBind = valueBindings.find((n) => n.name === 'app.cache.enabled');
+    expect(enabledBind).toBeDefined();
+    expect(cg.getOutgoingEdges(enabledBind!.id).some((e) => {
+      const t = cfgByQn('app.cache.enabled');
+      return t && e.target === t.id;
+    })).toBe(true);
+
+    // Relaxed binding: `app.retryCount` (camel) -> `app.retry-count` (kebab).
+    const retryBind = valueBindings.find((n) => n.name === 'app.retryCount');
+    expect(retryBind).toBeDefined();
+    expect(cg.getOutgoingEdges(retryBind!.id).some((e) => {
+      const t = cfgByQn('app.retry-count');
+      return t && e.target === t.id;
+    })).toBe(true);
+
+    // @ConfigurationProperties(prefix="app.cache") -> a key under that prefix.
+    const cpBindings = cg
+      .getNodesByKind('constant')
+      .filter((n) => n.id.startsWith('spring-cp:'));
+    const cpAppCache = cpBindings.find((n) => n.name === 'app.cache');
+    expect(cpAppCache).toBeDefined();
+    const cpEdges = cg.getOutgoingEdges(cpAppCache!.id);
+    expect(cpEdges.length).toBeGreaterThan(0);
+
+    cg.close();
+  });
+
+  it('emits only a file node for non-MyBatis XML (pom.xml, beans.xml, log4j.xml)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-xml-non-mybatis-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'pom.xml'),
+      '<project><groupId>x</groupId><artifactId>y</artifactId></project>\n'
+    );
+    fs.writeFileSync(
+      path.join(tmpDir, 'log4j.xml'),
+      '<?xml version="1.0"?><Configuration><Loggers><Root level="info"/></Loggers></Configuration>\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+    // No method nodes — non-mapper XML produces no symbols (just file rows).
+    expect(cg.getNodesByKind('method').filter((n) => n.language === 'xml').length).toBe(0);
+    cg.close();
+  });
+
+  it('resolves a `this.field.method()` call to a unique implementation class', async () => {
+    // Standalone test of the extractor `this.` strip: even without Spring annotations,
+    // `this.svc.run()` where `svc` is typed as a concrete class should route to that
+    // class's method. This is the general Java fix, Spring is only one consumer.
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-this-field-'));
+    fs.writeFileSync(
+      path.join(tmpDir, 'App.java'),
+      'class Svc { public void run() { } }\n' +
+        'class App {\n' +
+        '  private Svc svc;\n' +
+        '  public void go() { this.svc.run(); }\n' +
+        '}\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const methods = cg.getNodesByKind('method');
+    const go = methods.find((m) => m.name === 'go');
+    const run = methods.find((m) => m.name === 'run');
+    expect(go && run).toBeTruthy();
+
+    const edge = cg.getOutgoingEdges(go!.id).find((e) => e.target === run!.id);
+    expect(edge, '`this.svc.run()` should resolve to Svc.run').toBeDefined();
+
+    cg.close();
+  });
+});

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
docs/design/dynamic-dispatch-coverage-playbook.md


+ 13 - 1
src/extraction/grammars.ts

@@ -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 - 0
src/extraction/mybatis-extractor.ts

@@ -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
src/extraction/tree-sitter.ts

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

+ 70 - 2
src/resolution/callback-synthesizer.ts

@@ -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
src/resolution/frameworks/java.ts

@@ -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 - 0
src/resolution/name-matcher.ts

@@ -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!);
 

+ 2 - 0
src/types.ts

@@ -90,6 +90,8 @@ export const LANGUAGES = [
   'objc',
   'yaml',
   'twig',
+  'xml',
+  'properties',
   'unknown',
 ] as const;
 

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä