| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719 |
- /**
- * 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, beforeAll, 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,
- initGrammars,
- loadAllGrammars,
- } from '../src/extraction/grammars';
- beforeAll(async () => {
- await initGrammars();
- await loadAllGrammars();
- });
- // 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 the node:sqlite backend is available (Node >= 22.5)
- function hasSqliteBindings(): boolean {
- try {
- const { DatabaseSync } = require('node:sqlite');
- const db = new DatabaseSync(':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(4);
- });
- 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 dbPath = path.join(testDir, 'codegraph.db');
- const db = DatabaseConnection.initialize(dbPath);
- const queries = new QueryBuilder(db.getDb());
- // 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 dbPath = path.join(testDir, 'codegraph.db');
- const db = DatabaseConnection.initialize(dbPath);
- const queries = new QueryBuilder(db.getDb());
- // 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 dbPath = path.join(testDir, 'codegraph.db');
- const db = DatabaseConnection.initialize(dbPath);
- const rawDb = db.getDb();
- // 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 dbPath = path.join(testDir, 'codegraph.db');
- const db = DatabaseConnection.initialize(dbPath);
- const queries = new QueryBuilder(db.getDb());
- // 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 findSymbolMatches and truncateOutput as private methods', async () => {
- const { ToolHandler } = await import('../src/mcp/tools');
- const proto = ToolHandler.prototype;
- expect(typeof (proto as any).findSymbolMatches).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 findSymbolMatches = (handler as any).findSymbolMatches.bind(handler);
- const matches = findSymbolMatches(cg, 'getValue');
- // Exact-name match wins — a single result, not the partial getValueFromCache.
- expect(matches.length).toBe(1);
- expect(matches[0].name).toBe('getValue');
- handler.closeAll();
- cg.destroy();
- cleanupTempDir(tmpDir);
- });
- it.skipIf(!HAS_SQLITE)('should return all definitions 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 findSymbolMatches = (handler as any).findSymbolMatches.bind(handler);
- // Both same-named definitions are returned (no longer one + a dead-end
- // note) so codegraph_node can hand back every overload and the agent never
- // Reads to find the one it wanted.
- const matches = findSymbolMatches(cg, 'handle');
- expect(matches.length).toBe(2);
- expect(matches.every((n: any) => n.name === 'handle')).toBe(true);
- handler.closeAll();
- cg.destroy();
- cleanupTempDir(tmpDir);
- });
- it.skipIf(!HAS_SQLITE)('should return no matches 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 findSymbolMatches = (handler as any).findSymbolMatches.bind(handler);
- const matches = findSymbolMatches(cg, 'nonExistentSymbol');
- expect(matches.length).toBe(0);
- 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 WASM Setup', () => {
- it('should use web-tree-sitter and tree-sitter-wasms in dependencies', () => {
- const pkgPath = path.join(__dirname, '..', 'package.json');
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
- expect(pkg.dependencies['web-tree-sitter']).toBeDefined();
- expect(pkg.dependencies['tree-sitter-wasms']).toBeDefined();
- });
- it('should not have native tree-sitter in dependencies', () => {
- const pkgPath = path.join(__dirname, '..', 'package.json');
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
- expect(pkg.dependencies['tree-sitter']).toBeUndefined();
- expect(pkg.overrides).toBeUndefined();
- });
- });
- // =============================================================================
- // 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);
- });
- });
|