diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 101215d..e51be77 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export * from './context'; export * from './events'; export * from './github'; export * from './indexer'; +export * from './map'; export * from './observability'; export * from './scanner'; export * from './storage'; diff --git a/packages/core/src/map/__tests__/map.test.ts b/packages/core/src/map/__tests__/map.test.ts new file mode 100644 index 0000000..875f78d --- /dev/null +++ b/packages/core/src/map/__tests__/map.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for Codebase Map Generation + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { RepositoryIndexer } from '../../indexer'; +import type { SearchResult } from '../../vector/types'; +import { formatCodebaseMap, generateCodebaseMap } from '../index'; + +describe('Codebase Map', () => { + // Mock search results representing indexed documents + const mockSearchResults: SearchResult[] = [ + { + id: 'packages/core/src/scanner/typescript.ts:TypeScriptScanner:19', + score: 0.9, + metadata: { + path: 'packages/core/src/scanner/typescript.ts', + type: 'class', + name: 'TypeScriptScanner', + startLine: 19, + endLine: 100, + language: 'typescript', + exported: true, + }, + }, + { + id: 'packages/core/src/scanner/typescript.ts:scan:45', + score: 0.85, + metadata: { + path: 'packages/core/src/scanner/typescript.ts', + type: 'method', + name: 'scan', + startLine: 45, + endLine: 70, + language: 'typescript', + exported: true, + }, + }, + { + id: 'packages/core/src/indexer/index.ts:RepositoryIndexer:10', + score: 0.8, + metadata: { + path: 'packages/core/src/indexer/index.ts', + type: 'class', + name: 'RepositoryIndexer', + startLine: 10, + endLine: 200, + language: 'typescript', + exported: true, + }, + }, + { + id: 'packages/mcp-server/src/adapters/search-adapter.ts:SearchAdapter:35', + score: 0.75, + metadata: { + path: 'packages/mcp-server/src/adapters/search-adapter.ts', + type: 'class', + name: 'SearchAdapter', + startLine: 35, + endLine: 150, + language: 'typescript', + exported: true, + }, + }, + { + id: 'packages/cli/src/cli.ts:main:5', + score: 0.7, + metadata: { + path: 'packages/cli/src/cli.ts', + type: 'function', + name: 'main', + startLine: 5, + endLine: 50, + language: 'typescript', + exported: true, + }, + }, + { + id: 'packages/core/src/utils/helpers.ts:privateHelper:10', + score: 0.65, + metadata: { + path: 'packages/core/src/utils/helpers.ts', + type: 'function', + name: 'privateHelper', + startLine: 10, + endLine: 20, + language: 'typescript', + exported: false, // Not exported + }, + }, + ]; + + // Create mock indexer + function createMockIndexer(results: SearchResult[] = mockSearchResults): RepositoryIndexer { + return { + search: vi.fn().mockResolvedValue(results), + } as unknown as RepositoryIndexer; + } + + describe('generateCodebaseMap', () => { + it('should generate a map with correct structure', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer); + + expect(map.root).toBeDefined(); + expect(map.root.name).toBe('root'); + expect(map.totalComponents).toBeGreaterThan(0); + expect(map.totalDirectories).toBeGreaterThan(0); + expect(map.generatedAt).toBeDefined(); + }); + + it('should count components correctly', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer); + + // Should have all mock results counted (root includes all children) + expect(map.totalComponents).toBeGreaterThanOrEqual(6); + }); + + it('should build directory hierarchy', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 3 }); + + // Should have packages as a child of root + const packagesNode = map.root.children.find((c) => c.name === 'packages'); + expect(packagesNode).toBeDefined(); + expect(packagesNode?.children.length).toBeGreaterThan(0); + }); + + it('should respect depth limit', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 1 }); + + // At depth 1, should only have immediate children + const packagesNode = map.root.children.find((c) => c.name === 'packages'); + expect(packagesNode?.children.length).toBe(0); // Pruned at depth 1 + }); + + it('should filter by focus directory', async () => { + const indexer = createMockIndexer(); + const fullMap = await generateCodebaseMap(indexer); + const focusedMap = await generateCodebaseMap(indexer, { focus: 'packages/core' }); + + // Focused map should have fewer components than full map + expect(focusedMap.totalComponents).toBeLessThan(fullMap.totalComponents); + + // Root should contain core-related content + expect(focusedMap.totalComponents).toBeGreaterThan(0); + }); + + it('should extract exports when includeExports is true', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 5, includeExports: true }); + + // Find a node with exports + const findNodeWithExports = (node: typeof map.root): typeof map.root | null => { + if (node.exports && node.exports.length > 0) return node; + for (const child of node.children) { + const found = findNodeWithExports(child); + if (found) return found; + } + return null; + }; + + const nodeWithExports = findNodeWithExports(map.root); + expect(nodeWithExports).not.toBeNull(); + expect(nodeWithExports?.exports?.[0].name).toBeDefined(); + }); + + it('should not include exports when includeExports is false', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 5, includeExports: false }); + + // Check that no node has exports + const hasExports = (node: typeof map.root): boolean => { + if (node.exports && node.exports.length > 0) return true; + return node.children.some(hasExports); + }; + + expect(hasExports(map.root)).toBe(false); + }); + + it('should limit exports per directory', async () => { + // Create results with many exports in one directory + const manyExports: SearchResult[] = Array.from({ length: 20 }, (_, i) => ({ + id: `packages/core/src/index.ts:export${i}:${i * 10}`, + score: 0.9 - i * 0.01, + metadata: { + path: 'packages/core/src/index.ts', + type: 'function', + name: `export${i}`, + startLine: i * 10, + endLine: i * 10 + 5, + language: 'typescript', + exported: true, + }, + })); + + const indexer = createMockIndexer(manyExports); + const map = await generateCodebaseMap(indexer, { + depth: 5, + includeExports: true, + maxExportsPerDir: 5, + }); + + // Find the src node + const findNode = (node: typeof map.root, name: string): typeof map.root | null => { + if (node.name === name) return node; + for (const child of node.children) { + const found = findNode(child, name); + if (found) return found; + } + return null; + }; + + const srcNode = findNode(map.root, 'src'); + expect(srcNode?.exports?.length).toBeLessThanOrEqual(5); + }); + + it('should sort children alphabetically', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 3 }); + + const packagesNode = map.root.children.find((c) => c.name === 'packages'); + if (packagesNode && packagesNode.children.length > 1) { + const names = packagesNode.children.map((c) => c.name); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + } + }); + }); + + describe('formatCodebaseMap', () => { + it('should format map as readable text', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer); + const output = formatCodebaseMap(map); + + expect(output).toContain('# Codebase Map'); + expect(output).toContain('components'); + expect(output).toContain('directories'); + }); + + it('should include tree structure with connectors', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 2 }); + const output = formatCodebaseMap(map); + + // Should have tree connectors + expect(output).toMatch(/[├└]/); + expect(output).toMatch(/──/); + }); + + it('should show exports when includeExports is true', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer, { depth: 5, includeExports: true }); + const output = formatCodebaseMap(map, { includeExports: true }); + + expect(output).toContain('exports:'); + }); + + it('should show component counts', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer); + const output = formatCodebaseMap(map); + + expect(output).toMatch(/\d+ components/); + }); + + it('should show total summary', async () => { + const indexer = createMockIndexer(); + const map = await generateCodebaseMap(indexer); + const output = formatCodebaseMap(map); + + expect(output).toContain('**Total:**'); + expect(output).toContain('indexed components'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty results', async () => { + const indexer = createMockIndexer([]); + const map = await generateCodebaseMap(indexer); + + expect(map.totalComponents).toBe(0); + expect(map.root.children.length).toBe(0); + }); + + it('should handle results with missing path', async () => { + const resultsWithMissingPath: SearchResult[] = [ + { + id: 'test:1', + score: 0.9, + metadata: { + type: 'function', + name: 'test', + // No path field + }, + }, + ]; + + const indexer = createMockIndexer(resultsWithMissingPath); + const map = await generateCodebaseMap(indexer); + + // Should not crash, just skip the result + expect(map.totalComponents).toBe(0); + }); + + it('should handle deeply nested directories', async () => { + const deepResults: SearchResult[] = [ + { + id: 'a/b/c/d/e/f/g/file.ts:fn:1', + score: 0.9, + metadata: { + path: 'a/b/c/d/e/f/g/file.ts', + type: 'function', + name: 'fn', + exported: true, + }, + }, + ]; + + const indexer = createMockIndexer(deepResults); + const map = await generateCodebaseMap(indexer, { depth: 10 }); + + expect(map.totalComponents).toBe(1); + }); + }); +}); diff --git a/packages/core/src/map/index.ts b/packages/core/src/map/index.ts new file mode 100644 index 0000000..03e5dd9 --- /dev/null +++ b/packages/core/src/map/index.ts @@ -0,0 +1,280 @@ +/** + * Codebase Map Generator + * Generates a hierarchical view of the codebase structure + */ + +import * as path from 'node:path'; +import type { RepositoryIndexer } from '../indexer'; +import type { SearchResult } from '../vector/types'; +import type { CodebaseMap, ExportInfo, MapNode, MapOptions } from './types'; + +export * from './types'; + +/** Default options for map generation */ +const DEFAULT_OPTIONS: Required = { + depth: 2, + focus: '', + includeExports: true, + maxExportsPerDir: 5, + tokenBudget: 2000, +}; + +/** + * Generate a codebase map from indexed documents + * + * @param indexer - Repository indexer with indexed documents + * @param options - Map generation options + * @returns Codebase map structure + */ +export async function generateCodebaseMap( + indexer: RepositoryIndexer, + options: MapOptions = {} +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + // Get all indexed documents (use a broad search) + // Note: We search with a generic query to get all documents + const allDocs = await indexer.search('function class interface type', { + limit: 10000, + scoreThreshold: 0, + }); + + // Build directory tree from documents + const root = buildDirectoryTree(allDocs, opts); + + // Count totals + const totalComponents = countComponents(root); + const totalDirectories = countDirectories(root); + + return { + root, + totalComponents, + totalDirectories, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Build a directory tree from search results + */ +function buildDirectoryTree(docs: SearchResult[], opts: Required): MapNode { + // Group documents by directory + const byDir = new Map(); + + for (const doc of docs) { + const filePath = (doc.metadata.path as string) || (doc.metadata.file as string) || ''; + if (!filePath) continue; + + // Apply focus filter + if (opts.focus && !filePath.startsWith(opts.focus)) { + continue; + } + + const dir = path.dirname(filePath); + if (!byDir.has(dir)) { + byDir.set(dir, []); + } + byDir.get(dir)!.push(doc); + } + + // Build tree structure + const rootName = opts.focus || '.'; + const root: MapNode = { + name: rootName === '.' ? 'root' : path.basename(rootName), + path: rootName, + componentCount: 0, + children: [], + exports: [], + }; + + // Process each directory + for (const [dir, dirDocs] of byDir) { + insertIntoTree(root, dir, dirDocs, opts); + } + + // Prune tree to depth + pruneToDepth(root, opts.depth); + + // Sort children alphabetically + sortTree(root); + + return root; +} + +/** + * Insert documents into the tree at the appropriate location + */ +function insertIntoTree( + root: MapNode, + dirPath: string, + docs: SearchResult[], + opts: Required +): void { + const parts = dirPath.split(path.sep).filter((p) => p && p !== '.'); + + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const currentPath = parts.slice(0, i + 1).join(path.sep); + + let child = current.children.find((c) => c.name === part); + if (!child) { + child = { + name: part, + path: currentPath, + componentCount: 0, + children: [], + exports: [], + }; + current.children.push(child); + } + current = child; + } + + // Add component count and exports to the leaf directory + current.componentCount += docs.length; + + if (opts.includeExports) { + const exports = extractExports(docs, opts.maxExportsPerDir); + current.exports = current.exports || []; + current.exports.push(...exports); + // Limit total exports + if (current.exports.length > opts.maxExportsPerDir) { + current.exports = current.exports.slice(0, opts.maxExportsPerDir); + } + } + + // Propagate counts up the tree + propagateCounts(root); +} + +/** + * Extract export information from documents + */ +function extractExports(docs: SearchResult[], maxExports: number): ExportInfo[] { + const exports: ExportInfo[] = []; + + for (const doc of docs) { + if (doc.metadata.exported && doc.metadata.name) { + exports.push({ + name: doc.metadata.name as string, + type: (doc.metadata.type as string) || 'unknown', + file: (doc.metadata.path as string) || (doc.metadata.file as string) || '', + }); + + if (exports.length >= maxExports) break; + } + } + + return exports; +} + +/** + * Propagate component counts up the tree + */ +function propagateCounts(node: MapNode): number { + let total = node.componentCount; + + for (const child of node.children) { + total += propagateCounts(child); + } + + node.componentCount = total; + return total; +} + +/** + * Prune tree to specified depth + */ +function pruneToDepth(node: MapNode, depth: number, currentDepth = 0): void { + if (currentDepth >= depth) { + // At max depth, collapse children + node.children = []; + return; + } + + for (const child of node.children) { + pruneToDepth(child, depth, currentDepth + 1); + } +} + +/** + * Sort tree children alphabetically + */ +function sortTree(node: MapNode): void { + node.children.sort((a, b) => a.name.localeCompare(b.name)); + for (const child of node.children) { + sortTree(child); + } +} + +/** + * Count total components in tree + */ +function countComponents(node: MapNode): number { + return node.componentCount; +} + +/** + * Count total directories in tree + */ +function countDirectories(node: MapNode): number { + let count = 1; // Count this node + for (const child of node.children) { + count += countDirectories(child); + } + return count; +} + +/** + * Format codebase map as readable text + */ +export function formatCodebaseMap(map: CodebaseMap, options: MapOptions = {}): string { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const lines: string[] = []; + + lines.push('# Codebase Map'); + lines.push(''); + + // Format tree + formatNode(map.root, lines, '', true, opts); + + lines.push(''); + lines.push( + `**Total:** ${map.totalComponents} indexed components across ${map.totalDirectories} directories` + ); + + return lines.join('\n'); +} + +/** + * Format a single node in the tree + */ +function formatNode( + node: MapNode, + lines: string[], + prefix: string, + isLast: boolean, + opts: Required +): void { + const connector = isLast ? '└── ' : '├── '; + const countStr = node.componentCount > 0 ? ` (${node.componentCount} components)` : ''; + + lines.push(`${prefix}${connector}${node.name}/${countStr}`); + + // Add exports if present + if (opts.includeExports && node.exports && node.exports.length > 0) { + const exportPrefix = prefix + (isLast ? ' ' : '│ '); + const exportNames = node.exports.map((e) => e.name).join(', '); + lines.push(`${exportPrefix}└── exports: ${exportNames}`); + } + + // Format children + const childPrefix = prefix + (isLast ? ' ' : '│ '); + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const isChildLast = i === node.children.length - 1; + formatNode(child, lines, childPrefix, isChildLast, opts); + } +} diff --git a/packages/core/src/map/types.ts b/packages/core/src/map/types.ts new file mode 100644 index 0000000..ad21d29 --- /dev/null +++ b/packages/core/src/map/types.ts @@ -0,0 +1,64 @@ +/** + * Codebase Map Types + * Types for representing codebase structure + */ + +/** + * A node in the codebase map tree + */ +export interface MapNode { + /** Directory or file name */ + name: string; + /** Full path from repository root */ + path: string; + /** Number of indexed components in this node (recursive) */ + componentCount: number; + /** Child nodes (subdirectories) */ + children: MapNode[]; + /** Exported symbols from this directory (if includeExports is true) */ + exports?: ExportInfo[]; + /** Whether this is a leaf node (file, not directory) */ + isFile?: boolean; +} + +/** + * Information about an exported symbol + */ +export interface ExportInfo { + /** Symbol name */ + name: string; + /** Type of export (function, class, interface, type) */ + type: string; + /** File where it's defined */ + file: string; +} + +/** + * Options for generating a codebase map + */ +export interface MapOptions { + /** Maximum depth to traverse (1-5, default: 2) */ + depth?: number; + /** Focus on a specific directory path */ + focus?: string; + /** Include exported symbols (default: true) */ + includeExports?: boolean; + /** Maximum exports to show per directory (default: 5) */ + maxExportsPerDir?: number; + /** Token budget for output (default: 2000) */ + tokenBudget?: number; +} + +/** + * Result of codebase map generation + */ +export interface CodebaseMap { + /** Root node of the map tree */ + root: MapNode; + /** Total number of indexed components */ + totalComponents: number; + /** Total number of directories */ + totalDirectories: number; + /** Generation timestamp */ + generatedAt: string; +} diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index 6e27ce3..d6c9271 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -21,6 +21,7 @@ import { ExploreAdapter, GitHubAdapter, HealthAdapter, + MapAdapter, PlanAdapter, RefsAdapter, SearchAdapter, @@ -186,6 +187,12 @@ async function main() { defaultLimit: 20, }); + const mapAdapter = new MapAdapter({ + repositoryIndexer: indexer, + defaultDepth: 2, + defaultTokenBudget: 2000, + }); + // Create MCP server with coordinator const server = new MCPServer({ serverInfo: { @@ -205,6 +212,7 @@ async function main() { githubAdapter, healthAdapter, refsAdapter, + mapAdapter, ], coordinator, }); diff --git a/packages/mcp-server/src/adapters/__tests__/map-adapter.test.ts b/packages/mcp-server/src/adapters/__tests__/map-adapter.test.ts new file mode 100644 index 0000000..52453e8 --- /dev/null +++ b/packages/mcp-server/src/adapters/__tests__/map-adapter.test.ts @@ -0,0 +1,296 @@ +/** + * Tests for MapAdapter + */ + +import type { RepositoryIndexer, SearchResult } from '@lytics/dev-agent-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConsoleLogger } from '../../utils/logger'; +import { MapAdapter } from '../built-in/map-adapter'; +import type { AdapterContext, ToolExecutionContext } from '../types'; + +/** Type for MapAdapter result data */ +interface MapResultData { + content: string; + totalComponents: number; + totalDirectories: number; + depth: number; + focus: string | null; + truncated: boolean; +} + +describe('MapAdapter', () => { + let mockIndexer: RepositoryIndexer; + let adapter: MapAdapter; + let context: AdapterContext; + let execContext: ToolExecutionContext; + + // Mock search results representing indexed documents + const mockSearchResults: SearchResult[] = [ + { + id: 'packages/core/src/scanner/typescript.ts:TypeScriptScanner:19', + score: 0.9, + metadata: { + path: 'packages/core/src/scanner/typescript.ts', + type: 'class', + name: 'TypeScriptScanner', + exported: true, + }, + }, + { + id: 'packages/core/src/indexer/index.ts:RepositoryIndexer:10', + score: 0.8, + metadata: { + path: 'packages/core/src/indexer/index.ts', + type: 'class', + name: 'RepositoryIndexer', + exported: true, + }, + }, + { + id: 'packages/mcp-server/src/adapters/search-adapter.ts:SearchAdapter:35', + score: 0.75, + metadata: { + path: 'packages/mcp-server/src/adapters/search-adapter.ts', + type: 'class', + name: 'SearchAdapter', + exported: true, + }, + }, + ]; + + beforeEach(async () => { + // Create mock indexer + mockIndexer = { + search: vi.fn().mockResolvedValue(mockSearchResults), + } as unknown as RepositoryIndexer; + + // Create adapter + adapter = new MapAdapter({ + repositoryIndexer: mockIndexer, + defaultDepth: 2, + defaultTokenBudget: 2000, + }); + + // 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_map'); + expect(def.description).toContain('codebase structure'); + expect(def.inputSchema.type).toBe('object'); + expect(def.inputSchema.properties).toHaveProperty('depth'); + expect(def.inputSchema.properties).toHaveProperty('focus'); + expect(def.inputSchema.properties).toHaveProperty('includeExports'); + expect(def.inputSchema.properties).toHaveProperty('tokenBudget'); + }); + + it('should have no required parameters', () => { + const def = adapter.getToolDefinition(); + expect(def.inputSchema.required).toEqual([]); + }); + + it('should have correct depth constraints', () => { + const def = adapter.getToolDefinition(); + const depthProp = def.inputSchema.properties?.depth as { minimum: number; maximum: number }; + + expect(depthProp.minimum).toBe(1); + expect(depthProp.maximum).toBe(5); + }); + }); + + describe('Validation', () => { + it('should reject invalid depth', async () => { + const result = await adapter.execute({ depth: 10 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_DEPTH'); + }); + + it('should reject depth less than 1', async () => { + const result = await adapter.execute({ depth: 0 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_DEPTH'); + }); + + it('should reject invalid focus type', async () => { + const result = await adapter.execute({ focus: 123 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_FOCUS'); + }); + + it('should reject invalid token budget', async () => { + const result = await adapter.execute({ tokenBudget: 100 }, execContext); + + expect(result.success).toBe(false); + expect(result.error?.code).toBe('INVALID_TOKEN_BUDGET'); + }); + }); + + describe('Map Generation', () => { + it('should generate map with default options', async () => { + const result = await adapter.execute({}, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.content).toContain('# Codebase Map'); + expect(data.totalComponents).toBeGreaterThan(0); + expect(data.totalDirectories).toBeGreaterThan(0); + }); + + it('should respect depth parameter', async () => { + const result = await adapter.execute({ depth: 1 }, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.depth).toBe(1); + }); + + it('should respect focus parameter', async () => { + const result = await adapter.execute({ focus: 'packages/core' }, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.focus).toBe('packages/core'); + }); + + it('should include exports when requested', async () => { + // Use deeper depth to reach leaf directories with exports + const result = await adapter.execute({ includeExports: true, depth: 5 }, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.content).toContain('exports:'); + }); + + it('should exclude exports when requested', async () => { + const result = await adapter.execute({ includeExports: false }, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + // Content should not have exports line + expect(data.content).not.toContain('exports:'); + }); + }); + + describe('Output Format', () => { + it('should include tree structure', async () => { + const result = await adapter.execute({}, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.content).toMatch(/[├└]/); + }); + + it('should include component counts', async () => { + const result = await adapter.execute({}, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.content).toMatch(/\d+ components/); + }); + + it('should include total summary', async () => { + const result = await adapter.execute({}, execContext); + const data = result.data as MapResultData; + + expect(result.success).toBe(true); + expect(data.content).toContain('**Total:**'); + }); + }); + + describe('Metadata', () => { + it('should include token count', async () => { + const result = await adapter.execute({}, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.tokens).toBeDefined(); + expect(typeof result.metadata?.tokens).toBe('number'); + }); + + it('should include duration', async () => { + const result = await adapter.execute({}, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.duration_ms).toBeDefined(); + expect(typeof result.metadata?.duration_ms).toBe('number'); + }); + + it('should include timestamp', async () => { + const result = await adapter.execute({}, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.timestamp).toBeDefined(); + }); + }); + + describe('Token Budget', () => { + it('should respect token budget', async () => { + const result = await adapter.execute({ tokenBudget: 500 }, execContext); + + expect(result.success).toBe(true); + expect(result.metadata?.tokens).toBeLessThanOrEqual(600); // Some tolerance + }); + + it('should indicate truncation when depth is reduced', async () => { + // Create mock with lots of results to force truncation + const manyResults: SearchResult[] = Array.from({ length: 100 }, (_, i) => ({ + id: `packages/pkg${i}/src/file.ts:fn:1`, + score: 0.9, + metadata: { + path: `packages/pkg${i}/src/file.ts`, + type: 'function', + name: `fn${i}`, + exported: true, + }, + })); + + const largeIndexer = { + search: vi.fn().mockResolvedValue(manyResults), + } as unknown as RepositoryIndexer; + + const largeAdapter = new MapAdapter({ + repositoryIndexer: largeIndexer, + defaultDepth: 5, + defaultTokenBudget: 500, + }); + + await largeAdapter.initialize(context); + const result = await largeAdapter.execute({ depth: 5, tokenBudget: 500 }, execContext); + + expect(result.success).toBe(true); + // May or may not be truncated depending on output size + }); + }); + + describe('Token Estimation', () => { + it('should estimate tokens based on depth', () => { + const shallow = adapter.estimateTokens({ depth: 1 }); + const deep = adapter.estimateTokens({ depth: 5 }); + + expect(deep).toBeGreaterThan(shallow); + }); + + it('should respect token budget in estimation', () => { + const estimate = adapter.estimateTokens({ depth: 5, tokenBudget: 500 }); + + expect(estimate).toBeLessThanOrEqual(500); + }); + }); +}); diff --git a/packages/mcp-server/src/adapters/built-in/index.ts b/packages/mcp-server/src/adapters/built-in/index.ts index 0b07d55..a96e339 100644 --- a/packages/mcp-server/src/adapters/built-in/index.ts +++ b/packages/mcp-server/src/adapters/built-in/index.ts @@ -6,6 +6,7 @@ export { ExploreAdapter, type ExploreAdapterConfig } from './explore-adapter'; export { GitHubAdapter, type GitHubAdapterConfig } from './github-adapter'; export { HealthAdapter, type HealthCheckConfig } from './health-adapter'; +export { MapAdapter, type MapAdapterConfig } from './map-adapter'; export { PlanAdapter, type PlanAdapterConfig } from './plan-adapter'; export { RefsAdapter, type RefsAdapterConfig } from './refs-adapter'; export { SearchAdapter, type SearchAdapterConfig } from './search-adapter'; diff --git a/packages/mcp-server/src/adapters/built-in/map-adapter.ts b/packages/mcp-server/src/adapters/built-in/map-adapter.ts new file mode 100644 index 0000000..fe8512f --- /dev/null +++ b/packages/mcp-server/src/adapters/built-in/map-adapter.ts @@ -0,0 +1,247 @@ +/** + * Map Adapter + * Provides codebase structure overview via the dev_map tool + */ + +import { + formatCodebaseMap, + generateCodebaseMap, + type MapOptions, + type RepositoryIndexer, +} from '@lytics/dev-agent-core'; +import { estimateTokensForText, startTimer } from '../../formatters/utils'; +import { ToolAdapter } from '../tool-adapter'; +import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types'; + +/** + * Map adapter configuration + */ +export interface MapAdapterConfig { + /** + * Repository indexer instance + */ + repositoryIndexer: RepositoryIndexer; + + /** + * Default depth for map generation + */ + defaultDepth?: number; + + /** + * Default token budget + */ + defaultTokenBudget?: number; +} + +/** + * Map Adapter + * Implements the dev_map tool for codebase structure overview + */ +export class MapAdapter extends ToolAdapter { + readonly metadata = { + name: 'map-adapter', + version: '1.0.0', + description: 'Codebase structure overview adapter', + author: 'Dev-Agent Team', + }; + + private indexer: RepositoryIndexer; + private config: Required; + + constructor(config: MapAdapterConfig) { + super(); + this.indexer = config.repositoryIndexer; + this.config = { + repositoryIndexer: config.repositoryIndexer, + defaultDepth: config.defaultDepth ?? 2, + defaultTokenBudget: config.defaultTokenBudget ?? 2000, + }; + } + + async initialize(context: AdapterContext): Promise { + context.logger.info('MapAdapter initialized', { + defaultDepth: this.config.defaultDepth, + defaultTokenBudget: this.config.defaultTokenBudget, + }); + } + + getToolDefinition(): ToolDefinition { + return { + name: 'dev_map', + description: + 'Get a high-level overview of the codebase structure. Shows directories, component counts, and exported symbols.', + inputSchema: { + type: 'object', + properties: { + depth: { + type: 'number', + description: `Directory depth to show (1-5, default: ${this.config.defaultDepth})`, + minimum: 1, + maximum: 5, + default: this.config.defaultDepth, + }, + focus: { + type: 'string', + description: 'Focus on a specific directory path (e.g., "packages/core/src")', + }, + includeExports: { + type: 'boolean', + description: 'Include exported symbols in output (default: true)', + default: true, + }, + tokenBudget: { + type: 'number', + description: `Maximum tokens for output (default: ${this.config.defaultTokenBudget})`, + minimum: 500, + maximum: 10000, + default: this.config.defaultTokenBudget, + }, + }, + required: [], + }, + }; + } + + async execute(args: Record, context: ToolExecutionContext): Promise { + const { + depth = this.config.defaultDepth, + focus, + includeExports = true, + tokenBudget = this.config.defaultTokenBudget, + } = args as { + depth?: number; + focus?: string; + includeExports?: boolean; + tokenBudget?: number; + }; + + // Validate depth + if (typeof depth !== 'number' || depth < 1 || depth > 5) { + return { + success: false, + error: { + code: 'INVALID_DEPTH', + message: 'Depth must be a number between 1 and 5', + }, + }; + } + + // Validate focus if provided + if (focus !== undefined && typeof focus !== 'string') { + return { + success: false, + error: { + code: 'INVALID_FOCUS', + message: 'Focus must be a string path', + }, + }; + } + + // Validate tokenBudget + if (typeof tokenBudget !== 'number' || tokenBudget < 500 || tokenBudget > 10000) { + return { + success: false, + error: { + code: 'INVALID_TOKEN_BUDGET', + message: 'Token budget must be a number between 500 and 10000', + }, + }; + } + + try { + const timer = startTimer(); + context.logger.debug('Generating codebase map', { + depth, + focus, + includeExports, + tokenBudget, + }); + + const mapOptions: MapOptions = { + depth, + focus: focus || '', + includeExports, + tokenBudget, + }; + + // Generate the map + const map = await generateCodebaseMap(this.indexer, mapOptions); + + // Format the output + let content = formatCodebaseMap(map, mapOptions); + + // Check token budget and truncate if needed + let tokens = estimateTokensForText(content); + let truncated = false; + + if (tokens > tokenBudget) { + // Try reducing depth + let reducedDepth = depth; + while (tokens > tokenBudget && reducedDepth > 1) { + reducedDepth--; + const reducedMap = await generateCodebaseMap(this.indexer, { + ...mapOptions, + depth: reducedDepth, + }); + content = formatCodebaseMap(reducedMap, { ...mapOptions, depth: reducedDepth }); + tokens = estimateTokensForText(content); + truncated = true; + } + + if (truncated) { + content += `\n\n*Note: Depth reduced to ${reducedDepth} to fit token budget*`; + } + } + + const duration_ms = timer.elapsed(); + + context.logger.info('Codebase map generated', { + depth, + focus, + totalComponents: map.totalComponents, + totalDirectories: map.totalDirectories, + tokens, + truncated, + duration_ms, + }); + + return { + success: true, + data: { + content, + totalComponents: map.totalComponents, + totalDirectories: map.totalDirectories, + depth, + focus: focus || null, + truncated, + }, + metadata: { + tokens, + duration_ms, + timestamp: new Date().toISOString(), + cached: false, + }, + }; + } catch (error) { + context.logger.error('Map generation failed', { error }); + return { + success: false, + error: { + code: 'MAP_FAILED', + message: error instanceof Error ? error.message : 'Unknown error', + details: error, + }, + }; + } + } + + estimateTokens(args: Record): number { + const { depth = this.config.defaultDepth, tokenBudget = this.config.defaultTokenBudget } = args; + + // Estimate based on depth - each level roughly doubles the output + const baseTokens = 100; + const depthMultiplier = 2 ** ((depth as number) - 1); + + return Math.min(baseTokens * depthMultiplier, tokenBudget as number); + } +}