diff --git a/packages/core/src/indexer/utils/documents.ts b/packages/core/src/indexer/utils/documents.ts index 822e11c..a8b433a 100644 --- a/packages/core/src/indexer/utils/documents.ts +++ b/packages/core/src/indexer/utils/documents.ts @@ -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, }, })); } @@ -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, }, }; } diff --git a/packages/core/src/scanner/__tests__/scanner.test.ts b/packages/core/src/scanner/__tests__/scanner.test.ts index ebaccf3..bc92a8f 100644 --- a/packages/core/src/scanner/__tests__/scanner.test.ts +++ b/packages/core/src/scanner/__tests__/scanner.test.ts @@ -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(); + 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); + }); + }); }); diff --git a/packages/core/src/scanner/index.ts b/packages/core/src/scanner/index.ts index b95d3ce..9c56932 100644 --- a/packages/core/src/scanner/index.ts +++ b/packages/core/src/scanner/index.ts @@ -3,6 +3,8 @@ export { MarkdownScanner } from './markdown'; export { ScannerRegistry } from './registry'; export type { + CalleeInfo, + CallerInfo, Document, DocumentMetadata, DocumentType, diff --git a/packages/core/src/scanner/types.ts b/packages/core/src/scanner/types.ts index 6f9640f..b83b784 100644 --- a/packages/core/src/scanner/types.ts +++ b/packages/core/src/scanner/types.ts @@ -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) @@ -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; } diff --git a/packages/core/src/scanner/typescript.ts b/packages/core/src/scanner/typescript.ts index b00351c..ab518a8 100644 --- a/packages/core/src/scanner/typescript.ts +++ b/packages/core/src/scanner/typescript.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; import { + type CallExpression, type ClassDeclaration, type FunctionDeclaration, type InterfaceDeclaration, @@ -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 @@ -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); } @@ -95,7 +96,8 @@ export class TypeScriptScanner implements Scanner { method, cls.getName() || 'Anonymous', relativeFile, - imports + imports, + sourceFile ); if (methodDoc) documents.push(methodDoc); } @@ -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 @@ -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({ @@ -180,6 +184,7 @@ export class TypeScriptScanner implements Scanner { docstring: docComment, snippet, imports, + callees: callees.length > 0 ? callees : undefined, }, }; } @@ -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; @@ -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', @@ -270,6 +277,7 @@ export class TypeScriptScanner implements Scanner { docstring: docComment, snippet, imports, + callees: callees.length > 0 ? callees : undefined, }, }; } @@ -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(); // 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, + }; + } } diff --git a/packages/core/src/vector/types.ts b/packages/core/src/vector/types.ts index f22468f..e1c08c5 100644 --- a/packages/core/src/vector/types.ts +++ b/packages/core/src/vector/types.ts @@ -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 @@ -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; } diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index f43e7b6..6e27ce3 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -22,6 +22,7 @@ import { GitHubAdapter, HealthAdapter, PlanAdapter, + RefsAdapter, SearchAdapter, StatusAdapter, } from '../src/adapters/built-in'; @@ -180,6 +181,11 @@ async function main() { githubStatePath: filePaths.githubState, }); + const refsAdapter = new RefsAdapter({ + repositoryIndexer: indexer, + defaultLimit: 20, + }); + // Create MCP server with coordinator const server = new MCPServer({ serverInfo: { @@ -198,6 +204,7 @@ async function main() { exploreAdapter, githubAdapter, healthAdapter, + refsAdapter, ], coordinator, }); diff --git a/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts new file mode 100644 index 0000000..f1e92dc --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/refs-adapter.test.ts @@ -0,0 +1,287 @@ +/** + * Tests for RefsAdapter + */ + +import type { RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConsoleLogger } from '../../utils/logger'; +import { RefsAdapter } from '../built-in/refs-adapter'; +import type { AdapterContext, ToolExecutionContext } from '../types'; + +describe('RefsAdapter', () => { + let mockIndexer: RepositoryIndexer; + let adapter: RefsAdapter; + let context: AdapterContext; + let execContext: ToolExecutionContext; + + // Mock search results with callees + const mockSearchResults: SearchResult[] = [ + { + id: 'src/planner.ts:createPlan:10', + score: 0.95, + metadata: { + path: 'src/planner.ts', + type: 'function', + name: 'createPlan', + startLine: 10, + endLine: 50, + language: 'typescript', + exported: true, + signature: 'export function createPlan(issue: Issue): Plan', + callees: [ + { name: 'fetchIssue', line: 15, file: 'src/github.ts' }, + { name: 'analyzeCode', line: 20 }, + { name: 'generateTasks', line: 30, file: 'src/tasks.ts' }, + ], + }, + }, + { + id: 'src/executor.ts:runPlan:5', + score: 0.85, + metadata: { + path: 'src/executor.ts', + type: 'function', + name: 'runPlan', + startLine: 5, + endLine: 40, + language: 'typescript', + exported: true, + callees: [ + { name: 'createPlan', line: 10, file: 'src/planner.ts' }, + { name: 'execute', line: 20 }, + ], + }, + }, + { + id: 'src/cli.ts:main:1', + score: 0.8, + metadata: { + path: 'src/cli.ts', + type: 'function', + name: 'main', + startLine: 1, + endLine: 30, + language: 'typescript', + exported: true, + callees: [{ name: 'createPlan', line: 15, file: 'src/planner.ts' }], + }, + }, + ]; + + beforeEach(async () => { + // Create mock indexer + mockIndexer = { + search: vi.fn().mockResolvedValue(mockSearchResults), + } as unknown as RepositoryIndexer; + + // Create adapter + adapter = new RefsAdapter({ + repositoryIndexer: mockIndexer, + defaultLimit: 20, + }); + + // Create context + const logger = new ConsoleLogger('[test]', 'error'); // Quiet for tests + context = { + logger, + config: { repositoryPath: '/test' }, + }; + + execContext = { + logger, + config: { repositoryPath: '/test' }, + }; + + await adapter.initialize(context); + }); + + describe('Tool Definition', () => { + it('should provide valid tool definition', () => { + const def = adapter.getToolDefinition(); + + expect(def.name).toBe('dev_refs'); + expect(def.description).toContain('call relationships'); + expect(def.inputSchema.type).toBe('object'); + expect(def.inputSchema.properties).toHaveProperty('name'); + expect(def.inputSchema.properties).toHaveProperty('direction'); + expect(def.inputSchema.properties).toHaveProperty('limit'); + expect(def.inputSchema.required).toContain('name'); + }); + + it('should have correct direction enum', () => { + const def = adapter.getToolDefinition(); + const directionProp = def.inputSchema.properties?.direction; + + expect(directionProp).toBeDefined(); + expect(directionProp).toHaveProperty('enum'); + expect((directionProp as { enum: string[] }).enum).toEqual(['callees', 'callers', 'both']); + }); + }); + + describe('Validation', () => { + it('should reject empty name', async () => { + const result = await adapter.execute({ name: '' }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_NAME'); + }); + + it('should reject invalid direction', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'invalid' }, + execContext + ); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_DIRECTION'); + }); + + it('should reject invalid limit', async () => { + const result = await adapter.execute({ name: 'createPlan', limit: 100 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_LIMIT'); + }); + }); + + describe('Callee Queries', () => { + it('should return callees for a function', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'callees' }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data?.callees).toBeDefined(); + expect(result.data?.callees.length).toBe(3); + expect(result.data?.callees[0].name).toBe('fetchIssue'); + }); + + it('should include callee file paths when available', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'callees' }, + execContext + ); + + expect(result.success).toBe(true); + const callees = result.data?.callees; + expect(callees?.find((c: { name: string }) => c.name === 'fetchIssue')?.file).toBe( + 'src/github.ts' + ); + expect( + callees?.find((c: { name: string }) => c.name === 'analyzeCode')?.file + ).toBeUndefined(); + }); + + it('should not include callers when direction is callees', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'callees' }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data?.callers).toBeUndefined(); + }); + }); + + describe('Caller Queries', () => { + it('should return callers for a function', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'callers' }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data?.callers).toBeDefined(); + // runPlan and main both call createPlan + expect(result.data?.callers.length).toBe(2); + }); + + it('should not include callees when direction is callers', async () => { + const result = await adapter.execute( + { name: 'createPlan', direction: 'callers' }, + execContext + ); + + expect(result.success).toBe(true); + expect(result.data?.callees).toBeUndefined(); + }); + }); + + describe('Bidirectional Queries', () => { + it('should return both callees and callers when direction is both', async () => { + const result = await adapter.execute({ name: 'createPlan', direction: 'both' }, execContext); + + expect(result.success).toBe(true); + expect(result.data?.callees).toBeDefined(); + expect(result.data?.callers).toBeDefined(); + }); + + it('should use both as default direction', async () => { + const result = await adapter.execute({ name: 'createPlan' }, execContext); + + expect(result.success).toBe(true); + expect(result.data?.callees).toBeDefined(); + expect(result.data?.callers).toBeDefined(); + }); + }); + + describe('Output Formatting', () => { + it('should include target information', async () => { + const result = await adapter.execute({ name: 'createPlan' }, execContext); + + expect(result.success).toBe(true); + expect(result.data?.target).toBeDefined(); + expect(result.data?.target.name).toBe('createPlan'); + expect(result.data?.target.file).toBe('src/planner.ts'); + expect(result.data?.target.type).toBe('function'); + }); + + it('should format output as markdown', async () => { + const result = await adapter.execute({ name: 'createPlan' }, execContext); + + expect(result.success).toBe(true); + expect(result.data?.content).toContain('# References for createPlan'); + expect(result.data?.content).toContain('## Callees'); + expect(result.data?.content).toContain('## Callers'); + }); + + it('should include token count in metadata', async () => { + const result = await adapter.execute({ name: 'createPlan' }, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.tokens).toBeDefined(); + expect(typeof result.metadata?.tokens).toBe('number'); + }); + + it('should include duration in metadata', async () => { + const result = await adapter.execute({ name: 'createPlan' }, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.duration_ms).toBeDefined(); + expect(typeof result.metadata?.duration_ms).toBe('number'); + }); + }); + + describe('Not Found', () => { + it('should return error when function not found', async () => { + // Mock empty results + (mockIndexer.search as ReturnType).mockResolvedValueOnce([]); + + const result = await adapter.execute({ name: 'nonExistentFunction' }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('NOT_FOUND'); + }); + }); + + describe('Token Estimation', () => { + it('should estimate tokens based on limit and direction', () => { + const bothTokens = adapter.estimateTokens({ limit: 10, direction: 'both' }); + const singleTokens = adapter.estimateTokens({ limit: 10, direction: 'callees' }); + + // Both directions should estimate more tokens + expect(bothTokens).toBeGreaterThan(singleTokens); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts index f8af2f3..0b07d55 100644 --- a/packages/mcp-server/src/adapters/built-in/index.ts +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -7,5 +7,6 @@ export { ExploreAdapter, type ExploreAdapterConfig } from './explore-adapter'; export { GitHubAdapter, type GitHubAdapterConfig } from './github-adapter'; export { HealthAdapter, type HealthCheckConfig } from './health-adapter'; export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter'; +export { RefsAdapter, type RefsAdapterConfig } from './refs-adapter'; export { SearchAdapter, type SearchAdapterConfig } from './search-adapter'; export { StatusAdapter, type StatusAdapterConfig } from './status-adapter'; diff --git a/packages/mcp-server/src/adapters/built-in/refs-adapter.ts b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts new file mode 100644 index 0000000..6ad9302 --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/refs-adapter.ts @@ -0,0 +1,362 @@ +/** + * Refs Adapter + * Provides call graph queries via the dev_refs tool + */ + +import type { CalleeInfo, RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core'; +import { estimateTokensForText, startTimer } from '../../formatters/utils'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * Direction of relationship query + */ +export type RefDirection = 'callees' | 'callers' | 'both'; + +/** + * Refs adapter configuration + */ +export interface RefsAdapterConfig { + /** + * Repository indexer instance + */ + repositoryIndexer: RepositoryIndexer; + + /** + * Default result limit + */ + defaultLimit?: number; +} + +/** + * Reference result for output + */ +interface RefResult { + name: string; + file?: string; + line: number; + type?: string; + snippet?: string; +} + +/** + * Refs Adapter + * Implements the dev_refs tool for querying call relationships + */ +export class RefsAdapter extends ToolAdapter { + readonly metadata = { + name: 'refs-adapter', + version: '1.0.0', + description: 'Call graph relationship adapter', + author: 'Dev-Agent Team', + }; + + private indexer: RepositoryIndexer; + private config: Required; + + constructor(config: RefsAdapterConfig) { + super(); + this.indexer = config.repositoryIndexer; + this.config = { + repositoryIndexer: config.repositoryIndexer, + defaultLimit: config.defaultLimit ?? 20, + }; + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('RefsAdapter initialized', { + defaultLimit: this.config.defaultLimit, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_refs', + description: + 'Find call relationships for a function or method. Shows what calls it (callers) and what it calls (callees).', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Name of the function or method to query (e.g., "createPlan", "SearchAdapter.execute")', + }, + direction: { + type: 'string', + enum: ['callees', 'callers', 'both'], + description: + 'Direction of query: "callees" (what this calls), "callers" (what calls this), or "both" (default)', + default: 'both', + }, + limit: { + type: 'number', + description: `Maximum number of results per direction (default: ${this.config.defaultLimit})`, + minimum: 1, + maximum: 50, + default: this.config.defaultLimit, + }, + }, + required: ['name'], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + name, + direction = 'both', + limit = this.config.defaultLimit, + } = args as { + name: string; + direction?: RefDirection; + limit?: number; + }; + + // Validate name + if (typeof name !== 'string' || name.trim().length === 0) { + return { + success: false, + error: { + code: 'INVALID_NAME', + message: 'Name must be a non-empty string', + }, + }; + } + + // Validate direction + if (!['callees', 'callers', 'both'].includes(direction)) { + return { + success: false, + error: { + code: 'INVALID_DIRECTION', + message: 'Direction must be "callees", "callers", or "both"', + }, + }; + } + + // Validate limit + if (typeof limit !== 'number' || limit < 1 || limit > 50) { + return { + success: false, + error: { + code: 'INVALID_LIMIT', + message: 'Limit must be a number between 1 and 50', + }, + }; + } + + try { + const timer = startTimer(); + context.logger.debug('Executing refs query', { name, direction, limit }); + + // First, find the target component + const searchResults = await this.indexer.search(name, { limit: 10 }); + const target = this.findBestMatch(searchResults, name); + + if (!target) { + return { + success: false, + error: { + code: 'NOT_FOUND', + message: `Could not find function or method named "${name}"`, + }, + }; + } + + const result: { + target: { + name: string; + file: string; + line: number; + type: string; + }; + callees?: RefResult[]; + callers?: RefResult[]; + } = { + target: { + name: target.metadata.name || name, + file: target.metadata.path || '', + line: target.metadata.startLine || 0, + type: (target.metadata.type as string) || 'unknown', + }, + }; + + // Get callees if requested + if (direction === 'callees' || direction === 'both') { + result.callees = this.getCallees(target, limit); + } + + // Get callers if requested + if (direction === 'callers' || direction === 'both') { + result.callers = await this.getCallers(target, limit); + } + + const content = this.formatOutput(result, direction); + const duration_ms = timer.elapsed(); + + context.logger.info('Refs query completed', { + name, + direction, + calleesCount: result.callees?.length ?? 0, + callersCount: result.callers?.length ?? 0, + duration_ms, + }); + + const tokens = estimateTokensForText(content); + + return { + success: true, + data: { + name, + direction, + content, + ...result, + }, + metadata: { + tokens, + duration_ms, + timestamp: new Date().toISOString(), + cached: false, + }, + }; + } catch (error) { + context.logger.error('Refs query failed', { error }); + return { + success: false, + error: { + code: 'REFS_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + /** + * Find the best matching result for a name query + */ + private findBestMatch(results: SearchResult[], name: string): SearchResult | null { + if (results.length === 0) return null; + + // Exact name match takes priority + const exactMatch = results.find( + (r) => r.metadata.name === name || r.metadata.name?.endsWith(`.${name}`) + ); + if (exactMatch) return exactMatch; + + // Otherwise return the highest scoring result + return results[0]; + } + + /** + * Get callees from the target's metadata + */ + private getCallees(target: SearchResult, limit: number): RefResult[] { + const callees = target.metadata.callees as CalleeInfo[] | undefined; + if (!callees || callees.length === 0) return []; + + return callees.slice(0, limit).map((c) => ({ + name: c.name, + file: c.file, + line: c.line, + })); + } + + /** + * Find callers by searching all indexed components for callees that reference the target + */ + private async getCallers(target: SearchResult, limit: number): Promise { + const targetName = target.metadata.name; + if (!targetName) return []; + + // Search for components that might call this target + // We search broadly and then filter by callees + const candidates = await this.indexer.search(targetName, { limit: 100 }); + + const callers: RefResult[] = []; + + for (const candidate of candidates) { + // Skip the target itself + if (candidate.id === target.id) continue; + + const callees = candidate.metadata.callees as CalleeInfo[] | undefined; + if (!callees) continue; + + // Check if any callee matches our target + const callsTarget = callees.some( + (c) => + c.name === targetName || + c.name.endsWith(`.${targetName}`) || + targetName.endsWith(`.${c.name}`) + ); + + if (callsTarget) { + callers.push({ + name: candidate.metadata.name || 'unknown', + file: candidate.metadata.path, + line: candidate.metadata.startLine || 0, + type: candidate.metadata.type as string, + snippet: candidate.metadata.signature as string | undefined, + }); + + if (callers.length >= limit) break; + } + } + + return callers; + } + + /** + * Format the output as readable text + */ + private formatOutput( + result: { + target: { name: string; file: string; line: number; type: string }; + callees?: RefResult[]; + callers?: RefResult[]; + }, + direction: RefDirection + ): string { + const lines: string[] = []; + + lines.push(`# References for ${result.target.name}`); + lines.push(`**Location:** ${result.target.file}:${result.target.line}`); + lines.push(`**Type:** ${result.target.type}`); + lines.push(''); + + if (direction === 'callees' || direction === 'both') { + lines.push('## Callees (what this calls)'); + if (result.callees && result.callees.length > 0) { + for (const callee of result.callees) { + const location = callee.file ? `${callee.file}:${callee.line}` : `line ${callee.line}`; + lines.push(`- \`${callee.name}\` at ${location}`); + } + } else { + lines.push('*No callees found*'); + } + lines.push(''); + } + + if (direction === 'callers' || direction === 'both') { + lines.push('## Callers (what calls this)'); + if (result.callers && result.callers.length > 0) { + for (const caller of result.callers) { + const location = caller.file ? `${caller.file}:${caller.line}` : `line ${caller.line}`; + lines.push(`- \`${caller.name}\` (${caller.type}) at ${location}`); + } + } else { + lines.push('*No callers found in indexed code*'); + } + lines.push(''); + } + + return lines.join('\n'); + } + + estimateTokens(args: Record): number { + const { limit = this.config.defaultLimit, direction = 'both' } = args; + const multiplier = direction === 'both' ? 2 : 1; + return (limit as number) * 15 * multiplier + 50; + } +} diff --git a/packages/mcp-server/src/formatters/__tests__/utils.test.ts b/packages/mcp-server/src/formatters/__tests__/utils.test.ts index 42b9fef..595821e 100644 --- a/packages/mcp-server/src/formatters/__tests__/utils.test.ts +++ b/packages/mcp-server/src/formatters/__tests__/utils.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from 'vitest'; -import { estimateTokensForJSON, estimateTokensForText, truncateToTokenBudget } from '../utils'; +import { + estimateTokensForJSON, + estimateTokensForText, + startTimer, + truncateToTokenBudget, +} from '../utils'; describe('Formatter Utils', () => { describe('estimateTokensForText', () => { @@ -198,4 +203,37 @@ describe('Formatter Utils', () => { expect(errorPercent).toBeLessThan(5); }); }); + + describe('startTimer', () => { + it('should return elapsed time', async () => { + const timer = startTimer(); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + const elapsed = timer.elapsed(); + expect(elapsed).toBeGreaterThanOrEqual(10); + expect(elapsed).toBeLessThan(100); // Should be fast + }); + + it('should allow multiple elapsed() calls', async () => { + const timer = startTimer(); + + await new Promise((resolve) => setTimeout(resolve, 5)); + const elapsed1 = timer.elapsed(); + + await new Promise((resolve) => setTimeout(resolve, 5)); + const elapsed2 = timer.elapsed(); + + expect(elapsed2).toBeGreaterThan(elapsed1); + }); + + it('should return 0 immediately after creation', () => { + const timer = startTimer(); + const elapsed = timer.elapsed(); + + // Should be very small (< 5ms) + expect(elapsed).toBeLessThan(5); + }); + }); }); diff --git a/packages/mcp-server/src/formatters/utils.ts b/packages/mcp-server/src/formatters/utils.ts index dc022c3..341c4a2 100644 --- a/packages/mcp-server/src/formatters/utils.ts +++ b/packages/mcp-server/src/formatters/utils.ts @@ -1,8 +1,36 @@ /** * Formatter Utilities - * Token estimation and text processing utilities + * Token estimation, text processing, and timing utilities */ +/** + * Simple timer for measuring operation duration + */ +export interface SimpleTimer { + /** Get elapsed time in milliseconds */ + elapsed(): number; +} + +/** + * Start a simple timer for measuring operation duration + * Unlike logger.startTimer(), this doesn't log automatically + * + * @returns Timer object with elapsed() method + * + * @example + * ```typescript + * const timer = startTimer(); + * await doSomething(); + * const duration_ms = timer.elapsed(); + * ``` + */ +export function startTimer(): SimpleTimer { + const start = Date.now(); + return { + elapsed: () => Date.now() - start, + }; +} + /** * Estimate tokens for text using a calibrated heuristic * diff --git a/scripts/gh-issue-add-subs.sh b/scripts/gh-issue-add-subs.sh index d6d5d78..929efb1 100755 --- a/scripts/gh-issue-add-subs.sh +++ b/scripts/gh-issue-add-subs.sh @@ -15,7 +15,7 @@ # gh-issue-add-subs 31 52 53 54 # # # Add a single sub-issue -# gh-issue-add-subs 31 52 +# gh-issue-add-subs 31 52 # # Requirements: # - GitHub CLI (gh) installed and authenticated