Skip to content

Commit ced0e18

Browse files
committed
feat: Pattern Momentum - detect migration direction via git history (v1.1.0)
1 parent 83c153f commit ced0e18

File tree

6 files changed

+147
-8
lines changed

6 files changed

+147
-8
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 1.1.0 (2025-12-15)
4+
5+
### Features
6+
7+
- **Pattern Momentum**: Detect migration direction via git history. Each pattern in `get_team_patterns` now includes:
8+
- `newestFileDate`: ISO timestamp of the most recent file using this pattern
9+
- `trend`: `Rising` (≤60 days), `Stable`, or `Declining` (≥180 days)
10+
- This solves the "3% Problem" — AI can now distinguish between legacy patterns being phased out vs. new patterns being adopted
11+
12+
### Technical
13+
14+
- New `src/utils/git-dates.ts`: Extracts file commit dates via single `git log` command
15+
- Updated `PatternDetector` to track temporal data per pattern
16+
- Graceful fallback for non-git repositories
17+
318
## 1.0.1 (2025-12-11)
419

520
### Fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Add this to your MCP client config (Claude Desktop, VS Code, Cursor, etc.).
2121

2222
- **Internal library discovery**`@mycompany/ui-toolkit`: 847 uses vs `primeng`: 3 uses
2323
- **Pattern frequencies**`inject()`: 97%, `constructor()`: 3%
24+
- **Pattern momentum**`Signals`: Rising (last used 2 days ago) vs `RxJS`: Declining (180+ days)
2425
- **Golden file examples** → Real implementations showing all patterns together
2526
- **Testing conventions**`Jest`: 74%, `Playwright`: 6%
2627
- **Framework patterns** → Angular signals, standalone components, etc.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-context",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"description": "MCP server for semantic codebase indexing and search - gives AI agents real understanding of your codebase",
55
"type": "module",
66
"main": "./dist/lib.js",

src/core/indexer.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
CodeChunkWithEmbedding,
2929
} from "../storage/index.js";
3030
import { LibraryUsageTracker, PatternDetector, ImportGraph } from "../utils/usage-tracker.js";
31+
import { getFileCommitDates } from "../utils/git-dates.js";
3132

3233
export interface IndexerOptions {
3334
rootPath: string;
@@ -173,6 +174,9 @@ export class CodebaseIndexer {
173174
const patternDetector = new PatternDetector();
174175
const importGraph = new ImportGraph();
175176

177+
// Fetch git commit dates for pattern momentum analysis
178+
const fileDates = await getFileCommitDates(this.rootPath);
179+
176180
for (let i = 0; i < files.length; i++) {
177181
const file = files[i];
178182
this.progress.currentFile = file;
@@ -212,6 +216,11 @@ export class CodebaseIndexer {
212216

213217
const relPath = file.split(/[\\/]/).slice(-3).join('/');
214218

219+
// Get file date for pattern momentum tracking
220+
// Try multiple path formats since git uses forward slashes
221+
const normalizedRelPath = path.relative(this.rootPath, file).replace(/\\/g, '/');
222+
const fileDate = fileDates.get(normalizedRelPath);
223+
215224
// GENERIC PATTERN FORWARDING
216225
// Framework analyzers return detectedPatterns in metadata - we just forward them
217226
// This keeps the indexer framework-agnostic
@@ -221,7 +230,7 @@ export class CodebaseIndexer {
221230
const snippetPattern = this.getSnippetPatternFor(pattern.category, pattern.name);
222231
const snippet = snippetPattern ? extractSnippet(snippetPattern) : undefined;
223232
patternDetector.track(pattern.category, pattern.name,
224-
snippet ? { file: relPath, snippet } : undefined);
233+
snippet ? { file: relPath, snippet } : undefined, fileDate);
225234
}
226235
}
227236

src/utils/git-dates.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Git Date Utility
3+
* Extracts file commit dates from git history for pattern momentum analysis
4+
*/
5+
6+
import { exec } from "child_process";
7+
import { promisify } from "util";
8+
import path from "path";
9+
10+
const execAsync = promisify(exec);
11+
12+
/**
13+
* Get the last commit date for each file in the repository.
14+
* Uses a single git command to efficiently extract all file dates.
15+
*
16+
* @param rootPath - Root path of the git repository
17+
* @returns Map of relative file paths to their last commit date
18+
*/
19+
export async function getFileCommitDates(rootPath: string): Promise<Map<string, Date>> {
20+
const fileDates = new Map<string, Date>();
21+
22+
try {
23+
// Single git command to get all file dates
24+
// Format: ":::ISO_DATE" followed by affected files on new lines
25+
const { stdout } = await execAsync(
26+
'git log --format=":::%cd" --name-only --date=iso-strict',
27+
{
28+
cwd: rootPath,
29+
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large repos
30+
}
31+
);
32+
33+
let currentDate: Date | null = null;
34+
35+
for (const line of stdout.split('\n')) {
36+
const trimmed = line.trim();
37+
38+
if (!trimmed) continue;
39+
40+
if (trimmed.startsWith(':::')) {
41+
// New commit date marker
42+
const dateStr = trimmed.slice(3);
43+
currentDate = new Date(dateStr);
44+
} else if (currentDate) {
45+
// File path - only store if we don't already have a date (first occurrence = most recent)
46+
const normalizedPath = trimmed.replace(/\\/g, '/');
47+
if (!fileDates.has(normalizedPath)) {
48+
fileDates.set(normalizedPath, currentDate);
49+
}
50+
}
51+
}
52+
53+
console.error(`[git-dates] Loaded commit dates for ${fileDates.size} files`);
54+
} catch (error) {
55+
// Not a git repo or git not available - graceful fallback
56+
const message = error instanceof Error ? error.message : String(error);
57+
if (message.includes('not a git repository') || message.includes('ENOENT')) {
58+
console.error('[git-dates] Not a git repository, skipping temporal analysis');
59+
} else {
60+
console.error(`[git-dates] Failed to get git dates: ${message}`);
61+
}
62+
}
63+
64+
return fileDates;
65+
}
66+
67+
/**
68+
* Calculate pattern trend based on file date.
69+
*
70+
* @param newestDate - The most recent file date using this pattern
71+
* @returns Trend classification
72+
*/
73+
export function calculateTrend(newestDate: Date | undefined): 'Rising' | 'Declining' | 'Stable' | undefined {
74+
if (!newestDate) return undefined;
75+
76+
const now = new Date();
77+
const daysDiff = Math.floor((now.getTime() - newestDate.getTime()) / (1000 * 60 * 60 * 24));
78+
79+
if (daysDiff <= 60) return 'Rising';
80+
if (daysDiff >= 180) return 'Declining';
81+
return 'Stable';
82+
}

src/utils/usage-tracker.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface LibraryUsageStats {
1010
};
1111
}
1212

13+
export type PatternTrend = 'Rising' | 'Declining' | 'Stable';
14+
1315
export interface PatternUsageStats {
1416
[patternName: string]: {
1517
primary: {
@@ -18,11 +20,15 @@ export interface PatternUsageStats {
1820
frequency: string;
1921
examples: string[];
2022
canonicalExample?: { file: string; snippet: string };
23+
newestFileDate?: string;
24+
trend?: PatternTrend;
2125
};
2226
alsoDetected?: Array<{
2327
name: string;
2428
count: number;
2529
frequency: string;
30+
newestFileDate?: string;
31+
trend?: PatternTrend;
2632
}>;
2733
};
2834
}
@@ -212,24 +218,36 @@ const DEFAULT_TEST_FRAMEWORK_CONFIGS: TestFrameworkConfig[] = [
212218
{ name: 'Generic Test', type: 'unit', indicators: ['describe(', 'it(', 'expect('], priority: 10 },
213219
];
214220

221+
import { calculateTrend } from "./git-dates.js";
222+
215223
export class PatternDetector {
216224
private patterns: Map<string, Map<string, number>> = new Map();
217225
private canonicalExamples: Map<string, { file: string; snippet: string }> = new Map();
226+
private patternFileDates: Map<string, Date> = new Map(); // Track newest file date per pattern
218227
private goldenFiles: GoldenFile[] = [];
219228
private testFrameworkConfigs: TestFrameworkConfig[];
220229

221230
constructor(customConfigs?: TestFrameworkConfig[]) {
222231
this.testFrameworkConfigs = customConfigs || DEFAULT_TEST_FRAMEWORK_CONFIGS;
223232
}
224233

225-
track(category: string, patternName: string, example?: { file: string; snippet: string }): void {
234+
track(category: string, patternName: string, example?: { file: string; snippet: string }, fileDate?: Date): void {
226235
if (!this.patterns.has(category)) {
227236
this.patterns.set(category, new Map());
228237
}
229238

230239
const categoryPatterns = this.patterns.get(category)!;
231240
categoryPatterns.set(patternName, (categoryPatterns.get(patternName) || 0) + 1);
232241

242+
// Track newest file date for this pattern
243+
if (fileDate) {
244+
const dateKey = `${category}:${patternName}`;
245+
const existing = this.patternFileDates.get(dateKey);
246+
if (!existing || fileDate > existing) {
247+
this.patternFileDates.set(dateKey, fileDate);
248+
}
249+
}
250+
233251
// Smart Canonical Example Selection
234252
const exampleKey = `${category}:${patternName}`;
235253

@@ -300,27 +318,41 @@ export class PatternDetector {
300318
const exampleKey = `${category}:${primaryName}`;
301319
const canonicalExample = this.canonicalExamples.get(exampleKey);
302320

321+
// Get temporal data for primary pattern
322+
const primaryDate = this.patternFileDates.get(exampleKey);
323+
const primaryTrend = calculateTrend(primaryDate);
324+
303325
const result: PatternUsageStats[string] = {
304326
primary: {
305327
name: primaryName,
306328
count: primaryCount,
307329
frequency: `${primaryFreq}%`,
308330
examples: canonicalExample ? [canonicalExample.file] : [],
309331
canonicalExample: canonicalExample,
332+
newestFileDate: primaryDate?.toISOString(),
333+
trend: primaryTrend,
310334
},
311335
};
312336

313337
if (sorted.length > 1) {
314-
result.alsoDetected = sorted.slice(1, 3).map(([name, count]) => ({
315-
name,
316-
count,
317-
frequency: `${Math.round((count / total) * 100)}%`,
318-
}));
338+
result.alsoDetected = sorted.slice(1, 3).map(([name, count]) => {
339+
const altKey = `${category}:${name}`;
340+
const altDate = this.patternFileDates.get(altKey);
341+
const altTrend = calculateTrend(altDate);
342+
return {
343+
name,
344+
count,
345+
frequency: `${Math.round((count / total) * 100)}%`,
346+
newestFileDate: altDate?.toISOString(),
347+
trend: altTrend,
348+
};
349+
});
319350
}
320351

321352
return result;
322353
}
323354

355+
324356
getAllPatterns(): PatternUsageStats {
325357
const stats: PatternUsageStats = {};
326358

0 commit comments

Comments
 (0)