Skip to content

Commit 90e66e1

Browse files
committed
feat(core): integrate change frequency and add comparison/export utilities
- Integrate change frequency into getStats() for enriched metrics - Language stats now include avgCommitsPerFile and lastModified - Package stats now include totalCommits and lastModified - Graceful degradation with console.warn when git unavailable - Add compareStats() for trend analysis between snapshots - Add export utilities (JSON, CSV, Markdown table) - Relax RepositoryMetadata.remote validation (allow non-URL values) - Mock console.error in event-bus test to prevent CI pollution All 598 core tests passing with clean output. Sets foundation for metrics store by: - Enabling actual use of change frequency data - Providing comparison capabilities for trends - Supporting data export for dashboards
1 parent 468b2bf commit 90e66e1

File tree

8 files changed

+1262
-8
lines changed

8 files changed

+1262
-8
lines changed

packages/core/src/events/__tests__/event-bus.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ describe('AsyncEventBus', () => {
237237

238238
describe('error handling', () => {
239239
it('should not crash on handler error', async () => {
240+
// Mock console.error to suppress expected error logs in test output
241+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
242+
240243
const errorHandler = vi.fn().mockRejectedValue(new Error('Handler error'));
241244
const goodHandler = vi.fn();
242245

@@ -249,6 +252,9 @@ describe('AsyncEventBus', () => {
249252

250253
expect(errorHandler).toHaveBeenCalled();
251254
expect(goodHandler).toHaveBeenCalled();
255+
256+
// Restore console.error
257+
consoleErrorSpy.mockRestore();
252258
});
253259
});
254260
});

packages/core/src/indexer/index.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ import type {
2121
IndexerState,
2222
IndexOptions,
2323
IndexStats,
24+
LanguageStats,
25+
PackageStats,
2426
SupportedLanguage,
2527
UpdateOptions,
2628
} from './types';
2729
import { getExtensionForLanguage, prepareDocumentsForEmbedding } from './utils';
30+
import { aggregateChangeFrequency, calculateChangeFrequency } from './utils/change-frequency.js';
2831

2932
const INDEXER_VERSION = '1.0.0';
3033
const DEFAULT_STATE_PATH = '.dev-agent/indexer-state.json';
@@ -451,6 +454,14 @@ export class RepositoryIndexer {
451454
const incrementalUpdatesSince = this.state.incrementalUpdatesSince || 0;
452455
const warning = this.getStatsWarning(incrementalUpdatesSince);
453456

457+
// Enrich stats with change frequency (optional, non-blocking)
458+
const enrichedByLanguage = await this.enrichLanguageStatsWithChangeFrequency(
459+
this.state.stats.byLanguage
460+
);
461+
const enrichedByPackage = await this.enrichPackageStatsWithChangeFrequency(
462+
this.state.stats.byPackage
463+
);
464+
454465
const stats = {
455466
filesScanned: this.state.stats.totalFiles,
456467
documentsExtracted: this.state.stats.totalDocuments,
@@ -461,9 +472,9 @@ export class RepositoryIndexer {
461472
startTime: this.state.lastIndexTime,
462473
endTime: this.state.lastIndexTime,
463474
repositoryPath: this.state.repositoryPath,
464-
byLanguage: this.state.stats.byLanguage,
475+
byLanguage: enrichedByLanguage,
465476
byComponentType: this.state.stats.byComponentType,
466-
byPackage: this.state.stats.byPackage,
477+
byPackage: enrichedByPackage,
467478
statsMetadata: {
468479
isIncremental: false, // getStats returns full picture
469480
lastFullIndex,
@@ -483,6 +494,114 @@ export class RepositoryIndexer {
483494
return validation.data;
484495
}
485496

497+
/**
498+
* Enrich language stats with change frequency data
499+
* Non-blocking: returns original stats if git analysis fails
500+
*/
501+
private async enrichLanguageStatsWithChangeFrequency(
502+
byLanguage?: Partial<Record<SupportedLanguage, LanguageStats>>
503+
): Promise<Partial<Record<SupportedLanguage, LanguageStats>> | undefined> {
504+
if (!byLanguage) return byLanguage;
505+
506+
try {
507+
// Calculate change frequency for repository
508+
const changeFreq = await calculateChangeFrequency({
509+
repositoryPath: this.config.repositoryPath,
510+
maxCommits: 1000,
511+
});
512+
513+
// Enrich each language with aggregate stats
514+
const enriched: Partial<Record<SupportedLanguage, LanguageStats>> = {};
515+
516+
for (const [lang, langStats] of Object.entries(byLanguage) as Array<
517+
[SupportedLanguage, LanguageStats]
518+
>) {
519+
// Filter change frequency by file extension for this language
520+
const langExtensions = this.getExtensionsForLanguage(lang);
521+
const langFiles = new Map(
522+
[...changeFreq.entries()].filter(([filePath]) =>
523+
langExtensions.some((ext) => filePath.endsWith(ext))
524+
)
525+
);
526+
527+
const aggregate = aggregateChangeFrequency(langFiles);
528+
529+
enriched[lang] = {
530+
...langStats,
531+
avgCommitsPerFile: aggregate.avgCommitsPerFile,
532+
lastModified: aggregate.lastModified ?? undefined,
533+
};
534+
}
535+
536+
return enriched;
537+
} catch (error) {
538+
// Git not available or analysis failed - return original stats without change frequency
539+
const errorMessage = error instanceof Error ? error.message : String(error);
540+
console.warn(
541+
`[indexer] Unable to calculate change frequency for language stats: ${errorMessage}`
542+
);
543+
return byLanguage;
544+
}
545+
}
546+
547+
/**
548+
* Enrich package stats with change frequency data
549+
* Non-blocking: returns original stats if git analysis fails
550+
*/
551+
private async enrichPackageStatsWithChangeFrequency(
552+
byPackage?: Record<string, PackageStats>
553+
): Promise<Record<string, PackageStats> | undefined> {
554+
if (!byPackage) return byPackage;
555+
556+
try {
557+
// Calculate change frequency for repository
558+
const changeFreq = await calculateChangeFrequency({
559+
repositoryPath: this.config.repositoryPath,
560+
maxCommits: 1000,
561+
});
562+
563+
// Enrich each package with aggregate stats
564+
const enriched: Record<string, PackageStats> = {};
565+
566+
for (const [pkgPath, pkgStats] of Object.entries(byPackage)) {
567+
// Filter change frequency by package path
568+
const pkgFiles = new Map(
569+
[...changeFreq.entries()].filter(([filePath]) => filePath.startsWith(pkgPath))
570+
);
571+
572+
const aggregate = aggregateChangeFrequency(pkgFiles);
573+
574+
enriched[pkgPath] = {
575+
...pkgStats,
576+
totalCommits: aggregate.totalCommits,
577+
lastModified: aggregate.lastModified ?? undefined,
578+
};
579+
}
580+
581+
return enriched;
582+
} catch (error) {
583+
// Git not available or analysis failed - return original stats without change frequency
584+
const errorMessage = error instanceof Error ? error.message : String(error);
585+
console.warn(
586+
`[indexer] Unable to calculate change frequency for package stats: ${errorMessage}`
587+
);
588+
return byPackage;
589+
}
590+
}
591+
592+
/**
593+
* Get file extensions for a language
594+
*/
595+
private getExtensionsForLanguage(language: SupportedLanguage): string[] {
596+
const extensionMap: Record<SupportedLanguage, string[]> = {
597+
typescript: ['.ts', '.tsx'],
598+
javascript: ['.js', '.jsx', '.mjs', '.cjs'],
599+
go: ['.go'],
600+
markdown: ['.md', '.markdown'],
601+
};
602+
return extensionMap[language] || [];
603+
}
604+
486605
/**
487606
* Apply stat merging using pure functions
488607
* Wrapper around the pure mergeStats function that updates state

0 commit comments

Comments
 (0)