diff --git a/packages/core/src/map/__tests__/map.test.ts b/packages/core/src/map/__tests__/map.test.ts index d8a99c3..f1aa062 100644 --- a/packages/core/src/map/__tests__/map.test.ts +++ b/packages/core/src/map/__tests__/map.test.ts @@ -657,4 +657,95 @@ describe('Codebase Map', () => { expect(map.totalComponents).toBe(1); }); }); + + describe('Change Frequency', () => { + it('should include change frequency when enabled with git extractor', async () => { + const mockGitExtractor = { + getCommits: vi.fn().mockResolvedValue([ + { + hash: 'abc123', + shortHash: 'abc123', + subject: 'feat: test', + message: 'feat: test', + body: '', + author: { name: 'Test', email: 'test@test.com', date: new Date().toISOString() }, + committer: { name: 'Test', email: 'test@test.com', date: new Date().toISOString() }, + files: [], + stats: { additions: 0, deletions: 0, filesChanged: 0 }, + refs: { branches: [], tags: [], issueRefs: [], prRefs: [] }, + parents: [], + }, + { + hash: 'def456', + shortHash: 'def456', + subject: 'fix: test', + message: 'fix: test', + body: '', + author: { + name: 'Test', + email: 'test@test.com', + date: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(), // 45 days ago + }, + committer: { + name: 'Test', + email: 'test@test.com', + date: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(), + }, + files: [], + stats: { additions: 0, deletions: 0, filesChanged: 0 }, + refs: { branches: [], tags: [], issueRefs: [], prRefs: [] }, + parents: [], + }, + ]), + }; + + const indexer = createMockIndexer(mockSearchResults); + const map = await generateCodebaseMap( + { indexer, gitExtractor: mockGitExtractor as any }, + { includeChangeFrequency: true } + ); + + // Root should have change frequency + expect(map.root.changeFrequency).toBeDefined(); + expect(map.root.changeFrequency?.last90Days).toBeGreaterThan(0); + }); + + it('should not include change frequency when disabled', async () => { + const indexer = createMockIndexer(mockSearchResults); + const map = await generateCodebaseMap(indexer, { includeChangeFrequency: false }); + + expect(map.root.changeFrequency).toBeUndefined(); + }); + + it('should format change frequency in output', async () => { + const mockGitExtractor = { + getCommits: vi.fn().mockResolvedValue([ + { + hash: 'abc123', + shortHash: 'abc123', + subject: 'feat: test', + message: 'feat: test', + body: '', + author: { name: 'Test', email: 'test@test.com', date: new Date().toISOString() }, + committer: { name: 'Test', email: 'test@test.com', date: new Date().toISOString() }, + files: [], + stats: { additions: 0, deletions: 0, filesChanged: 0 }, + refs: { branches: [], tags: [], issueRefs: [], prRefs: [] }, + parents: [], + }, + ]), + }; + + const indexer = createMockIndexer(mockSearchResults); + const map = await generateCodebaseMap( + { indexer, gitExtractor: mockGitExtractor as any }, + { includeChangeFrequency: true } + ); + + const formatted = formatCodebaseMap(map, { includeChangeFrequency: true }); + + // Should include some frequency indicator + expect(formatted).toContain('commits'); + }); + }); }); diff --git a/packages/core/src/map/index.ts b/packages/core/src/map/index.ts index e20c797..5c986a1 100644 --- a/packages/core/src/map/index.ts +++ b/packages/core/src/map/index.ts @@ -4,9 +4,17 @@ */ import * as path from 'node:path'; +import type { LocalGitExtractor } from '../git/extractor'; import type { RepositoryIndexer } from '../indexer'; import type { SearchResult } from '../vector/types'; -import type { CodebaseMap, ExportInfo, HotPath, MapNode, MapOptions } from './types'; +import type { + ChangeFrequency, + CodebaseMap, + ExportInfo, + HotPath, + MapNode, + MapOptions, +} from './types'; export * from './types'; @@ -21,8 +29,15 @@ const DEFAULT_OPTIONS: Required = { smartDepth: false, smartDepthThreshold: 10, tokenBudget: 2000, + includeChangeFrequency: false, }; +/** Context for map generation including optional git extractor */ +export interface MapGenerationContext { + indexer: RepositoryIndexer; + gitExtractor?: LocalGitExtractor; +} + /** * Generate a codebase map from indexed documents * @@ -32,13 +47,36 @@ const DEFAULT_OPTIONS: Required = { */ export async function generateCodebaseMap( indexer: RepositoryIndexer, - options: MapOptions = {} + options?: MapOptions +): Promise; + +/** + * Generate a codebase map with git history context + * + * @param context - Map generation context with indexer and optional git extractor + * @param options - Map generation options + * @returns Codebase map structure + */ +export async function generateCodebaseMap( + context: MapGenerationContext, + options?: MapOptions +): Promise; + +export async function generateCodebaseMap( + indexerOrContext: RepositoryIndexer | MapGenerationContext, + options?: MapOptions ): Promise { - const opts = { ...DEFAULT_OPTIONS, ...options }; + const opts = { ...DEFAULT_OPTIONS, ...(options || {}) }; + + // Normalize input + const context: MapGenerationContext = + 'indexer' in indexerOrContext + ? indexerOrContext + : { indexer: indexerOrContext as RepositoryIndexer }; // 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', { + const allDocs = await context.indexer.search('function class interface type', { limit: 10000, scoreThreshold: 0, }); @@ -53,6 +91,11 @@ export async function generateCodebaseMap( // Compute hot paths (most referenced files) const hotPaths = opts.includeHotPaths ? computeHotPaths(allDocs, opts.maxHotPaths) : []; + // Compute change frequency if requested and git extractor is available + if (opts.includeChangeFrequency && context.gitExtractor) { + await computeChangeFrequency(root, context.gitExtractor); + } + return { root, totalComponents, @@ -274,6 +317,86 @@ function countDirectories(node: MapNode): number { return count; } +/** + * Compute change frequency for all nodes in the tree + */ +async function computeChangeFrequency(root: MapNode, extractor: LocalGitExtractor): Promise { + // Collect all unique directory paths + const dirPaths = collectDirectoryPaths(root); + + // Get date thresholds + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + + // Compute frequency for each directory + const frequencyMap = new Map(); + + for (const dirPath of dirPaths) { + try { + // Get commits for this directory in the last 90 days + const commits = await extractor.getCommits({ + path: dirPath === 'root' ? '.' : dirPath, + limit: 100, + since: ninetyDaysAgo.toISOString(), + noMerges: true, + }); + + // Count commits in each time window + let last30Days = 0; + const last90Days = commits.length; + let lastCommit: string | undefined; + + for (const commit of commits) { + const commitDate = new Date(commit.author.date); + if (commitDate >= thirtyDaysAgo) { + last30Days++; + } + if (!lastCommit || commitDate > new Date(lastCommit)) { + lastCommit = commit.author.date; + } + } + + frequencyMap.set(dirPath, { + last30Days, + last90Days, + lastCommit, + }); + } catch { + // Directory might not exist in git or other error + // Just skip it + } + } + + // Apply frequency data to tree nodes + applyChangeFrequency(root, frequencyMap); +} + +/** + * Collect all directory paths from the tree + */ +function collectDirectoryPaths(node: MapNode, paths: string[] = []): string[] { + paths.push(node.path); + for (const child of node.children) { + collectDirectoryPaths(child, paths); + } + return paths; +} + +/** + * Apply change frequency data to tree nodes + */ +function applyChangeFrequency(node: MapNode, frequencyMap: Map): void { + const freq = frequencyMap.get(node.path); + if (freq) { + node.changeFrequency = freq; + } + + for (const child of node.children) { + applyChangeFrequency(child, frequencyMap); + } +} + /** * Compute hot paths - files with the most incoming references */ @@ -369,7 +492,23 @@ function formatNode( const connector = isLast ? '└── ' : '├── '; const countStr = node.componentCount > 0 ? ` (${node.componentCount} components)` : ''; - lines.push(`${prefix}${connector}${node.name}/${countStr}`); + // Add change frequency indicator if available + let freqStr = ''; + if (opts.includeChangeFrequency && node.changeFrequency) { + const freq = node.changeFrequency; + if (freq.last30Days > 0) { + // Hot: 5+ commits in 30 days + if (freq.last30Days >= 5) { + freqStr = ` 🔥 ${freq.last30Days} commits this month`; + } else { + freqStr = ` ✏️ ${freq.last30Days} commits this month`; + } + } else if (freq.last90Days > 0) { + freqStr = ` 📝 ${freq.last90Days} commits (90d)`; + } + } + + lines.push(`${prefix}${connector}${node.name}/${countStr}${freqStr}`); // Add exports if present if (opts.includeExports && node.exports && node.exports.length > 0) { diff --git a/packages/core/src/map/types.ts b/packages/core/src/map/types.ts index 7e0d507..975a50c 100644 --- a/packages/core/src/map/types.ts +++ b/packages/core/src/map/types.ts @@ -3,6 +3,18 @@ * Types for representing codebase structure */ +/** + * Change frequency data for a node + */ +export interface ChangeFrequency { + /** Number of commits in the last 30 days */ + last30Days: number; + /** Number of commits in the last 90 days */ + last90Days: number; + /** Date of the most recent commit */ + lastCommit?: string; +} + /** * A node in the codebase map tree */ @@ -19,6 +31,8 @@ export interface MapNode { exports?: ExportInfo[]; /** Whether this is a leaf node (file, not directory) */ isFile?: boolean; + /** Change frequency data (if includeChangeFrequency is true) */ + changeFrequency?: ChangeFrequency; } /** @@ -57,6 +71,8 @@ export interface MapOptions { smartDepthThreshold?: number; /** Token budget for output (default: 2000) */ tokenBudget?: number; + /** Include change frequency data (default: false) */ + includeChangeFrequency?: boolean; } /** diff --git a/packages/mcp-server/bin/dev-agent-mcp.ts b/packages/mcp-server/bin/dev-agent-mcp.ts index 6130073..eb42fca 100644 --- a/packages/mcp-server/bin/dev-agent-mcp.ts +++ b/packages/mcp-server/bin/dev-agent-mcp.ts @@ -193,6 +193,7 @@ async function main() { const mapAdapter = new MapAdapter({ repositoryIndexer: indexer, + repositoryPath, defaultDepth: 2, defaultTokenBudget: 2000, }); diff --git a/packages/mcp-server/src/adapters/built-in/map-adapter.ts b/packages/mcp-server/src/adapters/built-in/map-adapter.ts index fe8512f..f394b34 100644 --- a/packages/mcp-server/src/adapters/built-in/map-adapter.ts +++ b/packages/mcp-server/src/adapters/built-in/map-adapter.ts @@ -6,6 +6,7 @@ import { formatCodebaseMap, generateCodebaseMap, + LocalGitExtractor, type MapOptions, type RepositoryIndexer, } from '@lytics/dev-agent-core'; @@ -22,6 +23,11 @@ export interface MapAdapterConfig { */ repositoryIndexer: RepositoryIndexer; + /** + * Repository path for git operations + */ + repositoryPath?: string; + /** * Default depth for map generation */ @@ -46,11 +52,13 @@ export class MapAdapter extends ToolAdapter { }; private indexer: RepositoryIndexer; - private config: Required; + private repositoryPath?: string; + private config: Required>; constructor(config: MapAdapterConfig) { super(); this.indexer = config.repositoryIndexer; + this.repositoryPath = config.repositoryPath; this.config = { repositoryIndexer: config.repositoryIndexer, defaultDepth: config.defaultDepth ?? 2, @@ -96,6 +104,12 @@ export class MapAdapter extends ToolAdapter { maximum: 10000, default: this.config.defaultTokenBudget, }, + includeChangeFrequency: { + type: 'boolean', + description: + 'Include change frequency (commits per directory) - requires git access (default: false)', + default: false, + }, }, required: [], }, @@ -108,11 +122,13 @@ export class MapAdapter extends ToolAdapter { focus, includeExports = true, tokenBudget = this.config.defaultTokenBudget, + includeChangeFrequency = false, } = args as { depth?: number; focus?: string; includeExports?: boolean; tokenBudget?: number; + includeChangeFrequency?: boolean; }; // Validate depth @@ -155,6 +171,7 @@ export class MapAdapter extends ToolAdapter { focus, includeExports, tokenBudget, + includeChangeFrequency, }); const mapOptions: MapOptions = { @@ -162,10 +179,17 @@ export class MapAdapter extends ToolAdapter { focus: focus || '', includeExports, tokenBudget, + includeChangeFrequency, }; + // Create git extractor if change frequency is requested + const gitExtractor = + includeChangeFrequency && this.repositoryPath + ? new LocalGitExtractor(this.repositoryPath) + : undefined; + // Generate the map - const map = await generateCodebaseMap(this.indexer, mapOptions); + const map = await generateCodebaseMap({ indexer: this.indexer, gitExtractor }, mapOptions); // Format the output let content = formatCodebaseMap(map, mapOptions); @@ -179,10 +203,10 @@ export class MapAdapter extends ToolAdapter { let reducedDepth = depth; while (tokens > tokenBudget && reducedDepth > 1) { reducedDepth--; - const reducedMap = await generateCodebaseMap(this.indexer, { - ...mapOptions, - depth: reducedDepth, - }); + const reducedMap = await generateCodebaseMap( + { indexer: this.indexer, gitExtractor }, + { ...mapOptions, depth: reducedDepth } + ); content = formatCodebaseMap(reducedMap, { ...mapOptions, depth: reducedDepth }); tokens = estimateTokensForText(content); truncated = true;