Merge pull request #20 from colbymchenry/pr-19
Port quality improvements from PR #15
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user