Skip to content

Commit f48dfc5

Browse files
committed
feat(core): add change frequency to codebase map (#94)
- Add ChangeFrequency type with last30Days, last90Days, lastCommit - Add includeChangeFrequency option to MapOptions - Implement computeChangeFrequency using git extractor - Display frequency indicators in formatted output (🔥 hot, ✏️ active, 📝 recent) - Update MapAdapter to support change frequency with repositoryPath - Add MapGenerationContext for flexible indexer + git extractor passing - Add 3 tests for change frequency feature Part of Epic: Intelligent Git History (v0.4.0) #90
1 parent 4d3b6b6 commit f48dfc5

File tree

5 files changed

+282
-11
lines changed

5 files changed

+282
-11
lines changed

packages/core/src/map/__tests__/map.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,4 +657,95 @@ describe('Codebase Map', () => {
657657
expect(map.totalComponents).toBe(1);
658658
});
659659
});
660+
661+
describe('Change Frequency', () => {
662+
it('should include change frequency when enabled with git extractor', async () => {
663+
const mockGitExtractor = {
664+
getCommits: vi.fn().mockResolvedValue([
665+
{
666+
hash: 'abc123',
667+
shortHash: 'abc123',
668+
subject: 'feat: test',
669+
message: 'feat: test',
670+
body: '',
671+
author: { name: 'Test', email: '[email protected]', date: new Date().toISOString() },
672+
committer: { name: 'Test', email: '[email protected]', date: new Date().toISOString() },
673+
files: [],
674+
stats: { additions: 0, deletions: 0, filesChanged: 0 },
675+
refs: { branches: [], tags: [], issueRefs: [], prRefs: [] },
676+
parents: [],
677+
},
678+
{
679+
hash: 'def456',
680+
shortHash: 'def456',
681+
subject: 'fix: test',
682+
message: 'fix: test',
683+
body: '',
684+
author: {
685+
name: 'Test',
686+
687+
date: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(), // 45 days ago
688+
},
689+
committer: {
690+
name: 'Test',
691+
692+
date: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString(),
693+
},
694+
files: [],
695+
stats: { additions: 0, deletions: 0, filesChanged: 0 },
696+
refs: { branches: [], tags: [], issueRefs: [], prRefs: [] },
697+
parents: [],
698+
},
699+
]),
700+
};
701+
702+
const indexer = createMockIndexer(mockSearchResults);
703+
const map = await generateCodebaseMap(
704+
{ indexer, gitExtractor: mockGitExtractor as any },
705+
{ includeChangeFrequency: true }
706+
);
707+
708+
// Root should have change frequency
709+
expect(map.root.changeFrequency).toBeDefined();
710+
expect(map.root.changeFrequency?.last90Days).toBeGreaterThan(0);
711+
});
712+
713+
it('should not include change frequency when disabled', async () => {
714+
const indexer = createMockIndexer(mockSearchResults);
715+
const map = await generateCodebaseMap(indexer, { includeChangeFrequency: false });
716+
717+
expect(map.root.changeFrequency).toBeUndefined();
718+
});
719+
720+
it('should format change frequency in output', async () => {
721+
const mockGitExtractor = {
722+
getCommits: vi.fn().mockResolvedValue([
723+
{
724+
hash: 'abc123',
725+
shortHash: 'abc123',
726+
subject: 'feat: test',
727+
message: 'feat: test',
728+
body: '',
729+
author: { name: 'Test', email: '[email protected]', date: new Date().toISOString() },
730+
committer: { name: 'Test', email: '[email protected]', date: new Date().toISOString() },
731+
files: [],
732+
stats: { additions: 0, deletions: 0, filesChanged: 0 },
733+
refs: { branches: [], tags: [], issueRefs: [], prRefs: [] },
734+
parents: [],
735+
},
736+
]),
737+
};
738+
739+
const indexer = createMockIndexer(mockSearchResults);
740+
const map = await generateCodebaseMap(
741+
{ indexer, gitExtractor: mockGitExtractor as any },
742+
{ includeChangeFrequency: true }
743+
);
744+
745+
const formatted = formatCodebaseMap(map, { includeChangeFrequency: true });
746+
747+
// Should include some frequency indicator
748+
expect(formatted).toContain('commits');
749+
});
750+
});
660751
});

packages/core/src/map/index.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@
44
*/
55

66
import * as path from 'node:path';
7+
import type { LocalGitExtractor } from '../git/extractor';
78
import type { RepositoryIndexer } from '../indexer';
89
import type { SearchResult } from '../vector/types';
9-
import type { CodebaseMap, ExportInfo, HotPath, MapNode, MapOptions } from './types';
10+
import type {
11+
ChangeFrequency,
12+
CodebaseMap,
13+
ExportInfo,
14+
HotPath,
15+
MapNode,
16+
MapOptions,
17+
} from './types';
1018

1119
export * from './types';
1220

@@ -21,8 +29,15 @@ const DEFAULT_OPTIONS: Required<MapOptions> = {
2129
smartDepth: false,
2230
smartDepthThreshold: 10,
2331
tokenBudget: 2000,
32+
includeChangeFrequency: false,
2433
};
2534

35+
/** Context for map generation including optional git extractor */
36+
export interface MapGenerationContext {
37+
indexer: RepositoryIndexer;
38+
gitExtractor?: LocalGitExtractor;
39+
}
40+
2641
/**
2742
* Generate a codebase map from indexed documents
2843
*
@@ -32,13 +47,36 @@ const DEFAULT_OPTIONS: Required<MapOptions> = {
3247
*/
3348
export async function generateCodebaseMap(
3449
indexer: RepositoryIndexer,
35-
options: MapOptions = {}
50+
options?: MapOptions
51+
): Promise<CodebaseMap>;
52+
53+
/**
54+
* Generate a codebase map with git history context
55+
*
56+
* @param context - Map generation context with indexer and optional git extractor
57+
* @param options - Map generation options
58+
* @returns Codebase map structure
59+
*/
60+
export async function generateCodebaseMap(
61+
context: MapGenerationContext,
62+
options?: MapOptions
63+
): Promise<CodebaseMap>;
64+
65+
export async function generateCodebaseMap(
66+
indexerOrContext: RepositoryIndexer | MapGenerationContext,
67+
options?: MapOptions
3668
): Promise<CodebaseMap> {
37-
const opts = { ...DEFAULT_OPTIONS, ...options };
69+
const opts = { ...DEFAULT_OPTIONS, ...(options || {}) };
70+
71+
// Normalize input
72+
const context: MapGenerationContext =
73+
'indexer' in indexerOrContext
74+
? indexerOrContext
75+
: { indexer: indexerOrContext as RepositoryIndexer };
3876

3977
// Get all indexed documents (use a broad search)
4078
// Note: We search with a generic query to get all documents
41-
const allDocs = await indexer.search('function class interface type', {
79+
const allDocs = await context.indexer.search('function class interface type', {
4280
limit: 10000,
4381
scoreThreshold: 0,
4482
});
@@ -53,6 +91,11 @@ export async function generateCodebaseMap(
5391
// Compute hot paths (most referenced files)
5492
const hotPaths = opts.includeHotPaths ? computeHotPaths(allDocs, opts.maxHotPaths) : [];
5593

94+
// Compute change frequency if requested and git extractor is available
95+
if (opts.includeChangeFrequency && context.gitExtractor) {
96+
await computeChangeFrequency(root, context.gitExtractor);
97+
}
98+
5699
return {
57100
root,
58101
totalComponents,
@@ -274,6 +317,86 @@ function countDirectories(node: MapNode): number {
274317
return count;
275318
}
276319

320+
/**
321+
* Compute change frequency for all nodes in the tree
322+
*/
323+
async function computeChangeFrequency(root: MapNode, extractor: LocalGitExtractor): Promise<void> {
324+
// Collect all unique directory paths
325+
const dirPaths = collectDirectoryPaths(root);
326+
327+
// Get date thresholds
328+
const now = new Date();
329+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
330+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
331+
332+
// Compute frequency for each directory
333+
const frequencyMap = new Map<string, ChangeFrequency>();
334+
335+
for (const dirPath of dirPaths) {
336+
try {
337+
// Get commits for this directory in the last 90 days
338+
const commits = await extractor.getCommits({
339+
path: dirPath === 'root' ? '.' : dirPath,
340+
limit: 100,
341+
since: ninetyDaysAgo.toISOString(),
342+
noMerges: true,
343+
});
344+
345+
// Count commits in each time window
346+
let last30Days = 0;
347+
const last90Days = commits.length;
348+
let lastCommit: string | undefined;
349+
350+
for (const commit of commits) {
351+
const commitDate = new Date(commit.author.date);
352+
if (commitDate >= thirtyDaysAgo) {
353+
last30Days++;
354+
}
355+
if (!lastCommit || commitDate > new Date(lastCommit)) {
356+
lastCommit = commit.author.date;
357+
}
358+
}
359+
360+
frequencyMap.set(dirPath, {
361+
last30Days,
362+
last90Days,
363+
lastCommit,
364+
});
365+
} catch {
366+
// Directory might not exist in git or other error
367+
// Just skip it
368+
}
369+
}
370+
371+
// Apply frequency data to tree nodes
372+
applyChangeFrequency(root, frequencyMap);
373+
}
374+
375+
/**
376+
* Collect all directory paths from the tree
377+
*/
378+
function collectDirectoryPaths(node: MapNode, paths: string[] = []): string[] {
379+
paths.push(node.path);
380+
for (const child of node.children) {
381+
collectDirectoryPaths(child, paths);
382+
}
383+
return paths;
384+
}
385+
386+
/**
387+
* Apply change frequency data to tree nodes
388+
*/
389+
function applyChangeFrequency(node: MapNode, frequencyMap: Map<string, ChangeFrequency>): void {
390+
const freq = frequencyMap.get(node.path);
391+
if (freq) {
392+
node.changeFrequency = freq;
393+
}
394+
395+
for (const child of node.children) {
396+
applyChangeFrequency(child, frequencyMap);
397+
}
398+
}
399+
277400
/**
278401
* Compute hot paths - files with the most incoming references
279402
*/
@@ -369,7 +492,23 @@ function formatNode(
369492
const connector = isLast ? '└── ' : '├── ';
370493
const countStr = node.componentCount > 0 ? ` (${node.componentCount} components)` : '';
371494

372-
lines.push(`${prefix}${connector}${node.name}/${countStr}`);
495+
// Add change frequency indicator if available
496+
let freqStr = '';
497+
if (opts.includeChangeFrequency && node.changeFrequency) {
498+
const freq = node.changeFrequency;
499+
if (freq.last30Days > 0) {
500+
// Hot: 5+ commits in 30 days
501+
if (freq.last30Days >= 5) {
502+
freqStr = ` 🔥 ${freq.last30Days} commits this month`;
503+
} else {
504+
freqStr = ` ✏️ ${freq.last30Days} commits this month`;
505+
}
506+
} else if (freq.last90Days > 0) {
507+
freqStr = ` 📝 ${freq.last90Days} commits (90d)`;
508+
}
509+
}
510+
511+
lines.push(`${prefix}${connector}${node.name}/${countStr}${freqStr}`);
373512

374513
// Add exports if present
375514
if (opts.includeExports && node.exports && node.exports.length > 0) {

packages/core/src/map/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
* Types for representing codebase structure
44
*/
55

6+
/**
7+
* Change frequency data for a node
8+
*/
9+
export interface ChangeFrequency {
10+
/** Number of commits in the last 30 days */
11+
last30Days: number;
12+
/** Number of commits in the last 90 days */
13+
last90Days: number;
14+
/** Date of the most recent commit */
15+
lastCommit?: string;
16+
}
17+
618
/**
719
* A node in the codebase map tree
820
*/
@@ -19,6 +31,8 @@ export interface MapNode {
1931
exports?: ExportInfo[];
2032
/** Whether this is a leaf node (file, not directory) */
2133
isFile?: boolean;
34+
/** Change frequency data (if includeChangeFrequency is true) */
35+
changeFrequency?: ChangeFrequency;
2236
}
2337

2438
/**
@@ -57,6 +71,8 @@ export interface MapOptions {
5771
smartDepthThreshold?: number;
5872
/** Token budget for output (default: 2000) */
5973
tokenBudget?: number;
74+
/** Include change frequency data (default: false) */
75+
includeChangeFrequency?: boolean;
6076
}
6177

6278
/**

packages/mcp-server/bin/dev-agent-mcp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ async function main() {
193193

194194
const mapAdapter = new MapAdapter({
195195
repositoryIndexer: indexer,
196+
repositoryPath,
196197
defaultDepth: 2,
197198
defaultTokenBudget: 2000,
198199
});

0 commit comments

Comments
 (0)