Ver Fonte

Merge pull request #20 from colbymchenry/pr-19

Port quality improvements from PR #15
Colby Mchenry há 4 meses atrás
pai
commit
5e2d3d6d75

+ 715 - 0
__tests__/pr19-improvements.test.ts

@@ -0,0 +1,715 @@
+/**
+ * PR #19 Improvement Tests
+ *
+ * Tests for changes ported from PR #15 and #16:
+ * - Lazy grammar loading
+ * - Arrow function extraction (body traversal)
+ * - Graph traversal 'both' direction fix
+ * - Best-candidate resolution picking
+ * - Schema v2 migration (filePath/language on unresolved_refs)
+ * - Batch insert for unresolved refs
+ * - SQLite performance pragmas
+ * - MCP symbol disambiguation and output truncation
+ * - CLI uninit command
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { extractFromSource } from '../src/extraction';
+import {
+  getParser,
+  isLanguageSupported,
+  getSupportedLanguages,
+  clearParserCache,
+  getUnavailableGrammarErrors,
+} from '../src/extraction/grammars';
+
+// Create a temporary directory for each test
+function createTempDir(): string {
+  return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pr19-test-'));
+}
+
+// Clean up temporary directory
+function cleanupTempDir(dir: string): void {
+  if (fs.existsSync(dir)) {
+    fs.rmSync(dir, { recursive: true, force: true });
+  }
+}
+
+// Check if better-sqlite3 native bindings are available
+function hasSqliteBindings(): boolean {
+  try {
+    const Database = require('better-sqlite3');
+    const db = new Database(':memory:');
+    db.close();
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+const HAS_SQLITE = hasSqliteBindings();
+
+// =============================================================================
+// Lazy Grammar Loading
+// =============================================================================
+
+describe('Lazy Grammar Loading', () => {
+  afterEach(() => {
+    clearParserCache();
+  });
+
+  it('should load grammars lazily on first use', () => {
+    // Clear cache to force fresh load
+    clearParserCache();
+
+    // TypeScript should be loadable
+    const parser = getParser('typescript');
+    expect(parser).not.toBeNull();
+  });
+
+  it('should cache loaded grammars', () => {
+    clearParserCache();
+
+    const parser1 = getParser('typescript');
+    const parser2 = getParser('typescript');
+
+    // Same reference from cache
+    expect(parser1).toBe(parser2);
+  });
+
+  it('should return null for unknown language', () => {
+    const parser = getParser('unknown');
+    expect(parser).toBeNull();
+  });
+
+  it('should handle unavailable grammars gracefully', () => {
+    // 'unknown' is not a valid grammar, should not crash
+    expect(isLanguageSupported('unknown')).toBe(false);
+  });
+
+  it('should report liquid as supported (custom extractor)', () => {
+    expect(isLanguageSupported('liquid')).toBe(true);
+  });
+
+  it('should include liquid in supported languages', () => {
+    const supported = getSupportedLanguages();
+    expect(supported).toContain('liquid');
+  });
+
+  it('should return unavailable grammar errors as a record', () => {
+    clearParserCache();
+    const errors = getUnavailableGrammarErrors();
+    // Should be a plain object (may or may not have entries depending on platform)
+    expect(typeof errors).toBe('object');
+  });
+
+  it('should support multiple languages independently', () => {
+    clearParserCache();
+
+    // Load two different languages - one failing shouldn't affect the other
+    const tsParser = getParser('typescript');
+    const pyParser = getParser('python');
+
+    expect(tsParser).not.toBeNull();
+    expect(pyParser).not.toBeNull();
+    expect(tsParser).not.toBe(pyParser);
+  });
+
+  it('should clear all caches on clearParserCache', () => {
+    // Load a grammar
+    getParser('typescript');
+
+    // Clear
+    clearParserCache();
+
+    // Errors should be cleared too
+    const errors = getUnavailableGrammarErrors();
+    expect(Object.keys(errors)).toHaveLength(0);
+  });
+});
+
+// =============================================================================
+// Arrow Function Extraction - Body Traversal
+// =============================================================================
+
+describe('Arrow Function Body Traversal', () => {
+  it('should extract unresolved references from arrow function bodies', () => {
+    const code = `
+export const useAuth = () => {
+  const user = getUser();
+  const token = generateToken(user);
+  return { user, token };
+};
+`;
+    const result = extractFromSource('hooks.ts', code);
+
+    // The arrow function should be extracted
+    const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'useAuth');
+    expect(funcNode).toBeDefined();
+
+    // Calls inside the body should be captured as unresolved references
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    const callNames = calls.map((c) => c.referenceName);
+    expect(callNames).toContain('getUser');
+    expect(callNames).toContain('generateToken');
+  });
+
+  it('should extract unresolved references from function expression bodies', () => {
+    const code = `
+export const processData = function(input: string): string {
+  const cleaned = sanitize(input);
+  return transform(cleaned);
+};
+`;
+    const result = extractFromSource('utils.ts', code);
+
+    const funcNode = result.nodes.find((n) => n.kind === 'function' && n.name === 'processData');
+    expect(funcNode).toBeDefined();
+
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    const callNames = calls.map((c) => c.referenceName);
+    expect(callNames).toContain('sanitize');
+    expect(callNames).toContain('transform');
+  });
+
+  it('should not create duplicate nodes for arrow functions', () => {
+    const code = `
+export const handler = () => {
+  doSomething();
+};
+`;
+    const result = extractFromSource('handler.ts', code);
+
+    // Should be exactly 1 function node, 0 variable nodes for 'handler'
+    const funcNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'function');
+    const varNodes = result.nodes.filter((n) => n.name === 'handler' && n.kind === 'variable');
+    expect(funcNodes).toHaveLength(1);
+    expect(varNodes).toHaveLength(0);
+  });
+
+  it('should extract nested calls in arrow functions in JavaScript', () => {
+    const code = `
+export const fetchData = async () => {
+  const response = await fetchAPI('/data');
+  return parseResponse(response);
+};
+`;
+    const result = extractFromSource('api.js', code);
+
+    const funcNode = result.nodes.find((n) => n.name === 'fetchData');
+    expect(funcNode).toBeDefined();
+    expect(funcNode?.kind).toBe('function');
+
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
+    const callNames = calls.map((c) => c.referenceName);
+    expect(callNames).toContain('fetchAPI');
+    expect(callNames).toContain('parseResponse');
+  });
+});
+
+// =============================================================================
+// Graph Traversal 'both' Direction Fix
+// (requires better-sqlite3 - will use CodeGraph integration)
+// =============================================================================
+
+describe('Graph Traversal Both Direction', () => {
+  let testDir: string;
+
+  beforeEach(() => {
+    testDir = createTempDir();
+  });
+
+  afterEach(() => {
+    cleanupTempDir(testDir);
+  });
+
+  it.skipIf(!HAS_SQLITE)('should traverse both directions from a node', async () => {
+    const CodeGraph = (await import('../src/index')).default;
+
+    const srcDir = path.join(testDir, 'src');
+    fs.mkdirSync(srcDir, { recursive: true });
+
+    // A -> B -> C  (A calls B, B calls C)
+    fs.writeFileSync(path.join(srcDir, 'a.ts'), `
+import { funcB } from './b';
+export function funcA(): void { funcB(); }
+`);
+    fs.writeFileSync(path.join(srcDir, 'b.ts'), `
+import { funcC } from './c';
+export function funcB(): void { funcC(); }
+`);
+    fs.writeFileSync(path.join(srcDir, 'c.ts'), `
+export function funcC(): void { console.log('c'); }
+`);
+
+    const cg = CodeGraph.initSync(testDir, {
+      config: { include: ['src/**/*.ts'], exclude: [] },
+    });
+
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const functions = cg.getNodesByKind('function');
+    const funcB = functions.find((n) => n.name === 'funcB');
+
+    if (!funcB) {
+      cg.destroy();
+      return;
+    }
+
+    // Traverse 'both' from B - should find A (incoming caller) and C (outgoing callee)
+    const subgraph = cg.traverse(funcB.id, {
+      maxDepth: 1,
+      direction: 'both',
+    });
+
+    // B itself + at least one neighbor in each direction
+    expect(subgraph.nodes.size).toBeGreaterThanOrEqual(2);
+    expect(subgraph.nodes.has(funcB.id)).toBe(true);
+
+    cg.destroy();
+  });
+});
+
+// =============================================================================
+// Best-Candidate Resolution
+// =============================================================================
+
+describe('Best-Candidate Resolution', () => {
+  it.skipIf(!HAS_SQLITE)('should be testable via the resolution module types', async () => {
+    const { ReferenceResolver } = await import('../src/resolution');
+    expect(typeof ReferenceResolver.prototype.resolveOne).toBe('function');
+  });
+});
+
+// =============================================================================
+// Schema v2 Migration
+// =============================================================================
+
+describe('Schema v2 Migration', () => {
+  it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => {
+    const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations');
+    expect(CURRENT_SCHEMA_VERSION).toBe(2);
+  });
+
+  it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => {
+    const { getPendingMigrations } = await import('../src/db/migrations');
+    expect(typeof getPendingMigrations).toBe('function');
+  });
+});
+
+// =============================================================================
+// Database Layer: Batch Insert, getAllNodes, Pragmas
+// =============================================================================
+
+describe('Database Layer Improvements', () => {
+  let testDir: string;
+
+  beforeEach(() => {
+    testDir = createTempDir();
+  });
+
+  afterEach(() => {
+    cleanupTempDir(testDir);
+  });
+
+  it.skipIf(!HAS_SQLITE)('should support batch insert of unresolved refs', async () => {
+    const { DatabaseConnection } = await import('../src/db');
+    const { QueryBuilder } = await import('../src/db/queries');
+
+    const db = DatabaseConnection.initialize(testDir);
+    const queries = new QueryBuilder(db.getDatabase());
+
+    // Insert a node first (needed as foreign key)
+    queries.insertNode({
+      id: 'func:test:1',
+      kind: 'function',
+      name: 'testFunc',
+      qualifiedName: 'test::testFunc',
+      filePath: 'test.ts',
+      language: 'typescript',
+      startLine: 1,
+      endLine: 5,
+      startColumn: 0,
+      endColumn: 1,
+      updatedAt: Date.now(),
+    });
+
+    // Batch insert unresolved refs with filePath and language
+    queries.insertUnresolvedRefsBatch([
+      {
+        fromNodeId: 'func:test:1',
+        referenceName: 'helperA',
+        referenceKind: 'calls',
+        line: 2,
+        column: 4,
+        filePath: 'test.ts',
+        language: 'typescript',
+      },
+      {
+        fromNodeId: 'func:test:1',
+        referenceName: 'helperB',
+        referenceKind: 'calls',
+        line: 3,
+        column: 4,
+        filePath: 'test.ts',
+        language: 'typescript',
+      },
+    ]);
+
+    const refs = queries.getUnresolvedReferences();
+    expect(refs).toHaveLength(2);
+    expect(refs.map((r) => r.referenceName).sort()).toEqual(['helperA', 'helperB']);
+
+    // Verify filePath and language are persisted
+    expect(refs[0]?.filePath).toBe('test.ts');
+    expect(refs[0]?.language).toBe('typescript');
+
+    db.close();
+  });
+
+  it.skipIf(!HAS_SQLITE)('should support getAllNodes', async () => {
+    const { DatabaseConnection } = await import('../src/db');
+    const { QueryBuilder } = await import('../src/db/queries');
+
+    const db = DatabaseConnection.initialize(testDir);
+    const queries = new QueryBuilder(db.getDatabase());
+
+    // Insert some nodes
+    for (let i = 0; i < 3; i++) {
+      queries.insertNode({
+        id: `func:test:${i}`,
+        kind: 'function',
+        name: `func${i}`,
+        qualifiedName: `test::func${i}`,
+        filePath: 'test.ts',
+        language: 'typescript',
+        startLine: i * 10 + 1,
+        endLine: i * 10 + 5,
+        startColumn: 0,
+        endColumn: 1,
+        updatedAt: Date.now(),
+      });
+    }
+
+    const allNodes = queries.getAllNodes();
+    expect(allNodes).toHaveLength(3);
+    expect(allNodes.map((n) => n.name).sort()).toEqual(['func0', 'func1', 'func2']);
+
+    db.close();
+  });
+
+  it.skipIf(!HAS_SQLITE)('should set performance pragmas on initialization', async () => {
+    const { DatabaseConnection } = await import('../src/db');
+
+    const db = DatabaseConnection.initialize(testDir);
+    const rawDb = db.getDatabase();
+
+    // Check pragmas were set
+    const synchronous = rawDb.pragma('synchronous', { simple: true });
+    expect(synchronous).toBe(1); // NORMAL = 1
+
+    const cacheSize = rawDb.pragma('cache_size', { simple: true }) as number;
+    expect(cacheSize).toBe(-64000);
+
+    const tempStore = rawDb.pragma('temp_store', { simple: true });
+    expect(tempStore).toBe(2); // MEMORY = 2
+
+    const mmapSize = rawDb.pragma('mmap_size', { simple: true }) as number;
+    expect(mmapSize).toBe(268435456); // 256 MB
+
+    db.close();
+  });
+
+  it.skipIf(!HAS_SQLITE)('should handle empty batch insert gracefully', async () => {
+    const { DatabaseConnection } = await import('../src/db');
+    const { QueryBuilder } = await import('../src/db/queries');
+
+    const db = DatabaseConnection.initialize(testDir);
+    const queries = new QueryBuilder(db.getDatabase());
+
+    // Should not throw on empty array
+    expect(() => queries.insertUnresolvedRefsBatch([])).not.toThrow();
+
+    db.close();
+  });
+});
+
+// =============================================================================
+// Resolution Warm Caches
+// =============================================================================
+
+describe('Resolution Warm Caches', () => {
+  let testDir: string;
+
+  beforeEach(() => {
+    testDir = createTempDir();
+  });
+
+  afterEach(() => {
+    cleanupTempDir(testDir);
+  });
+
+  it.skipIf(!HAS_SQLITE)('should warm caches and use them for lookups', async () => {
+    const CodeGraph = (await import('../src/index')).default;
+
+    const srcDir = path.join(testDir, 'src');
+    fs.mkdirSync(srcDir, { recursive: true });
+
+    fs.writeFileSync(path.join(srcDir, 'a.ts'), `
+export function myFunc(): void {}
+export function otherFunc(): void { myFunc(); }
+`);
+
+    const cg = CodeGraph.initSync(testDir, {
+      config: { include: ['src/**/*.ts'], exclude: [] },
+    });
+
+    await cg.indexAll();
+
+    // resolveReferences internally calls warmCaches
+    const result = cg.resolveReferences();
+
+    // Should complete without error
+    expect(result.stats.total).toBeGreaterThanOrEqual(0);
+
+    cg.destroy();
+  });
+});
+
+// =============================================================================
+// MCP Tool Improvements
+// =============================================================================
+
+describe('MCP Tool Improvements', () => {
+  it.skipIf(!HAS_SQLITE)('should export ToolHandler class', async () => {
+    const { ToolHandler } = await import('../src/mcp/tools');
+    expect(typeof ToolHandler).toBe('function');
+  });
+
+  it.skipIf(!HAS_SQLITE)('should have findSymbol and truncateOutput as private methods', async () => {
+    const { ToolHandler } = await import('../src/mcp/tools');
+    const proto = ToolHandler.prototype;
+    expect(typeof (proto as any).findSymbol).toBe('function');
+    expect(typeof (proto as any).truncateOutput).toBe('function');
+  });
+
+  it.skipIf(!HAS_SQLITE)('should truncate output exceeding MAX_OUTPUT_LENGTH', async () => {
+    const { ToolHandler } = await import('../src/mcp/tools');
+
+    // Access private method for testing
+    const handler = Object.create(ToolHandler.prototype);
+    const truncate = (handler as any).truncateOutput.bind(handler);
+
+    // Short text should not be truncated
+    const short = 'Hello world';
+    expect(truncate(short)).toBe(short);
+
+    // Long text should be truncated
+    const long = 'x'.repeat(20000);
+    const result = truncate(long);
+    expect(result.length).toBeLessThan(long.length);
+    expect(result).toContain('... (output truncated)');
+  });
+
+  it.skipIf(!HAS_SQLITE)('should truncate at a clean line boundary', async () => {
+    const { ToolHandler } = await import('../src/mcp/tools');
+
+    const handler = Object.create(ToolHandler.prototype);
+    const truncate = (handler as any).truncateOutput.bind(handler);
+
+    // Build text with newlines exceeding the limit
+    const lines: string[] = [];
+    for (let i = 0; i < 500; i++) {
+      lines.push(`Line ${i}: ${'a'.repeat(50)}`);
+    }
+    const text = lines.join('\n');
+
+    const result = truncate(text);
+    // Should end with truncation notice after a newline boundary
+    expect(result).toContain('... (output truncated)');
+    // Should not cut mid-line (the char before truncation notice should be \n)
+    const beforeTruncation = result.split('\n\n... (output truncated)')[0]!;
+    expect(beforeTruncation.endsWith('\n') || !beforeTruncation.includes('\0')).toBe(true);
+  });
+
+  describe('findSymbol disambiguation', () => {
+    it.skipIf(!HAS_SQLITE)('should prefer exact name matches', async () => {
+      const { ToolHandler } = await import('../src/mcp/tools');
+      const CodeGraph = (await import('../src/index')).default;
+
+      const tmpDir = createTempDir();
+      const srcDir = path.join(tmpDir, 'src');
+      fs.mkdirSync(srcDir, { recursive: true });
+
+      fs.writeFileSync(path.join(srcDir, 'a.ts'), `
+export function getValue(): number { return 1; }
+export function getValueFromCache(): number { return 2; }
+`);
+
+      const cg = CodeGraph.initSync(tmpDir, {
+        config: { include: ['src/**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      const handler = new ToolHandler(cg);
+      const findSymbol = (handler as any).findSymbol.bind(handler);
+
+      const match = findSymbol(cg, 'getValue');
+      expect(match).not.toBeNull();
+      expect(match.node.name).toBe('getValue');
+      // Should not have a disambiguation note for single exact match
+      expect(match.note).toBe('');
+
+      handler.closeAll();
+      cg.destroy();
+      cleanupTempDir(tmpDir);
+    });
+
+    it.skipIf(!HAS_SQLITE)('should note when multiple symbols share the same name', async () => {
+      const { ToolHandler } = await import('../src/mcp/tools');
+      const CodeGraph = (await import('../src/index')).default;
+
+      const tmpDir = createTempDir();
+      const srcDir = path.join(tmpDir, 'src');
+      fs.mkdirSync(srcDir, { recursive: true });
+
+      // Two files with the same function name
+      fs.writeFileSync(path.join(srcDir, 'a.ts'), `
+export function handle(): void {}
+`);
+      fs.writeFileSync(path.join(srcDir, 'b.ts'), `
+export function handle(): void {}
+`);
+
+      const cg = CodeGraph.initSync(tmpDir, {
+        config: { include: ['src/**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      const handler = new ToolHandler(cg);
+      const findSymbol = (handler as any).findSymbol.bind(handler);
+
+      const match = findSymbol(cg, 'handle');
+      expect(match).not.toBeNull();
+      expect(match.node.name).toBe('handle');
+      // Should have a disambiguation note
+      expect(match.note).toContain('2 symbols named "handle"');
+
+      handler.closeAll();
+      cg.destroy();
+      cleanupTempDir(tmpDir);
+    });
+
+    it.skipIf(!HAS_SQLITE)('should return null when symbol is not found', async () => {
+      const { ToolHandler } = await import('../src/mcp/tools');
+      const CodeGraph = (await import('../src/index')).default;
+
+      const tmpDir = createTempDir();
+      const srcDir = path.join(tmpDir, 'src');
+      fs.mkdirSync(srcDir, { recursive: true });
+      fs.writeFileSync(path.join(srcDir, 'a.ts'), `export function foo(): void {}`);
+
+      const cg = CodeGraph.initSync(tmpDir, {
+        config: { include: ['src/**/*.ts'], exclude: [] },
+      });
+      await cg.indexAll();
+
+      const handler = new ToolHandler(cg);
+      const findSymbol = (handler as any).findSymbol.bind(handler);
+
+      const match = findSymbol(cg, 'nonExistentSymbol');
+      expect(match).toBeNull();
+
+      handler.closeAll();
+      cg.destroy();
+      cleanupTempDir(tmpDir);
+    });
+  });
+});
+
+// =============================================================================
+// CLI uninit Command
+// =============================================================================
+
+describe('CLI uninit', () => {
+  let testDir: string;
+
+  beforeEach(() => {
+    testDir = createTempDir();
+  });
+
+  afterEach(() => {
+    cleanupTempDir(testDir);
+  });
+
+  it.skipIf(!HAS_SQLITE)('should uninitialize a project via CodeGraph.uninitialize()', async () => {
+    const CodeGraph = (await import('../src/index')).default;
+
+    // Initialize
+    const cg = CodeGraph.initSync(testDir);
+    expect(CodeGraph.isInitialized(testDir)).toBe(true);
+
+    // Uninitialize
+    cg.uninitialize();
+
+    // .codegraph directory should be removed
+    expect(CodeGraph.isInitialized(testDir)).toBe(false);
+  });
+});
+
+// =============================================================================
+// Tree-sitter Version Pinning
+// =============================================================================
+
+describe('Tree-sitter Version Pinning', () => {
+  it('should have exact versions (no caret) in package.json', () => {
+    const pkgPath = path.join(__dirname, '..', 'package.json');
+    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
+
+    const treeSitterDeps = Object.entries(pkg.dependencies as Record<string, string>)
+      .filter(([name]) => name.startsWith('tree-sitter') || name.includes('tree-sitter'));
+
+    for (const [name, version] of treeSitterDeps) {
+      // Skip github: references
+      if (version.startsWith('github:')) continue;
+      expect(version, `${name} should not use caret range`).not.toMatch(/^\^/);
+    }
+  });
+
+  it('should have tree-sitter override pinned', () => {
+    const pkgPath = path.join(__dirname, '..', 'package.json');
+    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
+
+    expect(pkg.overrides).toBeDefined();
+    expect(pkg.overrides['tree-sitter']).toBeDefined();
+    expect(pkg.overrides['tree-sitter']).not.toMatch(/^\^/);
+  });
+});
+
+// =============================================================================
+// Embedder Float32Array Fix
+// =============================================================================
+
+describe('Float32Array Fix', () => {
+  it('should correctly convert typed arrays (regression check)', () => {
+    // Simulates the fix: Float32Array.from(Array.from(arr)) vs new Float32Array(arr.length)
+    const source = new Float64Array([1.5, 2.5, 3.5, 4.5]);
+
+    // The OLD buggy approach:
+    const buggy = new Float32Array(source.length);
+    // buggy is all zeros!
+    expect(buggy[0]).toBe(0);
+    expect(buggy[1]).toBe(0);
+
+    // The NEW fixed approach:
+    const fixed = Float32Array.from(Array.from(source));
+    expect(fixed[0]).toBeCloseTo(1.5);
+    expect(fixed[1]).toBeCloseTo(2.5);
+    expect(fixed[2]).toBeCloseTo(3.5);
+    expect(fixed[3]).toBeCloseTo(4.5);
+  });
+});

+ 19 - 16
package.json

@@ -15,7 +15,7 @@
   "scripts": {
     "build": "tsc && npm run copy-assets",
     "postinstall": "node scripts/postinstall.js",
-    "copy-assets": "node -e \"const fs=require('fs'),p=require('path');function cpR(s,d){if(!fs.existsSync(s))return;fs.mkdirSync(d,{recursive:true});for(const f of fs.readdirSync(s)){const sp=p.join(s,f),dp=p.join(d,f);fs.statSync(sp).isDirectory()?cpR(sp,dp):fs.copyFileSync(sp,dp)}}cpR('src/extraction/queries','dist/extraction/queries');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql')\"",
+    "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql')\"",
     "dev": "tsc --watch",
     "cli": "npm run build && node dist/bin/codegraph.js",
     "test": "vitest run",
@@ -38,22 +38,22 @@
     "commander": "^14.0.2",
     "figlet": "^1.8.0",
     "sqlite-vss": "^0.1.2",
-    "tree-sitter": "^0.22.4",
-    "tree-sitter-c": "^0.23.4",
-    "tree-sitter-c-sharp": "^0.23.1",
-    "tree-sitter-cpp": "^0.23.4",
-    "@sengac/tree-sitter-dart": "^1.1.6",
-    "tree-sitter-go": "^0.23.4",
-    "tree-sitter-java": "^0.23.5",
-    "tree-sitter-javascript": "^0.23.1",
-    "tree-sitter-kotlin": "^0.3.8",
+    "tree-sitter": "0.22.4",
+    "tree-sitter-c": "0.23.4",
+    "tree-sitter-c-sharp": "0.23.1",
+    "tree-sitter-cpp": "0.23.4",
+    "@sengac/tree-sitter-dart": "1.1.6",
+    "tree-sitter-go": "0.23.4",
+    "tree-sitter-java": "0.23.5",
+    "tree-sitter-javascript": "0.23.1",
+    "tree-sitter-kotlin": "0.3.8",
     "tree-sitter-liquid": "github:hankthetank27/tree-sitter-liquid",
-    "tree-sitter-php": "^0.23.11",
-    "tree-sitter-python": "^0.23.6",
-    "tree-sitter-ruby": "^0.23.1",
-    "tree-sitter-rust": "^0.23.2",
-    "tree-sitter-swift": "^0.7.1",
-    "tree-sitter-typescript": "^0.23.2"
+    "tree-sitter-php": "0.23.11",
+    "tree-sitter-python": "0.23.6",
+    "tree-sitter-ruby": "0.23.1",
+    "tree-sitter-rust": "0.23.2",
+    "tree-sitter-swift": "0.7.1",
+    "tree-sitter-typescript": "0.23.2"
   },
   "devDependencies": {
     "@types/better-sqlite3": "^7.6.0",
@@ -64,5 +64,8 @@
   },
   "engines": {
     "node": ">=18.0.0"
+  },
+  "overrides": {
+    "tree-sitter": "0.22.4"
   }
 }

+ 46 - 0
src/bin/codegraph.ts

@@ -8,6 +8,7 @@
  *   codegraph                    Run interactive installer (when no args)
  *   codegraph install            Run interactive installer
  *   codegraph init [path]        Initialize CodeGraph in a project
+ *   codegraph uninit [path]      Remove CodeGraph from a project
  *   codegraph index [path]       Index all files in the project
  *   codegraph sync [path]        Sync changes since last index
  *   codegraph status [path]      Show index status
@@ -280,6 +281,51 @@ program
     }
   });
 
+/**
+ * codegraph uninit [path]
+ */
+program
+  .command('uninit [path]')
+  .description('Remove CodeGraph from a project (deletes .codegraph/ directory)')
+  .option('-f, --force', 'Skip confirmation prompt')
+  .action(async (pathArg: string | undefined, options: { force?: boolean }) => {
+    const projectPath = resolveProjectPath(pathArg);
+
+    try {
+      if (!CodeGraph.isInitialized(projectPath)) {
+        warn(`CodeGraph is not initialized in ${projectPath}`);
+        return;
+      }
+
+      if (!options.force) {
+        // Confirm with user
+        const readline = await import('readline');
+        const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+        const answer = await new Promise<string>((resolve) => {
+          rl.question(
+            chalk.yellow('⚠ This will permanently delete all CodeGraph data. Continue? (y/N) '),
+            resolve
+          );
+        });
+        rl.close();
+
+        if (answer.toLowerCase() !== 'y') {
+          info('Cancelled');
+          return;
+        }
+      }
+
+      const cg = CodeGraph.openSync(projectPath);
+      cg.uninitialize();
+
+      success(`Removed CodeGraph from ${projectPath}`);
+    } catch (err) {
+      captureException(err);
+      error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
+      process.exit(1);
+    }
+  });
+
 /**
  * codegraph index [path]
  */

+ 10 - 0
src/db/index.ts

@@ -41,6 +41,11 @@ export class DatabaseConnection {
     // Wait up to 2 minutes if database is locked by another process
     // (indexing operations can hold locks for extended periods)
     db.pragma('busy_timeout = 120000');
+    // Performance tuning
+    db.pragma('synchronous = NORMAL');     // Safe with WAL mode
+    db.pragma('cache_size = -64000');      // 64 MB page cache
+    db.pragma('temp_store = MEMORY');      // Temp tables in memory
+    db.pragma('mmap_size = 268435456');    // 256 MB memory-mapped I/O
 
     // Run schema initialization
     const schemaPath = path.join(__dirname, 'schema.sql');
@@ -66,6 +71,11 @@ export class DatabaseConnection {
     // Wait up to 2 minutes if database is locked by another process
     // (indexing operations can hold locks for extended periods)
     db.pragma('busy_timeout = 120000');
+    // Performance tuning
+    db.pragma('synchronous = NORMAL');
+    db.pragma('cache_size = -64000');
+    db.pragma('temp_store = MEMORY');
+    db.pragma('mmap_size = 268435456');
 
     // Check and run migrations if needed
     const conn = new DatabaseConnection(db, dbPath);

+ 11 - 12
src/db/migrations.ts

@@ -9,7 +9,7 @@ import Database from 'better-sqlite3';
 /**
  * Current schema version
  */
-export const CURRENT_SCHEMA_VERSION = 1;
+export const CURRENT_SCHEMA_VERSION = 2;
 
 /**
  * Migration definition
@@ -27,17 +27,16 @@ interface Migration {
  * Future migrations go here.
  */
 const migrations: Migration[] = [
-  // Example migration for version 2 (when needed):
-  // {
-  //   version: 2,
-  //   description: 'Add support for module resolution',
-  //   up: (db) => {
-  //     db.exec(`
-  //       ALTER TABLE nodes ADD COLUMN module_path TEXT;
-  //       CREATE INDEX idx_nodes_module_path ON nodes(module_path);
-  //     `);
-  //   },
-  // },
+  {
+    version: 2,
+    description: 'Add filePath and language to unresolved_refs for performance',
+    up: (db) => {
+      db.exec(`
+        ALTER TABLE unresolved_refs ADD COLUMN file_path TEXT;
+        ALTER TABLE unresolved_refs ADD COLUMN language TEXT;
+      `);
+    },
+  },
 ];
 
 /**

+ 47 - 2
src/db/queries.ts

@@ -73,6 +73,8 @@ interface UnresolvedRefRow {
   reference_kind: string;
   line: number;
   col: number;
+  file_path: string | null;
+  language: string | null;
   candidates: string | null;
 }
 
@@ -422,6 +424,14 @@ export class QueryBuilder {
     return rows.map(rowToNode);
   }
 
+  /**
+   * Get all nodes in the database
+   */
+  getAllNodes(): Node[] {
+    const rows = this.db.prepare('SELECT * FROM nodes').all() as NodeRow[];
+    return rows.map(rowToNode);
+  }
+
   /**
    * Search nodes by name using FTS with fallback to LIKE for better matching
    *
@@ -778,8 +788,8 @@ export class QueryBuilder {
   insertUnresolvedRef(ref: UnresolvedReference): void {
     if (!this.stmts.insertUnresolved) {
       this.stmts.insertUnresolved = this.db.prepare(`
-        INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, candidates)
-        VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates)
+        INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, candidates)
+        VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @filePath, @language, @candidates)
       `);
     }
 
@@ -789,10 +799,41 @@ export class QueryBuilder {
       referenceKind: ref.referenceKind,
       line: ref.line,
       col: ref.column,
+      filePath: ref.filePath ?? null,
+      language: ref.language ?? null,
       candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
     });
   }
 
+  /**
+   * Insert multiple unresolved references in a single transaction
+   */
+  insertUnresolvedRefsBatch(refs: UnresolvedReference[]): void {
+    if (refs.length === 0) return;
+
+    if (!this.stmts.insertUnresolved) {
+      this.stmts.insertUnresolved = this.db.prepare(`
+        INSERT INTO unresolved_refs (from_node_id, reference_name, reference_kind, line, col, file_path, language, candidates)
+        VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @filePath, @language, @candidates)
+      `);
+    }
+
+    this.db.transaction(() => {
+      for (const ref of refs) {
+        this.stmts.insertUnresolved!.run({
+          fromNodeId: ref.fromNodeId,
+          referenceName: ref.referenceName,
+          referenceKind: ref.referenceKind,
+          line: ref.line,
+          col: ref.column,
+          filePath: ref.filePath ?? null,
+          language: ref.language ?? null,
+          candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
+        });
+      }
+    })();
+  }
+
   /**
    * Delete unresolved references from a node
    */
@@ -821,6 +862,8 @@ export class QueryBuilder {
       referenceKind: row.reference_kind as EdgeKind,
       line: row.line,
       column: row.col,
+      filePath: row.file_path ?? undefined,
+      language: (row.language as Language) ?? undefined,
       candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
     }));
   }
@@ -836,6 +879,8 @@ export class QueryBuilder {
       referenceKind: row.reference_kind as EdgeKind,
       line: row.line,
       column: row.col,
+      filePath: row.file_path ?? undefined,
+      language: (row.language as Language) ?? undefined,
       candidates: row.candidates ? safeJsonParse<string[]>(row.candidates, []) : undefined,
     }));
   }

+ 4 - 0
src/db/schema.sql

@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS schema_versions (
 -- Insert initial version
 INSERT INTO schema_versions (version, applied_at, description)
 VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema');
+INSERT INTO schema_versions (version, applied_at, description)
+VALUES (2, strftime('%s', 'now') * 1000, 'Add filePath and language to unresolved_refs');
 
 -- =============================================================================
 -- Core Tables
@@ -73,6 +75,8 @@ CREATE TABLE IF NOT EXISTS unresolved_refs (
     reference_kind TEXT NOT NULL,
     line INTEGER NOT NULL,
     col INTEGER NOT NULL,
+    file_path TEXT,
+    language TEXT,
     candidates TEXT, -- JSON array
     FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
 );

+ 131 - 75
src/extraction/grammars.ts

@@ -1,73 +1,88 @@
 /**
  * Grammar Loading and Caching
  *
- * Manages tree-sitter language grammars.
+ * Uses lazy per-language loading so one missing native grammar does not
+ * break extraction for all other languages.
  */
 
 import Parser from 'tree-sitter';
 import { Language } from '../types';
 
-// Grammar module imports — wrapped in tryRequire so a missing native binding
-// (e.g. tree-sitter-kotlin on Windows) degrades gracefully instead of crashing.
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-function tryRequire(id: string, prop?: string): unknown | null {
-  try {
-    // eslint-disable-next-line @typescript-eslint/no-require-imports
-    const mod = require(id);
-    return prop ? mod[prop] : mod;
-  } catch {
-    console.warn(`[CodeGraph] Failed to load ${id} — ${prop ?? id} parsing will be unavailable on this platform.`);
-    return null;
-  }
-}
-
-const TypeScript = tryRequire('tree-sitter-typescript', 'typescript');
-const TSX = tryRequire('tree-sitter-typescript', 'tsx');
-const JavaScript = tryRequire('tree-sitter-javascript');
-const Python = tryRequire('tree-sitter-python');
-const Go = tryRequire('tree-sitter-go');
-const Rust = tryRequire('tree-sitter-rust');
-const Java = tryRequire('tree-sitter-java');
-const C = tryRequire('tree-sitter-c');
-const Cpp = tryRequire('tree-sitter-cpp');
-const CSharp = tryRequire('tree-sitter-c-sharp');
-const PHP = tryRequire('tree-sitter-php', 'php');
-const Ruby = tryRequire('tree-sitter-ruby');
-const Swift = tryRequire('tree-sitter-swift');
-const Kotlin = tryRequire('tree-sitter-kotlin');
-const Dart = tryRequire('@sengac/tree-sitter-dart');
-// Note: tree-sitter-liquid has ABI compatibility issues with tree-sitter 0.22+
-// Liquid extraction is handled separately via regex in tree-sitter.ts
+type GrammarLoader = () => unknown;
+type GrammarLanguage = Exclude<Language, 'liquid' | 'unknown'>;
 
 /**
- * Mapping of Language to tree-sitter grammar.
- * Parsers that failed to load are excluded.
+ * Lazy grammar loaders — each language's native binding is only loaded
+ * on first use, so a failure in one grammar doesn't affect others.
  */
-const GRAMMAR_MAP: Record<string, unknown> = {};
-
-const grammarEntries: [string, unknown][] = [
-  ['typescript', TypeScript],
-  ['tsx', TSX],
-  ['javascript', JavaScript],
-  ['jsx', JavaScript], // JSX uses the JavaScript grammar
-  ['python', Python],
-  ['go', Go],
-  ['rust', Rust],
-  ['java', Java],
-  ['c', C],
-  ['cpp', Cpp],
-  ['csharp', CSharp],
-  ['php', PHP],
-  ['ruby', Ruby],
-  ['swift', Swift],
-  ['kotlin', Kotlin],
-  ['dart', Dart],
-  // liquid: uses custom regex-based extraction, not tree-sitter
-];
-
-for (const [lang, grammar] of grammarEntries) {
-  if (grammar) GRAMMAR_MAP[lang] = grammar;
-}
+const grammarLoaders: Record<GrammarLanguage, GrammarLoader> = {
+  typescript: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-typescript').typescript;
+  },
+  tsx: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-typescript').tsx;
+  },
+  javascript: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-javascript');
+  },
+  jsx: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-javascript');
+  },
+  python: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-python');
+  },
+  go: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-go');
+  },
+  rust: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-rust');
+  },
+  java: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-java');
+  },
+  c: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-c');
+  },
+  cpp: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-cpp');
+  },
+  csharp: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-c-sharp');
+  },
+  php: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-php').php;
+  },
+  ruby: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-ruby');
+  },
+  swift: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-swift');
+  },
+  kotlin: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('tree-sitter-kotlin');
+  },
+  dart: () => {
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    return require('@sengac/tree-sitter-dart');
+  },
+  // Note: tree-sitter-liquid has ABI compatibility issues with tree-sitter 0.22+
+  // Liquid extraction is handled separately via regex in tree-sitter.ts
+};
 
 /**
  * File extension to Language mapping
@@ -103,30 +118,59 @@ export const EXTENSION_MAP: Record<string, Language> = {
 };
 
 /**
- * Cache for initialized parsers
+ * Caches for loaded grammars and parsers
  */
 const parserCache = new Map<Language, Parser>();
+const grammarCache = new Map<Language, unknown | null>();
+const unavailableGrammarErrors = new Map<Language, string>();
+
+/**
+ * Load a grammar on demand, caching the result.
+ * Returns null if the grammar is not available on this platform.
+ */
+function loadGrammar(language: Language): unknown | null {
+  if (grammarCache.has(language)) {
+    return grammarCache.get(language) ?? null;
+  }
+
+  const loader = grammarLoaders[language as GrammarLanguage];
+  if (!loader) {
+    grammarCache.set(language, null);
+    return null;
+  }
+
+  try {
+    const grammar = loader();
+    if (!grammar) {
+      throw new Error(`Grammar loader returned empty value for ${language}`);
+    }
+    grammarCache.set(language, grammar);
+    return grammar;
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    console.warn(`[CodeGraph] Failed to load ${language} grammar — parsing will be unavailable: ${message}`);
+    unavailableGrammarErrors.set(language, message);
+    grammarCache.set(language, null);
+    return null;
+  }
+}
 
 /**
  * Get a parser for the specified language
  */
 export function getParser(language: Language): Parser | null {
-  // Check cache first
   if (parserCache.has(language)) {
     return parserCache.get(language)!;
   }
 
-  // Get grammar for language
-  const grammar = GRAMMAR_MAP[language];
+  const grammar = loadGrammar(language);
   if (!grammar) {
     return null;
   }
 
-  // Create and cache parser
   const parser = new Parser();
   parser.setLanguage(grammar as Parameters<typeof parser.setLanguage>[0]);
   parserCache.set(language, parser);
-
   return parser;
 }
 
@@ -139,29 +183,41 @@ export function detectLanguage(filePath: string): Language {
 }
 
 /**
- * Check if a language is supported
+ * Check if a language is supported by currently available parsers.
  */
 export function isLanguageSupported(language: Language): boolean {
-  // Liquid uses custom regex-based extraction, not tree-sitter
-  if (language === 'liquid') return true;
-  return language !== 'unknown' && language in GRAMMAR_MAP;
+  if (language === 'liquid') return true; // custom regex extractor
+  if (language === 'unknown') return false;
+  return loadGrammar(language) !== null;
 }
 
 /**
- * Get all supported languages
+ * Get all currently supported languages.
  */
 export function getSupportedLanguages(): Language[] {
-  const languages = Object.keys(GRAMMAR_MAP) as Language[];
-  // Add Liquid which uses custom extraction
-  languages.push('liquid');
-  return languages;
+  const available = (Object.keys(grammarLoaders) as GrammarLanguage[])
+    .filter((language) => loadGrammar(language) !== null);
+  return [...available, 'liquid'];
 }
 
 /**
- * Clear the parser cache (useful for testing)
+ * Clear parser/grammar caches (useful for testing)
  */
 export function clearParserCache(): void {
   parserCache.clear();
+  grammarCache.clear();
+  unavailableGrammarErrors.clear();
+}
+
+/**
+ * Report grammars that failed to load.
+ */
+export function getUnavailableGrammarErrors(): Partial<Record<Language, string>> {
+  const out: Partial<Record<Language, string>> = {};
+  for (const [language, message] of unavailableGrammarErrors.entries()) {
+    out[language] = message;
+  }
+  return out;
 }
 
 /**

+ 11 - 5
src/extraction/index.ts

@@ -5,6 +5,7 @@
  */
 
 import * as fs from 'fs';
+import * as fsp from 'fs/promises';
 import * as path from 'path';
 import * as crypto from 'crypto';
 import {
@@ -399,8 +400,8 @@ export class ExtractionOrchestrator {
     let content: string;
     let stats: fs.Stats;
     try {
-      stats = fs.statSync(fullPath);
-      content = fs.readFileSync(fullPath, 'utf-8');
+      stats = await fsp.stat(fullPath);
+      content = await fsp.readFile(fullPath, 'utf-8');
     } catch (error) {
       captureException(error, { operation: 'extract-file', filePath: fullPath });
       return {
@@ -489,9 +490,14 @@ export class ExtractionOrchestrator {
       this.queries.insertEdges(result.edges);
     }
 
-    // Insert unresolved references
-    for (const ref of result.unresolvedReferences) {
-      this.queries.insertUnresolvedRef(ref);
+    // Insert unresolved references in batch with denormalized filePath/language
+    if (result.unresolvedReferences.length > 0) {
+      const refsWithContext = result.unresolvedReferences.map((ref) => ({
+        ...ref,
+        filePath: ref.filePath ?? filePath,
+        language: ref.language ?? language,
+      }));
+      this.queries.insertUnresolvedRefsBatch(refsWithContext);
     }
 
     // Insert file record

+ 3 - 2
src/extraction/tree-sitter.ts

@@ -1294,9 +1294,10 @@ export class TreeSitterExtractor {
 
           if (nameNode) {
             const name = getNodeText(nameNode, this.source);
-            // Skip if it looks like a function (arrow function or function expression)
+            // Arrow functions / function expressions: extract as function instead of variable
             if (valueNode && (valueNode.type === 'arrow_function' || valueNode.type === 'function_expression')) {
-              continue; // Already handled by function extraction
+              this.extractFunction(valueNode);
+              continue;
             }
 
             // Capture first 100 chars of initializer for context (stored in signature for searchability)

+ 6 - 2
src/graph/traversal.ts

@@ -85,7 +85,9 @@ export class GraphTraverser {
       const adjacentEdges = this.getAdjacentEdges(node.id, opts.direction, opts.edgeKinds);
 
       for (const adjEdge of adjacentEdges) {
-        const nextNodeId = opts.direction === 'incoming' ? adjEdge.source : adjEdge.target;
+        // Determine next node: for 'both' direction, edges can be either
+        // incoming or outgoing, so pick whichever end is not the current node
+        const nextNodeId = adjEdge.source === node.id ? adjEdge.target : adjEdge.source;
 
         if (visited.has(nextNodeId)) {
           continue;
@@ -169,7 +171,9 @@ export class GraphTraverser {
     const adjacentEdges = this.getAdjacentEdges(node.id, opts.direction, opts.edgeKinds);
 
     for (const edge of adjacentEdges) {
-      const nextNodeId = opts.direction === 'incoming' ? edge.source : edge.target;
+      // Determine next node: for 'both' direction, edges can be either
+      // incoming or outgoing, so pick whichever end is not the current node
+      const nextNodeId = edge.source === node.id ? edge.target : edge.source;
 
       if (visited.has(nextNodeId)) {
         continue;

+ 76 - 34
src/mcp/tools.ts

@@ -364,7 +364,7 @@ export class ToolHandler {
     }
 
     const formatted = this.formatSearchResults(results);
-    return this.textResult(formatted);
+    return this.textResult(this.truncateOutput(formatted));
   }
 
   /**
@@ -439,23 +439,20 @@ export class ToolHandler {
     const symbol = args.symbol as string;
     const limit = clamp((args.limit as number) || 20, 1, 100);
 
-    // First find the node by name
-    const results = cg.searchNodes(symbol, { limit: 1 });
-    if (results.length === 0 || !results[0]) {
+    const match = this.findSymbol(cg, symbol);
+    if (!match) {
       return this.textResult(`Symbol "${symbol}" not found in the codebase`);
     }
 
-    const node = results[0].node;
-    const callers = cg.getCallers(node.id);
+    const callers = cg.getCallers(match.node.id);
 
     if (callers.length === 0) {
-      return this.textResult(`No callers found for "${symbol}"`);
+      return this.textResult(`No callers found for "${symbol}"${match.note}`);
     }
 
-    // Extract just the nodes from the { node, edge } tuples
     const callerNodes = callers.slice(0, limit).map(c => c.node);
-    const formatted = this.formatNodeList(callerNodes, `Callers of ${symbol}`);
-    return this.textResult(formatted);
+    const formatted = this.formatNodeList(callerNodes, `Callers of ${symbol}`) + match.note;
+    return this.textResult(this.truncateOutput(formatted));
   }
 
   /**
@@ -466,23 +463,20 @@ export class ToolHandler {
     const symbol = args.symbol as string;
     const limit = clamp((args.limit as number) || 20, 1, 100);
 
-    // First find the node by name
-    const results = cg.searchNodes(symbol, { limit: 1 });
-    if (results.length === 0 || !results[0]) {
+    const match = this.findSymbol(cg, symbol);
+    if (!match) {
       return this.textResult(`Symbol "${symbol}" not found in the codebase`);
     }
 
-    const node = results[0].node;
-    const callees = cg.getCallees(node.id);
+    const callees = cg.getCallees(match.node.id);
 
     if (callees.length === 0) {
-      return this.textResult(`No callees found for "${symbol}"`);
+      return this.textResult(`No callees found for "${symbol}"${match.note}`);
     }
 
-    // Extract just the nodes from the { node, edge } tuples
     const calleeNodes = callees.slice(0, limit).map(c => c.node);
-    const formatted = this.formatNodeList(calleeNodes, `Callees of ${symbol}`);
-    return this.textResult(formatted);
+    const formatted = this.formatNodeList(calleeNodes, `Callees of ${symbol}`) + match.note;
+    return this.textResult(this.truncateOutput(formatted));
   }
 
   /**
@@ -493,17 +487,15 @@ export class ToolHandler {
     const symbol = args.symbol as string;
     const depth = clamp((args.depth as number) || 2, 1, 10);
 
-    // First find the node by name
-    const results = cg.searchNodes(symbol, { limit: 1 });
-    if (results.length === 0 || !results[0]) {
+    const match = this.findSymbol(cg, symbol);
+    if (!match) {
       return this.textResult(`Symbol "${symbol}" not found in the codebase`);
     }
 
-    const node = results[0].node;
-    const impact = cg.getImpactRadius(node.id, depth);
+    const impact = cg.getImpactRadius(match.node.id, depth);
 
-    const formatted = this.formatImpact(symbol, impact);
-    return this.textResult(formatted);
+    const formatted = this.formatImpact(symbol, impact) + match.note;
+    return this.textResult(this.truncateOutput(formatted));
   }
 
   /**
@@ -515,21 +507,19 @@ export class ToolHandler {
     // Default to false to minimize context usage
     const includeCode = args.includeCode === true;
 
-    // Find the node by name
-    const results = cg.searchNodes(symbol, { limit: 1 });
-    if (results.length === 0 || !results[0]) {
+    const match = this.findSymbol(cg, symbol);
+    if (!match) {
       return this.textResult(`Symbol "${symbol}" not found in the codebase`);
     }
 
-    const node = results[0].node;
     let code: string | null = null;
 
     if (includeCode) {
-      code = await cg.getCode(node.id);
+      code = await cg.getCode(match.node.id);
     }
 
-    const formatted = this.formatNodeDetails(node, code);
-    return this.textResult(formatted);
+    const formatted = this.formatNodeDetails(match.node, code) + match.note;
+    return this.textResult(this.truncateOutput(formatted));
   }
 
   /**
@@ -614,7 +604,7 @@ export class ToolHandler {
         break;
     }
 
-    return this.textResult(output);
+    return this.textResult(this.truncateOutput(output));
   }
 
   /**
@@ -754,6 +744,58 @@ export class ToolHandler {
     return lines.join('\n');
   }
 
+  // =========================================================================
+  // Symbol resolution helpers
+  // =========================================================================
+
+  /**
+   * Find a symbol by name, handling disambiguation when multiple matches exist.
+   * Returns the best match and a note about alternatives if any.
+   */
+  private findSymbol(cg: CodeGraph, symbol: string): { node: Node; note: string } | null {
+    const results = cg.searchNodes(symbol, { limit: 10 });
+
+    if (results.length === 0 || !results[0]) {
+      return null;
+    }
+
+    // If only one result, or first is an exact name match, use it directly
+    const exactMatches = results.filter(r => r.node.name === symbol);
+
+    if (exactMatches.length === 1) {
+      return { node: exactMatches[0]!.node, note: '' };
+    }
+
+    if (exactMatches.length > 1) {
+      // Multiple exact matches - pick first, note the others
+      const picked = exactMatches[0]!.node;
+      const others = exactMatches.slice(1).map(r =>
+        `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`
+      );
+      const note = `\n\n> **Note:** ${exactMatches.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
+      return { node: picked, note };
+    }
+
+    // No exact match, use best fuzzy match
+    return { node: results[0]!.node, note: '' };
+  }
+
+  /**
+   * Maximum output length to prevent context bloat (characters)
+   */
+  private readonly MAX_OUTPUT_LENGTH = 15000;
+
+  /**
+   * Truncate output if it exceeds the maximum length
+   */
+  private truncateOutput(text: string): string {
+    if (text.length <= this.MAX_OUTPUT_LENGTH) return text;
+    const truncated = text.slice(0, this.MAX_OUTPUT_LENGTH);
+    const lastNewline = truncated.lastIndexOf('\n');
+    const cutPoint = lastNewline > this.MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : this.MAX_OUTPUT_LENGTH;
+    return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
+  }
+
   // =========================================================================
   // Formatting helpers (compact by default to reduce context usage)
   // =========================================================================

+ 74 - 9
src/resolution/index.ts

@@ -36,6 +36,10 @@ export class ReferenceResolver {
   private frameworks: FrameworkResolver[] = [];
   private nodeCache: Map<string, Node[]> = new Map();
   private fileCache: Map<string, string | null> = new Map();
+  private nameCache: Map<string, Node[]> = new Map();
+  private qualifiedNameCache: Map<string, Node[]> = new Map();
+  private nodeByIdCache: Map<string, Node> = new Map();
+  private cachesWarmed = false;
 
   constructor(projectRoot: string, queries: QueryBuilder) {
     this.projectRoot = projectRoot;
@@ -51,12 +55,48 @@ export class ReferenceResolver {
     this.clearCaches();
   }
 
+  /**
+   * Pre-load all nodes into memory maps for fast lookup during resolution.
+   * This eliminates repeated SQLite queries and provides the core speedup.
+   */
+  warmCaches(): void {
+    if (this.cachesWarmed) return;
+
+    const allNodes = this.queries.getAllNodes();
+    for (const node of allNodes) {
+      // Index by name
+      const byName = this.nameCache.get(node.name);
+      if (byName) {
+        byName.push(node);
+      } else {
+        this.nameCache.set(node.name, [node]);
+      }
+
+      // Index by qualified name
+      const byQName = this.qualifiedNameCache.get(node.qualifiedName);
+      if (byQName) {
+        byQName.push(node);
+      } else {
+        this.qualifiedNameCache.set(node.qualifiedName, [node]);
+      }
+
+      // Index by ID
+      this.nodeByIdCache.set(node.id, node);
+    }
+
+    this.cachesWarmed = true;
+  }
+
   /**
    * Clear internal caches
    */
   clearCaches(): void {
     this.nodeCache.clear();
     this.fileCache.clear();
+    this.nameCache.clear();
+    this.qualifiedNameCache.clear();
+    this.nodeByIdCache.clear();
+    this.cachesWarmed = false;
   }
 
   /**
@@ -72,11 +112,18 @@ export class ReferenceResolver {
       },
 
       getNodesByName: (name: string) => {
+        // Use warm cache if available, otherwise fall back to search
+        if (this.cachesWarmed) {
+          return this.nameCache.get(name) ?? [];
+        }
         return this.queries.searchNodes(name, { limit: 100 }).map((r) => r.node);
       },
 
       getNodesByQualifiedName: (qualifiedName: string) => {
-        // Search for exact qualified name match
+        // Use warm cache if available, otherwise fall back to search + filter
+        if (this.cachesWarmed) {
+          return this.qualifiedNameCache.get(qualifiedName) ?? [];
+        }
         return this.queries
           .searchNodes(qualifiedName, { limit: 50 })
           .filter((r) => r.node.qualifiedName === qualifiedName)
@@ -131,19 +178,22 @@ export class ReferenceResolver {
     unresolvedRefs: UnresolvedReference[],
     onProgress?: (current: number, total: number) => void
   ): ResolutionResult {
+    // Pre-load all nodes into memory for fast lookups
+    this.warmCaches();
+
     const resolved: ResolvedRef[] = [];
     const unresolved: UnresolvedRef[] = [];
     const byMethod: Record<string, number> = {};
 
-    // Convert to our internal format
+    // Convert to our internal format, using denormalized fields when available
     const refs: UnresolvedRef[] = unresolvedRefs.map((ref) => ({
       fromNodeId: ref.fromNodeId,
       referenceName: ref.referenceName,
       referenceKind: ref.referenceKind,
       line: ref.line,
       column: ref.column,
-      filePath: this.getFilePathFromNodeId(ref.fromNodeId),
-      language: this.getLanguageFromNodeId(ref.fromNodeId),
+      filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId),
+      language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId),
     }));
 
     const total = refs.length;
@@ -196,27 +246,36 @@ export class ReferenceResolver {
       return null;
     }
 
-    // Strategy 1: Try framework-specific resolution first
+    const candidates: ResolvedRef[] = [];
+
+    // Strategy 1: Try framework-specific resolution
     for (const framework of this.frameworks) {
       const result = framework.resolve(ref, this.context);
       if (result) {
-        return result;
+        if (result.confidence >= 0.9) return result; // High confidence, return immediately
+        candidates.push(result);
       }
     }
 
     // Strategy 2: Try import-based resolution
     const importResult = resolveViaImport(ref, this.context);
     if (importResult) {
-      return importResult;
+      if (importResult.confidence >= 0.9) return importResult;
+      candidates.push(importResult);
     }
 
     // Strategy 3: Try name matching
     const nameResult = matchReference(ref, this.context);
     if (nameResult) {
-      return nameResult;
+      candidates.push(nameResult);
     }
 
-    return null;
+    if (candidates.length === 0) return null;
+
+    // Return highest confidence candidate
+    return candidates.reduce((best, curr) =>
+      curr.confidence > best.confidence ? curr : best
+    );
   }
 
   /**
@@ -311,6 +370,9 @@ export class ReferenceResolver {
    * Get file path from node ID
    */
   private getFilePathFromNodeId(nodeId: string): string {
+    // Check warm cache first
+    const cached = this.nodeByIdCache.get(nodeId);
+    if (cached) return cached.filePath;
     const node = this.queries.getNodeById(nodeId);
     return node?.filePath || '';
   }
@@ -319,6 +381,9 @@ export class ReferenceResolver {
    * Get language from node ID
    */
   private getLanguageFromNodeId(nodeId: string): UnresolvedRef['language'] {
+    // Check warm cache first
+    const cached = this.nodeByIdCache.get(nodeId);
+    if (cached) return cached.language;
     const node = this.queries.getNodeById(nodeId);
     return node?.language || 'unknown';
   }

+ 6 - 0
src/types.ts

@@ -257,6 +257,12 @@ export interface UnresolvedReference {
   line: number;
   column: number;
 
+  /** File path where reference occurs (denormalized for performance) */
+  filePath?: string;
+
+  /** Language of the source file (denormalized for performance) */
+  language?: Language;
+
   /** Possible qualified names it might resolve to */
   candidates?: string[];
 }

+ 1 - 1
src/vectors/embedder.ts

@@ -296,7 +296,7 @@ export class TextEmbedder {
     if (data && typeof data === 'object' && 'length' in data) {
       // Handle TypedArray-like objects
       const arr = data as ArrayLike<number>;
-      return new Float32Array(arr.length);
+      return Float32Array.from(Array.from(arr));
     }
     throw new Error('Unsupported data format for embedding');
   }

+ 8 - 7
src/vectors/search.ts

@@ -324,13 +324,14 @@ export class VectorSearchManager {
       const rows = this.db
         .prepare(
           `
-          SELECT
-            vss_map.node_id,
-            vss_vectors.distance
-          FROM vss_vectors
-          JOIN vss_map ON vss_map.rowid = vss_vectors.rowid
-          WHERE vss_search(vss_vectors.embedding, ?)
-          LIMIT ${safeLimit}
+          SELECT m.node_id, v.distance
+          FROM (
+            SELECT rowid, distance
+            FROM vss_vectors
+            WHERE vss_search(embedding, ?)
+            LIMIT ${safeLimit}
+          ) v
+          JOIN vss_map m ON m.rowid = v.rowid
         `
         )
         .all(vectorJson) as Array<{ node_id: string; distance: number }>;