Skip to content
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
2 changes: 2 additions & 0 deletions packages/core/src/indexer/utils/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function prepareDocumentsForEmbedding(documents: Document[]): EmbeddingDo
docstring: doc.metadata.docstring,
snippet: doc.metadata.snippet,
imports: doc.metadata.imports,
callees: doc.metadata.callees,
},
}));
}
Expand Down Expand Up @@ -76,6 +77,7 @@ export function prepareDocumentForEmbedding(doc: Document): EmbeddingDocument {
docstring: doc.metadata.docstring,
snippet: doc.metadata.snippet,
imports: doc.metadata.imports,
callees: doc.metadata.callees,
},
};
}
Expand Down
113 changes: 113 additions & 0 deletions packages/core/src/scanner/__tests__/scanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,4 +594,117 @@ describe('Scanner', () => {
expect(docType?.metadata.imports).toEqual([]);
});
});

describe('Callee Extraction', () => {
it('should extract callees from functions', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/index.ts'],
});

// createDefaultRegistry calls registry.register()
const fn = result.documents.find((d) => d.metadata.name === 'createDefaultRegistry');
expect(fn).toBeDefined();
expect(fn?.metadata.callees).toBeDefined();
expect(fn?.metadata.callees?.length).toBeGreaterThan(0);

// Should have calls to ScannerRegistry constructor and register method
const calleeNames = fn?.metadata.callees?.map((c) => c.name) || [];
expect(calleeNames.some((n) => n.includes('ScannerRegistry') || n.includes('new'))).toBe(
true
);
});

it('should extract callees from methods', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/typescript.ts'],
exclude: ['**/*.test.ts'],
});

// extractFromSourceFile calls other methods like extractFunction, extractClass
const method = result.documents.find(
(d) => d.type === 'method' && d.metadata.name === 'TypeScriptScanner.extractFromSourceFile'
);
expect(method).toBeDefined();
expect(method?.metadata.callees).toBeDefined();
expect(method?.metadata.callees?.length).toBeGreaterThan(0);

// Should call extractFunction, extractClass, etc.
const calleeNames = method?.metadata.callees?.map((c) => c.name) || [];
expect(calleeNames.some((n) => n.includes('extractFunction'))).toBe(true);
});

it('should include line numbers for callees', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/index.ts'],
});

const fn = result.documents.find((d) => d.metadata.name === 'createDefaultRegistry');
expect(fn?.metadata.callees).toBeDefined();

for (const callee of fn?.metadata.callees || []) {
expect(callee.line).toBeDefined();
expect(typeof callee.line).toBe('number');
expect(callee.line).toBeGreaterThan(0);
}
});

it('should not have callees for interfaces', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/types.ts'],
});

// Interfaces don't have callees (no function body)
const iface = result.documents.find((d) => d.metadata.name === 'Scanner');
expect(iface).toBeDefined();
expect(iface?.metadata.callees).toBeUndefined();
});

it('should not have callees for type aliases', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/types.ts'],
});

// Type aliases don't have callees
const typeAlias = result.documents.find((d) => d.metadata.name === 'DocumentType');
expect(typeAlias).toBeDefined();
expect(typeAlias?.metadata.callees).toBeUndefined();
});

it('should deduplicate callees at same line', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/index.ts'],
});

const fn = result.documents.find((d) => d.metadata.name === 'createDefaultRegistry');
expect(fn?.metadata.callees).toBeDefined();

// Check for no duplicates (same name + same line)
const seen = new Set<string>();
for (const callee of fn?.metadata.callees || []) {
const key = `${callee.name}:${callee.line}`;
expect(seen.has(key)).toBe(false);
seen.add(key);
}
});

it('should handle method calls on objects', async () => {
const result = await scanRepository({
repoRoot,
include: ['packages/core/src/scanner/index.ts'],
});

const fn = result.documents.find((d) => d.metadata.name === 'createDefaultRegistry');
expect(fn?.metadata.callees).toBeDefined();

// Should have registry.register() calls
const calleeNames = fn?.metadata.callees?.map((c) => c.name) || [];
expect(calleeNames.some((n) => n.includes('register'))).toBe(true);
});
});
});
2 changes: 2 additions & 0 deletions packages/core/src/scanner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
export { MarkdownScanner } from './markdown';
export { ScannerRegistry } from './registry';
export type {
CalleeInfo,
CallerInfo,
Document,
DocumentMetadata,
DocumentType,
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/scanner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ export type DocumentType =
| 'method'
| 'documentation';

/**
* Information about a function/method that calls this component
*/
export interface CallerInfo {
/** Name of the calling function/method */
name: string;
/** File path where the call originates */
file: string;
/** Line number of the call site */
line: number;
}

/**
* Information about a function/method called by this component
*/
export interface CalleeInfo {
/** Name of the called function/method */
name: string;
/** File path of the called function (if resolved) */
file?: string;
/** Line number of the call within this component */
line: number;
}

export interface Document {
id: string; // Unique identifier: file:name:line
text: string; // Text to embed (for vector search)
Expand All @@ -29,6 +53,10 @@ export interface DocumentMetadata {
snippet?: string; // Actual code content (truncated if large)
imports?: string[]; // File-level imports (module specifiers)

// Relationship data (call graph)
callees?: CalleeInfo[]; // Functions/methods this component calls
// Note: callers are computed at query time via reverse lookup

// Extensible for future use
custom?: Record<string, unknown>;
}
Expand Down
111 changes: 106 additions & 5 deletions packages/core/src/scanner/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'node:path';
import {
type CallExpression,
type ClassDeclaration,
type FunctionDeclaration,
type InterfaceDeclaration,
Expand All @@ -10,7 +11,7 @@ import {
SyntaxKind,
type TypeAliasDeclaration,
} from 'ts-morph';
import type { Document, Scanner, ScannerCapabilities } from './types';
import type { CalleeInfo, Document, Scanner, ScannerCapabilities } from './types';

/**
* Enhanced TypeScript scanner using ts-morph
Expand Down Expand Up @@ -80,7 +81,7 @@ export class TypeScriptScanner implements Scanner {

// Extract functions
for (const fn of sourceFile.getFunctions()) {
const doc = this.extractFunction(fn, relativeFile, imports);
const doc = this.extractFunction(fn, relativeFile, imports, sourceFile);
if (doc) documents.push(doc);
}

Expand All @@ -95,7 +96,8 @@ export class TypeScriptScanner implements Scanner {
method,
cls.getName() || 'Anonymous',
relativeFile,
imports
imports,
sourceFile
);
if (methodDoc) documents.push(methodDoc);
}
Expand Down Expand Up @@ -143,7 +145,8 @@ export class TypeScriptScanner implements Scanner {
private extractFunction(
fn: FunctionDeclaration,
file: string,
imports: string[]
imports: string[],
sourceFile: SourceFile
): Document | null {
const name = fn.getName();
if (!name) return null; // Skip anonymous functions
Expand All @@ -155,6 +158,7 @@ export class TypeScriptScanner implements Scanner {
const docComment = this.getDocComment(fn);
const isExported = fn.isExported();
const snippet = this.truncateSnippet(fullText);
const callees = this.extractCallees(fn, sourceFile);

// Build text for embedding
const text = this.buildEmbeddingText({
Expand All @@ -180,6 +184,7 @@ export class TypeScriptScanner implements Scanner {
docstring: docComment,
snippet,
imports,
callees: callees.length > 0 ? callees : undefined,
},
};
}
Expand Down Expand Up @@ -234,7 +239,8 @@ export class TypeScriptScanner implements Scanner {
method: MethodDeclaration,
className: string,
file: string,
imports: string[]
imports: string[],
sourceFile: SourceFile
): Document | null {
const name = method.getName();
if (!name) return null;
Expand All @@ -246,6 +252,7 @@ export class TypeScriptScanner implements Scanner {
const docComment = this.getDocComment(method);
const isPublic = !method.hasModifier(SyntaxKind.PrivateKeyword);
const snippet = this.truncateSnippet(fullText);
const callees = this.extractCallees(method, sourceFile);

const text = this.buildEmbeddingText({
type: 'method',
Expand All @@ -270,6 +277,7 @@ export class TypeScriptScanner implements Scanner {
docstring: docComment,
snippet,
imports,
callees: callees.length > 0 ? callees : undefined,
},
};
}
Expand Down Expand Up @@ -408,4 +416,97 @@ export class TypeScriptScanner implements Scanner {
const remaining = lines.length - maxLines;
return `${truncated}\n// ... ${remaining} more lines`;
}

/**
* Extract callees (functions/methods called) from a node
* Handles: function calls, method calls, constructor calls
*/
private extractCallees(node: Node, sourceFile: SourceFile): CalleeInfo[] {
const callees: CalleeInfo[] = [];
const seenCalls = new Set<string>(); // Deduplicate by name+line

// Get all call expressions within this node
const callExpressions = node.getDescendantsOfKind(SyntaxKind.CallExpression);

for (const callExpr of callExpressions) {
const calleeInfo = this.extractCalleeFromExpression(callExpr, sourceFile);
if (calleeInfo) {
const key = `${calleeInfo.name}:${calleeInfo.line}`;
if (!seenCalls.has(key)) {
seenCalls.add(key);
callees.push(calleeInfo);
}
}
}

// Also handle new expressions (constructor calls)
const newExpressions = node.getDescendantsOfKind(SyntaxKind.NewExpression);
for (const newExpr of newExpressions) {
const expression = newExpr.getExpression();
const name = expression.getText();
const line = newExpr.getStartLineNumber();
const key = `new ${name}:${line}`;

if (!seenCalls.has(key)) {
seenCalls.add(key);
callees.push({
name: `new ${name}`,
line,
file: undefined, // Could resolve via type checker if needed
});
}
}

return callees;
}

/**
* Extract callee info from a call expression
*/
private extractCalleeFromExpression(
callExpr: CallExpression,
_sourceFile: SourceFile
): CalleeInfo | null {
const expression = callExpr.getExpression();
const line = callExpr.getStartLineNumber();

// Handle different call patterns:
// 1. Simple call: foo()
// 2. Method call: obj.method()
// 3. Chained call: a.b.c()
// 4. Computed property: obj[key]()

const expressionText = expression.getText();

// Skip very complex expressions (e.g., IIFEs, callbacks)
if (expressionText.includes('(') || expressionText.includes('=>')) {
return null;
}

// Try to resolve the definition file
let file: string | undefined;
try {
// Get the symbol and find its declaration
const symbol = expression.getSymbol();
if (symbol) {
const declarations = symbol.getDeclarations();
if (declarations.length > 0) {
const declSourceFile = declarations[0].getSourceFile();
const filePath = declSourceFile.getFilePath();
// Only include if it's within the project (not node_modules)
if (!filePath.includes('node_modules')) {
file = filePath;
}
}
}
} catch {
// Symbol resolution can fail for various reasons, continue without file
}

return {
name: expressionText,
line,
file,
};
}
}
3 changes: 2 additions & 1 deletion packages/core/src/vector/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Vector storage and embedding types
*/

import type { DocumentType } from '../scanner/types';
import type { CalleeInfo, DocumentType } from '../scanner/types';

/**
* Document to be embedded and stored
Expand Down Expand Up @@ -33,6 +33,7 @@ export interface SearchResultMetadata {
docstring?: string; // Documentation comment
snippet?: string; // Actual code content (truncated if large)
imports?: string[]; // File-level imports (module specifiers)
callees?: CalleeInfo[]; // Functions/methods this component calls
// Allow additional custom fields for extensibility (e.g., GitHub indexer uses 'document')
[key: string]: unknown;
}
Expand Down
Loading