From c8c7785626be6da7afefc8c2d3656256434b1161 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Tue, 10 Feb 2026 00:19:48 -0600 Subject: [PATCH] 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 --- __tests__/pr19-improvements.test.ts | 715 ++++++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 __tests__/pr19-improvements.test.ts diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts new file mode 100644 index 0000000..dcaa564 --- /dev/null +++ b/__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) + .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); + }); +});