Przeglądaj źródła

Add tests for PR #19 improvements (Phases 3-5)

Covers arrow function extraction, best-candidate resolution, graph
traversal direction fix, MCP symbol disambiguation, output truncation,
CLI uninit command, and more. Tests requiring better-sqlite3 native
bindings are conditionally skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Colby McHenry 4 miesięcy temu
rodzic
commit
c8c7785626
1 zmienionych plików z 715 dodań i 0 usunięć
  1. 715 0
      __tests__/pr19-improvements.test.ts

+ 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);
+  });
+});