1
0
Эх сурвалжийг харах

fix(mybatis): quote/comment robustness, iBatis <sqlMap> coverage, dup-id collision (#1182) (#1204)

Four gaps in the MyBatis mapper extractor, all reported and reproduced by
@ESPINS in #1182 and verified against main:

1. Single-quoted attribute values (namespace/id/refid/resultType/parameterType)
   were dropped — the regexes hardcoded double quotes. Now accept either quote
   via a backreference.
2. Tags inside <!-- ... --> produced phantom statement/include symbols. A
   length-preserving, CDATA-aware pre-pass blanks comments before scanning,
   keeping offsets/line numbers intact.
3. Legacy iBatis 2 <sqlMap> files had zero statement coverage (the root finder
   gated on a <mapper namespace> root). It now also recognizes <sqlMap>
   (namespaced and namespace-less DAO.method ids) and iBatis's extra
   <statement>/<procedure> verbs — closing the gap with no new dependency
   (option (c) from the issue; the batis-xml parser route is declined).
4. Two statements sharing a qualifiedName AND a start line (a vendor-split
   databaseId pair on one line) collided on the node id, so INSERT OR REPLACE
   silently dropped one. The id-hash now folds in the statement's byte offset;
   the stored qualifiedName/startLine are unchanged so the Java<->XML bridge is
   untouched.

Gaps 1 and 2 follow @ESPINS's fix-mybatis-quotes-comments branch. Tests add
extractor-level coverage for all four gaps plus a DB-level e2e that proves
iBatis statements land and both vendor-split nodes survive a real indexAll.

Co-authored-by: Jimin Lee <dlwlalsggg@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 2 сар өмнө
parent
commit
f5edf8cf49

+ 2 - 0
CHANGELOG.md

@@ -31,9 +31,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - The Claude Code context hook now recognizes prompts that describe code in plain words — in any language — by checking the prompt's words against the symbol names actually in your project's index. Asking about "the state machine des commandes" finds `OrderStateMachine` with no keyword involved. Confidence decides how much gets injected: structural questions and prompts naming a real symbol still get full context up front; a plain-words match gets a short pointer to the matching symbols so the agent queries them itself; everything else stays silent, exactly as before.
 - Anonymous usage telemetry now counts how often the context hook injected context, offered a hint, or stayed silent — fixed counter names only; the prompt's content is never stored or sent. This makes the hook's accuracy measurable instead of guessed. The counters record what actually happened, not what was attempted: a lookup that errors or comes back empty counts as a distinct silent outcome, never as delivered context (#1143, thanks @inth3shadows).
 - Metal shader files (`.metal`) are now indexed. Metal Shading Language is close enough to C++ that vertex/fragment/kernel functions, structs, type aliases, and the calls between them all land in the graph — so shader pipelines in Apple-platform projects show up in impact analysis and flow traces instead of being silently skipped. Metal's `[[buffer(0)]]`-style attribute annotations are handled so they can't corrupt what gets extracted. Thanks @FluxKo for the report. (#1121)
+- CodeGraph now indexes legacy **iBatis 2** SQL maps (`<sqlMap>`), not just MyBatis 3 `<mapper>` files. `<select>`/`<insert>`/`<update>`/`<delete>`, iBatis's `<statement>`/`<procedure>`, and `<sql>` fragments inside a `<sqlMap>` become searchable statement symbols — for both namespaced maps and the namespace-less `Map.statement` id style — and `<include>` references resolve to the fragment they pull in, so search, callers, and impact queries return results on iBatis codebases that previously produced no statement symbols at all. Thanks @ESPINS for the report and the reproduction. (#1182)
 
 ### Fixes
 
+- The MyBatis mapper reader is sturdier on real-world XML. Single-quoted attribute values (`id='getById'`, legal XML and common in older mappers) are no longer skipped, so those statements make it into the graph. Statements and `<include>`s that were commented out with `<!-- ... -->` no longer produce phantom symbols. And two vendor-split statements — the same `id` with `databaseId="oracle"` / `databaseId="mysql"` — written on a single line no longer silently drop one of the pair. Thanks @ESPINS for the report, the reproductions, and the fixes. (#1182)
 - `codegraph init` and `codegraph index` no longer get killed by the safety watchdog at the "Resolving refs" step on large method-name-heavy codebases (big Java/enterprise monorepos were the main victims, especially on slower machines). Resolution used to come up for air only every 500 references, so a dense stretch of expensive ones could starve the watchdog long enough for it to assume the process was stuck and kill a perfectly healthy index. Resolution now checkpoints after every reference, and two of the expensive steps got much cheaper: repeated method lookups on the same type are now cached, and source files are no longer re-split line-by-line for every call being resolved — indexing such repos is several times faster as a result. Generated or minified single-line files are also skipped during receiver-type inference instead of being scanned per call. Thanks @UchihaYong and @wangmeng-95 for the reports. (#1122)
 - An index left incomplete by an interrupted run now heals itself on the next sync instead of silently staying wrong forever. If indexing died partway through resolving references (a crash, Ctrl-C, or the watchdog kill fixed above), the affected files still looked indexed but their caller/impact edges were missing — a too-small blast radius clustering by package or module, e.g. a Spring `@Resource`-injected method reporting 3 of its 10 real caller files — and because incremental syncs only re-resolve files that changed, the damage was permanent until a full re-index. Any sync (a watched file change, or a bare `codegraph sync`) now detects the leftover references and finishes resolving them, `codegraph status` warns when an index is in that state instead of passing it off as healthy, and a rare early-stop that could abandon resolution on repos whose first files reference only external libraries is fixed too. Thanks @KnifeOfLife for the report and the package-correlation observation that pinned it down. (#1187)
 - The shared background server no longer shuts down out from under a live editor/agent session that simply hasn't queried CodeGraph in a while. A safety timer meant to reap an *abandoned* server — one whose client vanished without the connection ever closing — was reaping **any** server that saw no requests for 30 minutes, including a perfectly live session that just wasn't asking CodeGraph anything; that silently dropped the session (and every other session sharing the same background server) to a slower in-process mode for the rest of its life. On one machine over a day it fired 20 times on live sessions and caught zero real phantoms. The timer now checks whether the connected clients are actually still alive and only reaps when none of them are, so a quiet-but-live session keeps its shared server while a genuinely abandoned one is still cleaned up. (#1200)

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

@@ -464,6 +464,70 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
     cg.close();
   });
 
+  it('covers legacy iBatis <sqlMap> statements and keeps same-line vendor-split pairs (#1182)', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ibatis-'));
+    const xmlDir = path.join(tmpDir, 'src/main/resources/sqlmaps');
+    fs.mkdirSync(xmlDir, { recursive: true });
+
+    // iBatis 2 sqlMap with an explicit namespace.
+    fs.writeFileSync(
+      path.join(xmlDir, 'Account.xml'),
+      '<?xml version="1.0" encoding="UTF-8"?>\n' +
+        '<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-2.dtd">\n' +
+        "<sqlMap namespace='Account'>\n" +
+        "  <sql id='cols'>id, name, email</sql>\n" +
+        "  <select id='getById' resultClass='Account'>SELECT <include refid='cols'/> FROM account WHERE id = #id#</select>\n" +
+        "  <insert id='insert' parameterClass='Account'>INSERT INTO account (id) VALUES (#id#)</insert>\n" +
+        '  <!-- <select id="disabled">SELECT 0</select> -->\n' +
+        '</sqlMap>\n'
+    );
+    // Namespace-less sqlMap whose ids carry the qualifier as `Map.statement`.
+    fs.writeFileSync(
+      path.join(xmlDir, 'LegacyDao.xml'),
+      '<sqlMap>\n' +
+        '  <select id="LegacyDao.findAll" resultClass="Row">SELECT * FROM t</select>\n' +
+        '</sqlMap>\n'
+    );
+    // MyBatis mapper with a vendor-split databaseId pair written on ONE line —
+    // same qualifiedName + same start line. Before the id-hash fold both nodes
+    // hashed identically and INSERT OR REPLACE dropped one.
+    fs.writeFileSync(
+      path.join(xmlDir, 'VendorMapper.xml'),
+      '<mapper namespace="com.example.VendorMapper">\n' +
+        '<select id="findUser" databaseId="oracle">SELECT 1 FROM dual</select><select id="findUser" databaseId="mysql">SELECT 1</select>\n' +
+        '</mapper>\n'
+    );
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+
+    const xmlMethods = cg.getNodesByKind('method').filter((n) => n.language === 'xml');
+    const qnames = xmlMethods.map((n) => n.qualifiedName);
+
+    // iBatis statements now land in the graph (was zero coverage before #1182).
+    expect(qnames).toContain('Account::getById');
+    expect(qnames).toContain('Account::insert');
+    expect(qnames).toContain('Account::cols');
+    expect(qnames).toContain('LegacyDao::findAll');
+    // The commented-out statement produced no node.
+    expect(qnames).not.toContain('Account::disabled');
+
+    // <include refid='cols'/> resolves to the <sql> fragment in the same map.
+    const getById = xmlMethods.find((n) => n.qualifiedName === 'Account::getById');
+    const cols = xmlMethods.find((n) => n.qualifiedName === 'Account::cols');
+    expect(getById).toBeDefined();
+    expect(cols).toBeDefined();
+    const incEdge = cg.getOutgoingEdges(getById!.id).find((e) => e.target === cols!.id);
+    expect(incEdge, "iBatis <include refid='cols'/> should reach the <sql> fragment").toBeDefined();
+
+    // Both vendor-split statements survive the DB write (the collision fix).
+    const findUser = xmlMethods.filter((n) => n.name === 'findUser');
+    expect(findUser, 'both databaseId variants of findUser should survive').toHaveLength(2);
+    expect(new Set(findUser.map((n) => n.id)).size).toBe(2);
+
+    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');

+ 218 - 0
__tests__/mybatis-extractor-robustness.test.ts

@@ -0,0 +1,218 @@
+import { describe, it, expect } from 'vitest';
+import { extractFromSource } from '../src/extraction/tree-sitter';
+
+// Robustness of the MyBatis / iBatis mapper extractor. Four shapes the regex
+// scanner previously mishandled, all reported and diagnosed by @ESPINS in #1182:
+//   1. single-quoted attribute values,
+//   2. tags that live inside XML comments,
+//   3. iBatis 2 `<sqlMap>` files (zero statement coverage before),
+//   4. two statements that share a qualifiedName *and* a start line colliding
+//      on the node id (silent statement loss at the DB layer).
+// The quoting and comment suites below follow @ESPINS's fix-mybatis-quotes-comments
+// branch; the iBatis and collision suites cover the regex-only path taken here
+// (no parser dependency).
+
+const methodNodes = (xml: string, file = 'FooMapper.xml') =>
+  extractFromSource(file, xml).nodes.filter((n) => n.kind === 'method');
+
+const methodNames = (xml: string, file = 'FooMapper.xml') =>
+  methodNodes(xml, file).map((n) => n.qualifiedName);
+
+describe('MyBatis extractor — attribute quoting', () => {
+  it('accepts a single-quoted namespace', () => {
+    const xml =
+      "<mapper namespace='com.example.FooMapper'>" +
+      '<select id="getById">SELECT 1</select></mapper>';
+    expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
+  });
+
+  it('accepts a single-quoted statement id', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      "<select id='getById'>SELECT 1</select></mapper>";
+    expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
+  });
+
+  it('accepts a single-quoted <include refid>', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<sql id="cols">id, name</sql>' +
+      "<select id='getById'>SELECT <include refid='cols'/> FROM t</select>" +
+      '</mapper>';
+    const refs = extractFromSource('FooMapper.xml', xml).unresolvedReferences.map(
+      (r) => r.referenceName
+    );
+    expect(refs).toContain('com.example.FooMapper::cols');
+  });
+
+  it('reads single-quoted resultType / parameterType into the signature', () => {
+    const xml =
+      "<mapper namespace='com.example.FooMapper'>" +
+      "<select id='getById' resultType='User' parameterType='int'>SELECT 1</select>" +
+      '</mapper>';
+    const sig = methodNodes(xml).find((n) => n.name === 'getById')?.signature;
+    expect(sig).toContain('result=User');
+    expect(sig).toContain('param=int');
+  });
+
+  it('handles mixed single- and double-quoted attributes in one file', () => {
+    const xml =
+      "<mapper namespace='com.example.FooMapper'>" +
+      "<select id='getById' resultType='User'>SELECT 1</select>" +
+      '<update id="touch" parameterType="User">UPDATE t SET x=1</update>' +
+      '</mapper>';
+    expect(methodNames(xml)).toEqual([
+      'com.example.FooMapper::getById',
+      'com.example.FooMapper::touch',
+    ]);
+  });
+
+  it('still accepts double-quoted attributes (regression guard)', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<select id="getById">SELECT 1</select></mapper>';
+    expect(methodNames(xml)).toContain('com.example.FooMapper::getById');
+  });
+});
+
+describe('MyBatis extractor — XML comments', () => {
+  const result = (xml: string) => extractFromSource('FooMapper.xml', xml);
+
+  it('does not emit a node for a statement inside a comment', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<!-- <select id="dead">SELECT 1</select> -->' +
+      '<select id="live">SELECT 2</select></mapper>';
+    const names = result(xml)
+      .nodes.filter((n) => n.kind === 'method')
+      .map((n) => n.name);
+    expect(names).toContain('live');
+    expect(names).not.toContain('dead');
+  });
+
+  it('does not follow an <include> inside a comment', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<select id="getById">SELECT 1 <!-- <include refid="cols"/> --></select>' +
+      '</mapper>';
+    const refs = result(xml).unresolvedReferences.map((r) => r.referenceName);
+    expect(refs).not.toContain('com.example.FooMapper::cols');
+  });
+
+  it('keeps the correct startLine for a statement after a multi-line comment', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">\n' +
+      '<!--\n' +
+      '  a commented-out block\n' +
+      '  spanning several lines\n' +
+      '-->\n' +
+      '<select id="getById">SELECT 1</select>\n' +
+      '</mapper>\n';
+    const stmt = result(xml).nodes.find((n) => n.name === 'getById');
+    expect(stmt).toBeDefined();
+    // The <select> is on the 6th line of the document.
+    expect(stmt!.startLine).toBe(6);
+  });
+
+  it('treats <!-- and --> inside CDATA as data, not comment delimiters', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<![CDATA[<!--]]>' +
+      '<select id="live">SELECT 1</select>' +
+      '<![CDATA[-->]]>' +
+      '</mapper>';
+    const names = result(xml)
+      .nodes.filter((n) => n.kind === 'method')
+      .map((n) => n.name);
+    expect(names).toContain('live');
+  });
+
+  it('does not crash on an unterminated comment (blanks to end of file)', () => {
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<select id="before">SELECT 1</select>' +
+      '<!-- unterminated, swallowing a <select id="after">SELECT 2</select>';
+    const names = result(xml)
+      .nodes.filter((n) => n.kind === 'method')
+      .map((n) => n.name);
+    expect(names).toContain('before');
+    expect(names).not.toContain('after');
+  });
+});
+
+describe('MyBatis extractor — duplicate-id collision (#1182 gap 4)', () => {
+  it('keeps both statements of a same-line vendor-split databaseId pair', () => {
+    // Two <select>s share qualifiedName `…::findUser` AND a start line. The node
+    // id previously hashed only (path, kind, qualifiedName, startLine), so both
+    // hashed identically and INSERT OR REPLACE dropped one at the DB layer. The
+    // extractor pushes both regardless, so the collision shows up as *identical
+    // ids* here — assert the ids are now distinct.
+    const xml =
+      '<mapper namespace="com.example.FooMapper">' +
+      '<select id="findUser" databaseId="oracle">SELECT 1 FROM dual</select>' +
+      '<select id="findUser" databaseId="mysql">SELECT 1</select>' +
+      '</mapper>';
+    const nodes = methodNodes(xml).filter((n) => n.name === 'findUser');
+    expect(nodes).toHaveLength(2);
+    expect(new Set(nodes.map((n) => n.id)).size).toBe(2);
+    // qualifiedName is intentionally unchanged (the Java↔XML bridge keys on it).
+    expect(nodes.every((n) => n.qualifiedName === 'com.example.FooMapper::findUser')).toBe(true);
+    // The databaseId keeps the two signatures distinguishable.
+    expect(nodes.map((n) => n.signature).sort()).toEqual([
+      'SELECT databaseId=mysql',
+      'SELECT databaseId=oracle',
+    ]);
+  });
+});
+
+describe('iBatis 2 <sqlMap> coverage (#1182 gap 3)', () => {
+  it('extracts statements from a namespaced <sqlMap>', () => {
+    const xml =
+      '<?xml version="1.0" encoding="UTF-8"?>\n' +
+      '<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://ibatis.apache.org/dtd/sql-map-2.dtd">\n' +
+      '<sqlMap namespace="Account">\n' +
+      '  <select id="getById" resultClass="Account">SELECT * FROM account WHERE id = #id#</select>\n' +
+      '  <insert id="insert" parameterClass="Account">INSERT INTO account (id) VALUES (#id#)</insert>\n' +
+      '</sqlMap>\n';
+    expect(methodNames(xml, 'Account.xml')).toEqual(['Account::getById', 'Account::insert']);
+  });
+
+  it('splits a namespace-less DAO.method id on the last dot', () => {
+    const xml =
+      '<sqlMap>\n' +
+      '  <select id="Account.getById" resultClass="Account">SELECT 1</select>\n' +
+      '</sqlMap>\n';
+    const node = methodNodes(xml, 'Account.xml').find((n) => n.name === 'getById');
+    expect(node).toBeDefined();
+    expect(node!.qualifiedName).toBe('Account::getById');
+  });
+
+  it('recognizes iBatis <statement> and <procedure> verbs', () => {
+    const xml =
+      '<sqlMap namespace="Account">' +
+      '<statement id="runIt">SELECT 1</statement>' +
+      '<procedure id="callIt">{ call do_it() }</procedure>' +
+      '</sqlMap>';
+    expect(methodNames(xml, 'Account.xml').sort()).toEqual(['Account::callIt', 'Account::runIt']);
+  });
+
+  it('resolves an <include> to a <sql> fragment inside the sqlMap', () => {
+    const xml =
+      '<sqlMap namespace="Account">' +
+      '<sql id="cols">id, name</sql>' +
+      '<select id="getById">SELECT <include refid="cols"/> FROM account</select>' +
+      '</sqlMap>';
+    const refs = extractFromSource('Account.xml', xml).unresolvedReferences.map(
+      (r) => r.referenceName
+    );
+    expect(refs).toContain('Account::cols');
+  });
+
+  it('leaves the iBatis config root (<sqlMapConfig>) with no statement nodes', () => {
+    const xml =
+      '<sqlMapConfig>' +
+      '<sqlMap resource="com/example/Account.xml"/>' +
+      '</sqlMapConfig>';
+    expect(methodNodes(xml, 'SqlMapConfig.xml')).toHaveLength(0);
+  });
+});

+ 149 - 37
src/extraction/mybatis-extractor.ts

@@ -14,15 +14,21 @@ import { generateNodeId } from './tree-sitter-helpers';
  *
  * 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>`.
+ * MyBatis framework synthesizer 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>`.
+ *
+ * Both dialects are covered: MyBatis 3 `<mapper namespace="...">` and the
+ * legacy iBatis 2 `<sqlMap>` (namespaced, or namespace-less with `Map.stmt`
+ * ids, plus its extra `<statement>`/`<procedure>` verbs). Attribute values may
+ * use either quote style, and statements commented out with `<!-- ... -->` are
+ * ignored (see the constructor's comment-stripping pre-pass).
  *
  * 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.
+ * etc.) is detected by the absence of a `<mapper namespace="...">` /
+ * `<sqlMap>` 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;
@@ -35,19 +41,50 @@ export class MyBatisExtractor {
 
   constructor(filePath: string, source: string) {
     this.filePath = filePath;
-    this.source = source;
+    // Blank out XML comments up front so commented-out statements and includes
+    // aren't matched by the scans below (a `<!-- <select id="old">…</select> -->`
+    // block must not produce a phantom node). Length-preserving — comment bytes
+    // become spaces, newlines are kept — so the offsets and line numbers
+    // computed afterwards still map to the original source. Text inside
+    // `<![CDATA[ … ]]>` is left intact: a literal `<!--` there is SQL data, not
+    // an XML comment.
+    this.source = MyBatisExtractor.stripXmlComments(source);
     this.computeLineStarts();
   }
 
+  private static stripXmlComments(source: string): string {
+    const out = source.split('');
+    const n = source.length;
+    let i = 0;
+    while (i < n) {
+      if (source.startsWith('<![CDATA[', i)) {
+        const end = source.indexOf(']]>', i + 9);
+        i = end >= 0 ? end + 3 : n;
+        continue;
+      }
+      if (source.startsWith('<!--', i)) {
+        const end = source.indexOf('-->', i + 4);
+        const stop = end >= 0 ? end + 3 : n;
+        for (let j = i; j < stop; j++) {
+          if (source.charCodeAt(j) !== 10) out[j] = ' ';
+        }
+        i = stop;
+        continue;
+      }
+      i++;
+    }
+    return out.join('');
+  }
+
   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);
+      const root = this.findMapperRoot();
+      if (root) {
+        this.extractMapper(fileNode.id, root.namespace, root.dialect, root.bodyStart, root.bodyEnd);
       }
     } catch (error) {
       this.errors.push({
@@ -87,47 +124,98 @@ export class MyBatisExtractor {
   }
 
   /**
-   * 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.
+   * Find the mapper root and its dialect. Two shapes are recognized:
+   *   - MyBatis 3: `<mapper namespace="com.foo.Bar">` — namespace required.
+   *   - iBatis 2:  `<sqlMap namespace="Account">`, or a namespace-less
+   *     `<sqlMap>` whose statement ids carry the qualifier as `Map.statement`.
+   * Returns the namespace, the dialect, and the byte offsets of the body
+   * (between the opening and closing tag) so statement extraction is scoped to
+   * the root's contents. Either quote style is accepted for the namespace
+   * (`namespace='X'` is legal XML and common in older mappers).
    */
-  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 findMapperRoot():
+    | { namespace: string; dialect: 'mybatis' | 'ibatis'; bodyStart: number; bodyEnd: number }
+    | null {
+    const mapper = /<mapper\b([^>]*)>/.exec(this.source);
+    if (mapper) {
+      const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(mapper[1] ?? '');
+      if (nsMatch) {
+        const bodyStart = mapper.index + mapper[0].length;
+        const closeIdx = this.source.indexOf('</mapper>', bodyStart);
+        return {
+          namespace: nsMatch[2]!,
+          dialect: 'mybatis',
+          bodyStart,
+          bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
+        };
+      }
+    }
+    // iBatis 2 SqlMap. `\b` keeps `<sqlMapConfig>` (the iBatis config root,
+    // which holds no statements) from matching here. namespace is optional.
+    const sqlMap = /<sqlMap\b([^>]*)>/.exec(this.source);
+    if (sqlMap) {
+      const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(sqlMap[1] ?? '');
+      const bodyStart = sqlMap.index + sqlMap[0].length;
+      const closeIdx = this.source.indexOf('</sqlMap>', bodyStart);
+      return {
+        namespace: nsMatch?.[2] ?? '',
+        dialect: 'ibatis',
+        bodyStart,
+        bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length,
+      };
+    }
+    return null;
   }
 
-  private extractMapper(fileNodeId: string, namespace: string, bodyStart: number, bodyEnd: number): void {
+  private extractMapper(
+    fileNodeId: string,
+    namespace: string,
+    dialect: 'mybatis' | 'ibatis',
+    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;
+    // because MyBatis/iBatis statement elements are not themselves nested.
+    // iBatis 2 adds the generic `<statement>` and `<procedure>` on top of the
+    // MyBatis 3 verbs; gating by dialect keeps MyBatis extraction unchanged.
+    const verbs =
+      dialect === 'ibatis'
+        ? 'select|insert|update|delete|sql|statement|procedure'
+        : 'select|insert|update|delete|sql';
+    const stmtRegex = new RegExp(`<(${verbs})\\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);
+      // Accept either quote style (`(["'])…\1`). The identifier-shaped MyBatis
+      // attributes matched here and below (namespace/id/refid/resultType/
+      // parameterType) are Java FQNs, method names, or type aliases and never
+      // contain a quote character, so excluding both quotes from the value is safe.
+      const idMatch = /\bid\s*=\s*(["'])([^"']+)\1/.exec(attrs);
       if (!idMatch) continue;
-      const id = idMatch[1]!;
+      const id = idMatch[2]!;
       const absoluteIndex = bodyStart + m.index;
       const startLine = this.getLineNumber(absoluteIndex);
       const endLine = this.getLineNumber(absoluteIndex + m[0].length);
-      const qualified = `${namespace}::${id}`;
+      const { qualifiedName: qualified, name } = this.qualifyStatement(namespace, id);
       const isSqlFragment = elemType === 'sql';
-      const nodeId = generateNodeId(this.filePath, 'method', qualified, startLine);
+      // The id-hash folds in the statement's byte offset (unique per statement
+      // in the file), not just the start line: two statements sharing a
+      // qualifiedName AND a start line — e.g. a vendor-split `databaseId` pair
+      // (`<select id="x" databaseId="oracle">…</select><select id="x"
+      // databaseId="mysql">…`) written on one line — would otherwise hash to
+      // the same node id, and `INSERT OR REPLACE INTO nodes` (id is the PRIMARY
+      // KEY) would silently drop one. qualifiedName and startLine are stored
+      // unchanged, so the Java↔XML suffix-match bridge is untouched.
+      const nodeId = generateNodeId(this.filePath, 'method', qualified, absoluteIndex);
       const node: Node = {
         id: nodeId,
         kind: 'method',
-        name: id,
+        name,
         qualifiedName: qualified,
         filePath: this.filePath,
         language: 'xml',
@@ -144,11 +232,15 @@ export class MyBatisExtractor {
 
       // <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;
+      const includeRegex = /<include\b[^>]*\brefid\s*=\s*(["'])([^"']+)\1/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 refid = inc[2]!;
+        const refQualified = refid.includes('.')
+          ? refid.replace(/\./g, '::')
+          : namespace
+            ? `${namespace}::${refid}`
+            : refid;
         const includeOffset = absoluteIndex + (m[0].length - m[3]!.length - `</${elemType}>`.length) + inc.index;
         const line = this.getLineNumber(includeOffset);
         this.unresolvedReferences.push({
@@ -165,14 +257,34 @@ export class MyBatisExtractor {
   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 result = /\bresultType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
+    const param = /\bparameterType\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
+    // A vendor-split statement carries `databaseId`; surface it so the two
+    // otherwise-identical `<namespace>::<id>` nodes are distinguishable.
+    const dbId = /\bdatabaseId\s*=\s*(["'])([^"']+)\1/.exec(attrs)?.[2];
     const parts = [verb];
     if (param) parts.push(`param=${param}`);
     if (result) parts.push(`result=${result}`);
+    if (dbId) parts.push(`databaseId=${dbId}`);
     return parts.join(' ');
   }
 
+  /**
+   * Build the `<namespace>::<id>` qualified name the MyBatis synthesizer
+   * suffix-matches against a Java `<Class>::<method>`, and the display name.
+   * For a namespace-less iBatis `<sqlMap>`, the statement id carries the
+   * qualifier as `Map.statement`, so split on the last dot to reach the same
+   * shape (`Account.getById` → `Account::getById`, name `getById`).
+   */
+  private qualifyStatement(namespace: string, id: string): { qualifiedName: string; name: string } {
+    if (namespace) return { qualifiedName: `${namespace}::${id}`, name: id };
+    const dot = id.lastIndexOf('.');
+    if (dot >= 0) {
+      return { qualifiedName: `${id.slice(0, dot)}::${id.slice(dot + 1)}`, name: id.slice(dot + 1) };
+    }
+    return { qualifiedName: id, name: id };
+  }
+
   private previewSql(body: string): string {
     return body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
   }