Skip to content
This repository was archived by the owner on Jan 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions src/core/indexer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { scip } from '@sourcegraph/scip-typescript/dist/src/scip.js';
import { LanguageRegistry } from '../languages/registry.js';
import type { LanguageAdapter } from '../languages/base.js';
import { StructuredLogger } from './logger.js';
Expand Down Expand Up @@ -69,8 +70,31 @@ export class ScipIndexer {
await this.ensureIndexDir();
await this.backupExistingIndex();

for (const adapter of adapters) {
await this.runAdapter(adapter, options);
// If only one adapter, write directly to the output path
if (adapters.length === 1) {
await this.runAdapter(adapters[0], options, this.indexPath);
} else {
// Multiple adapters: write to temp files, then merge
const tempPaths: string[] = [];
try {
for (let i = 0; i < adapters.length; i++) {
const tempPath = `${this.indexPath}.${adapters[i].name}.tmp`;
tempPaths.push(tempPath);
await this.runAdapter(adapters[i], options, tempPath);
}

// Merge all indexes
await this.mergeIndexes(tempPaths, this.indexPath);
} finally {
// Clean up temp files
for (const tempPath of tempPaths) {
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
}
}
}

this.logger.log({ source: 'indexer', action: 'generate_index_complete' });
Expand All @@ -85,6 +109,61 @@ export class ScipIndexer {
}
}

private async mergeIndexes(inputPaths: string[], outputPath: string): Promise<void> {
const allDocuments: any[] = [];
const seenPaths = new Set<string>();
let metadata: any = null;
const externalSymbols: any[] = [];

for (const inputPath of inputPaths) {
try {
const data = await fs.readFile(inputPath);
const index = scip.Index.deserializeBinary(data);

// Keep first metadata
if (!metadata && index.metadata) {
metadata = index.metadata;
}

// Merge documents (avoid duplicates by path)
for (const doc of index.documents || []) {
const path = doc.relative_path || '';
if (!seenPaths.has(path)) {
seenPaths.add(path);
allDocuments.push(doc);
}
}

// Merge external symbols
for (const sym of index.external_symbols || []) {
externalSymbols.push(sym);
}
} catch (error) {
// Skip indexes that can't be read
this.logger.log({
source: 'indexer',
action: 'merge_skip',
path: inputPath,
level: 'warning',
message: error instanceof Error ? error.message : String(error),
});
}
}

const mergedIndex = new scip.Index({
metadata,
documents: allDocuments,
external_symbols: externalSymbols,
});

await fs.writeFile(outputPath, Buffer.from(mergedIndex.serializeBinary()));
this.logger.log({
source: 'indexer',
action: 'merge_complete',
documents: allDocuments.length,
});
}

async needsReindex(): Promise<boolean> {
// If there is no index at all, we clearly need one.
const exists = await this.indexExists();
Expand Down Expand Up @@ -149,7 +228,7 @@ export class ScipIndexer {
return filename.slice(lastDot);
}

private async runAdapter(adapter: LanguageAdapter, options: GenerateOptions) {
private async runAdapter(adapter: LanguageAdapter, options: GenerateOptions, outputPath: string) {
options.onProgress?.(`Detected ${adapter.name} project`);
this.logger.log({ source: 'indexer', action: 'adapter_start', adapter: adapter.name });

Expand All @@ -164,7 +243,7 @@ export class ScipIndexer {
try {
await adapter.generateIndex({
projectRoot: this.projectRoot,
outputPath: this.indexPath,
outputPath,
incremental: options.incremental,
signal: options.signal,
onProgress: options.onProgress,
Expand Down
13 changes: 10 additions & 3 deletions src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class ScipQuery {

for (const document of documents) {
const relativePath = document?.relative_path ?? '';
if (!relativePath.endsWith('.py')) continue;
if (!this.isSupportedFile(relativePath)) continue;

const moduleName = this.pathToModuleName(relativePath);
let moduleNode = modules.get(moduleName);
Expand Down Expand Up @@ -309,10 +309,17 @@ export class ScipQuery {
return Array.from(modules.values());
}

private isSupportedFile(path: string): boolean {
const supportedExtensions = ['.py', '.ts', '.tsx', '.js', '.jsx', '.vue', '.mts', '.cts', '.mjs', '.cjs'];
return supportedExtensions.some(ext => path.endsWith(ext));
}

private pathToModuleName(path: string): string {
const withoutExt = path.replace(/\.py$/, '');
// Remove common extensions for Python, TypeScript, JavaScript, Vue
const withoutExt = path.replace(/\.(py|ts|tsx|js|jsx|vue|mts|cts|mjs|cjs)$/, '');
const parts = withoutExt.split(/[\\/]+/);
if (parts[0] === 'src') {
// Remove common source directory prefixes
if (parts[0] === 'src' || parts[0] === 'lib') {
parts.shift();
}
return parts.join('.');
Expand Down
27 changes: 21 additions & 6 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,30 @@ export const createScipTools: CustomToolFactory = (pi) => {

await ensureIndex(toolName, signal, emitProgress);

const tree = await query.buildProjectTree();
logger.log({ source: 'tool', action: 'query_complete', tool: toolName, modules: tree.length });
const runQuery = async () => {
const tree = await query.buildProjectTree();
logger.log({ source: 'tool', action: 'query_complete', tool: toolName, modules: tree.length });

const rendered = renderTree(tree, depth);
const rendered = renderTree(tree, depth);

return {
content: [{ type: 'text' as const, text: rendered }],
details: tree,
return {
content: [{ type: 'text' as const, text: rendered }],
details: tree,
};
};

try {
return await runQuery();
} catch (error) {
if (error instanceof NeedsReindexError) {
logger.log({ source: 'tool', action: 'index_stale', tool: toolName });
emitProgress('SCIP index is outdated. Regenerating...');
await runIndexGeneration(toolName, signal, emitProgress);
return runQuery();
}

throw error;
}
},
},
];
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ function createIndex(projectRoot: string) {
writeFileSync(join(projectRoot, '.scip', 'index.scip'), Buffer.from(data));
}

function createTsIndex(projectRoot: string) {
const doc = new scip.Document({
relative_path: 'src/utils.ts',
occurrences: [
// Class MyService
new scip.Occurrence({
symbol: 'scip-typescript npm pkg 1.0.0 src/`utils.ts`/MyService#',
symbol_roles: scip.SymbolRole.Definition,
range: [0, 0],
}),
// Method fetchData inside MyService
new scip.Occurrence({
symbol: 'scip-typescript npm pkg 1.0.0 src/`utils.ts`/MyService#fetchData().',
symbol_roles: scip.SymbolRole.Definition,
range: [1, 4],
}),
// Top-level function
new scip.Occurrence({
symbol: 'scip-typescript npm pkg 1.0.0 src/`utils.ts`/formatDate().',
symbol_roles: scip.SymbolRole.Definition,
range: [5, 0],
}),
],
});

const index = new scip.Index({ documents: [doc] });
const data = index.serializeBinary();

mkdirSync(join(projectRoot, '.scip'), { recursive: true });
writeFileSync(join(projectRoot, '.scip', 'index.scip'), Buffer.from(data));
}

describe('ScipQuery.buildProjectTree', () => {
it('builds a simple module tree with classes and functions', async () => {
const root = mkdtempSync(join(tmpdir(), 'pi-agent-scip-tree-'));
Expand Down Expand Up @@ -155,4 +187,81 @@ def helper():
expect(appModule?.children.some((c) => c.name === 'main')).toBe(true);
expect(utilsModule?.children.some((c) => c.name === 'util')).toBe(true);
});

it('handles TypeScript files with classes and functions', async () => {
const root = mkdtempSync(join(tmpdir(), 'pi-agent-scip-tree-ts-'));
mkdirSync(join(root, 'src'));
writeFileSync(
join(root, 'src', 'utils.ts'),
`class MyService {
fetchData(): void {}
}

export function formatDate(date: Date): string {
return date.toISOString();
}
`,
);

createTsIndex(root);

const query = new ScipQuery(root);
const tree = await query.buildProjectTree();

expect(tree.length).toBe(1);
const moduleNode = tree[0];
expect(moduleNode.kind).toBe('Module');
expect(moduleNode.name).toBe('utils');
expect(moduleNode.file).toBe('src/utils.ts');

const classNode = moduleNode.children.find((c) => c.kind === 'Class');
const funcNode = moduleNode.children.find((c) => c.kind === 'Function');

expect(classNode?.name).toBe('MyService');
expect(funcNode?.name).toBe('formatDate');
});

it('handles Vue and JSX files', async () => {
const root = mkdtempSync(join(tmpdir(), 'pi-agent-scip-tree-vue-'));
mkdirSync(join(root, 'src', 'components'), { recursive: true });

// Create index with Vue and JSX files
const doc1 = new scip.Document({
relative_path: 'src/components/Header.vue',
occurrences: [
new scip.Occurrence({
symbol: 'scip-typescript npm pkg 1.0.0 src/components/`Header.vue`/setup().',
symbol_roles: scip.SymbolRole.Definition,
range: [0, 0],
}),
],
});

const doc2 = new scip.Document({
relative_path: 'src/App.tsx',
occurrences: [
new scip.Occurrence({
symbol: 'scip-typescript npm pkg 1.0.0 src/`App.tsx`/App().',
symbol_roles: scip.SymbolRole.Definition,
range: [0, 0],
}),
],
});

const index = new scip.Index({ documents: [doc1, doc2] });
mkdirSync(join(root, '.scip'), { recursive: true });
writeFileSync(join(root, '.scip', 'index.scip'), Buffer.from(index.serializeBinary()));

const query = new ScipQuery(root);
const tree = await query.buildProjectTree();

expect(tree.length).toBe(2);
const headerModule = tree.find((m) => m.name === 'components.Header');
const appModule = tree.find((m) => m.name === 'App');

expect(headerModule).toBeDefined();
expect(appModule).toBeDefined();
expect(headerModule?.file).toBe('src/components/Header.vue');
expect(appModule?.file).toBe('src/App.tsx');
});
});