Merge pull request #20 from colbymchenry/pr-19

Port quality improvements from PR #15
This commit is contained in:
Colby Mchenry
2026-02-10 00:23:02 -06:00
committed by GitHub
16 changed files with 1168 additions and 165 deletions
+715
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }>;