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
91 changes: 91 additions & 0 deletions packages/core/src/map/__tests__/map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]', date: new Date().toISOString() },
committer: { name: 'Test', email: '[email protected]', 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: '[email protected]',
date: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(), // 45 days ago
},
committer: {
name: 'Test',
email: '[email protected]',
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: '[email protected]', date: new Date().toISOString() },
committer: { name: 'Test', email: '[email protected]', 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');
});
});
});
149 changes: 144 additions & 5 deletions packages/core/src/map/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,8 +29,15 @@ const DEFAULT_OPTIONS: Required<MapOptions> = {
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
*
Expand All @@ -32,13 +47,36 @@ const DEFAULT_OPTIONS: Required<MapOptions> = {
*/
export async function generateCodebaseMap(
indexer: RepositoryIndexer,
options: MapOptions = {}
options?: MapOptions
): Promise<CodebaseMap>;

/**
* 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<CodebaseMap>;

export async function generateCodebaseMap(
indexerOrContext: RepositoryIndexer | MapGenerationContext,
options?: MapOptions
): Promise<CodebaseMap> {
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,
});
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
// 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<string, ChangeFrequency>();

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<string, ChangeFrequency>): 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
*/
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-server/bin/dev-agent-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ async function main() {

const mapAdapter = new MapAdapter({
repositoryIndexer: indexer,
repositoryPath,
defaultDepth: 2,
defaultTokenBudget: 2000,
});
Expand Down
Loading