UNPKG

@zubenelakrab/gitstats

Version:

Powerful Git repository analyzer with comprehensive statistics and insights

341 lines 13.4 kB
import { dirname } from 'node:path'; import { daysDifference } from '../utils/date.js'; export class HealthAnalyzer { name = 'health-analyzer'; description = 'Analyzes repository health and identifies stale code'; async analyze(commits, _config) { if (commits.length === 0) { return this.emptyStats(); } const now = new Date(); // Track file last modified and commit counts const fileData = new Map(); // Track directory activity const dirData = new Map(); // Sort commits oldest first const sorted = [...commits].sort((a, b) => a.date.getTime() - b.date.getTime()); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); for (const commit of sorted) { for (const file of commit.files) { // File data if (!fileData.has(file.path)) { fileData.set(file.path, { lastModified: commit.date, firstModified: commit.date, commits: 0, authors: new Set(), lastAuthor: commit.author.name, }); } const data = fileData.get(file.path); data.lastModified = commit.date; data.commits++; data.authors.add(commit.author.email); data.lastAuthor = commit.author.name; // Directory data const dir = dirname(file.path); if (!dirData.has(dir)) { dirData.set(dir, { lastActivity: commit.date, files: new Set(), commits: 0, lastAuthor: commit.author.name, recentCommits: 0, }); } const dData = dirData.get(dir); dData.lastActivity = commit.date; dData.files.add(file.path); dData.commits++; dData.lastAuthor = commit.author.name; if (commit.date >= thirtyDaysAgo) { dData.recentCommits++; } } } // Identify zombie files (only 1 commit and > 180 days old) const zombieFiles = []; const legacyFiles = []; const ageDistribution = { fresh: 0, recent: 0, aging: 0, old: 0, ancient: 0, }; for (const [path, data] of fileData) { const daysSince = daysDifference(data.lastModified, now); // Age distribution if (daysSince < 30) ageDistribution.fresh++; else if (daysSince < 90) ageDistribution.recent++; else if (daysSince < 180) ageDistribution.aging++; else if (daysSince < 365) ageDistribution.old++; else ageDistribution.ancient++; // Zombie files if (data.commits === 1 && daysSince > 180) { zombieFiles.push({ path, lastModified: data.lastModified, daysSinceModified: daysSince, originalAuthor: data.lastAuthor, }); } // Legacy files (> 180 days without changes, but has history) if (daysSince > 180 && data.commits > 1) { let risk; if (daysSince > 365) risk = 'high'; else if (daysSince > 270) risk = 'medium'; else risk = 'low'; legacyFiles.push({ path, lastModified: data.lastModified, daysSinceModified: daysSince, totalCommits: data.commits, authors: Array.from(data.authors), risk, }); } } zombieFiles.sort((a, b) => b.daysSinceModified - a.daysSinceModified); legacyFiles.sort((a, b) => b.daysSinceModified - a.daysSinceModified); // Abandoned directories const abandonedDirs = []; const activeAreas = []; for (const [path, data] of dirData) { if (path === '.') continue; const daysSince = daysDifference(data.lastActivity, now); if (daysSince > 180 && data.files.size > 3) { abandonedDirs.push({ path, fileCount: data.files.size, lastActivity: data.lastActivity, daysSinceActivity: daysSince, lastAuthor: data.lastAuthor, }); } // Active areas let activityLevel; if (data.recentCommits > 10) activityLevel = 'hot'; else if (data.recentCommits > 3) activityLevel = 'warm'; else activityLevel = 'cold'; if (data.commits > 5) { activeAreas.push({ path, recentCommits: data.recentCommits, totalCommits: data.commits, activeAuthors: 0, // Would need more tracking activityLevel, }); } } abandonedDirs.sort((a, b) => b.daysSinceActivity - a.daysSinceActivity); activeAreas.sort((a, b) => b.recentCommits - a.recentCommits); // Calculate test metrics const testMetrics = this.calculateTestMetrics(fileData, thirtyDaysAgo); // Calculate health indicators const indicators = this.calculateIndicators(zombieFiles, legacyFiles, abandonedDirs, ageDistribution, fileData.size, testMetrics); // Calculate overall health score const healthScore = this.calculateHealthScore(indicators); return { zombieFiles: zombieFiles.slice(0, 20), legacyFiles: legacyFiles.slice(0, 30), abandonedDirs: abandonedDirs.slice(0, 15), activeAreas: activeAreas.slice(0, 15), ageDistribution, healthScore, indicators, testMetrics, }; } calculateTestMetrics(fileData, thirtyDaysAgo) { const testPatterns = [ /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /_test\.[jt]sx?$/, /test_.*\.[jt]sx?$/, /\.tests?\.[jt]sx?$/, /__tests__\//, /\/tests?\//, ]; const sourcePatterns = [ /\.[jt]sx?$/, /\.py$/, /\.go$/, /\.rs$/, /\.java$/, /\.cs$/, /\.rb$/, /\.php$/, ]; let testFiles = 0; let sourceFiles = 0; let recentTestActivity = 0; const testTypes = { unit: 0, integration: 0, e2e: 0, other: 0 }; const moduleTests = new Map(); for (const [path, data] of fileData) { const isTest = testPatterns.some(p => p.test(path)); const isSource = sourcePatterns.some(p => p.test(path)); // Get module (first directory level) const parts = path.split('/'); const module = parts.length > 1 ? parts[0] : '.'; if (!moduleTests.has(module)) { moduleTests.set(module, { sourceFiles: 0, testFiles: 0 }); } const moduleData = moduleTests.get(module); if (isTest) { testFiles++; moduleData.testFiles++; // Categorize test type if (/e2e|end-to-end|cypress|playwright|selenium/i.test(path)) { testTypes.e2e++; } else if (/integration|int\./i.test(path)) { testTypes.integration++; } else if (/unit|\.test\.|\.spec\./i.test(path)) { testTypes.unit++; } else { testTypes.other++; } // Recent test activity if (data.lastModified >= thirtyDaysAgo) { recentTestActivity++; } } else if (isSource) { sourceFiles++; moduleData.sourceFiles++; } } const testToCodeRatio = sourceFiles > 0 ? testFiles / sourceFiles : 0; // Estimate coverage based on ratio let testCoverage; if (testToCodeRatio >= 0.8) testCoverage = 'Excellent (80%+)'; else if (testToCodeRatio >= 0.5) testCoverage = 'Good (50-80%)'; else if (testToCodeRatio >= 0.3) testCoverage = 'Moderate (30-50%)'; else if (testToCodeRatio >= 0.1) testCoverage = 'Low (10-30%)'; else testCoverage = 'Minimal (<10%)'; // Find modules without tests const modulesWithoutTests = Array.from(moduleTests.entries()) .filter(([, data]) => data.sourceFiles > 3 && data.testFiles === 0) .map(([path, data]) => ({ path, sourceFiles: data.sourceFiles, testFiles: data.testFiles, hasTests: false, })) .sort((a, b) => b.sourceFiles - a.sourceFiles) .slice(0, 10); return { testFiles, sourceFiles, testToCodeRatio: Math.round(testToCodeRatio * 100) / 100, testCoverage, modulesWithoutTests, testTypes, recentTestActivity, }; } calculateIndicators(zombieFiles, legacyFiles, abandonedDirs, ageDistribution, totalFiles, testMetrics) { const indicators = []; // Fresh code ratio const freshRatio = totalFiles > 0 ? (ageDistribution.fresh / totalFiles) * 100 : 0; indicators.push({ name: 'Fresh Code', status: freshRatio > 30 ? 'good' : freshRatio > 10 ? 'warning' : 'critical', value: `${freshRatio.toFixed(1)}%`, description: 'Percentage of files modified in last 30 days', }); // Legacy code ratio const legacyRatio = totalFiles > 0 ? ((ageDistribution.old + ageDistribution.ancient) / totalFiles) * 100 : 0; indicators.push({ name: 'Legacy Code', status: legacyRatio < 20 ? 'good' : legacyRatio < 40 ? 'warning' : 'critical', value: `${legacyRatio.toFixed(1)}%`, description: 'Percentage of files not touched in 6+ months', }); // Zombie files indicators.push({ name: 'Zombie Files', status: zombieFiles.length < 5 ? 'good' : zombieFiles.length < 15 ? 'warning' : 'critical', value: zombieFiles.length.toString(), description: 'Files with single commit and 6+ months old', }); // Abandoned directories indicators.push({ name: 'Abandoned Areas', status: abandonedDirs.length < 3 ? 'good' : abandonedDirs.length < 7 ? 'warning' : 'critical', value: abandonedDirs.length.toString(), description: 'Directories with no activity in 6+ months', }); // High risk legacy const highRiskLegacy = legacyFiles.filter(f => f.risk === 'high').length; indicators.push({ name: 'High Risk Legacy', status: highRiskLegacy < 5 ? 'good' : highRiskLegacy < 15 ? 'warning' : 'critical', value: highRiskLegacy.toString(), description: 'Legacy files not touched in 1+ year', }); // Test coverage indicator const testRatio = testMetrics.testToCodeRatio; indicators.push({ name: 'Test Coverage', status: testRatio >= 0.5 ? 'good' : testRatio >= 0.2 ? 'warning' : 'critical', value: `${Math.round(testRatio * 100)}%`, description: `${testMetrics.testFiles} test files / ${testMetrics.sourceFiles} source files`, }); return indicators; } calculateHealthScore(indicators) { let score = 100; for (const indicator of indicators) { if (indicator.status === 'warning') score -= 10; if (indicator.status === 'critical') score -= 20; } return Math.max(0, score); } emptyStats() { return { zombieFiles: [], legacyFiles: [], abandonedDirs: [], activeAreas: [], ageDistribution: { fresh: 0, recent: 0, aging: 0, old: 0, ancient: 0 }, healthScore: 100, indicators: [], testMetrics: { testFiles: 0, sourceFiles: 0, testToCodeRatio: 0, testCoverage: 'No data', modulesWithoutTests: [], testTypes: { unit: 0, integration: 0, e2e: 0, other: 0 }, recentTestActivity: 0, }, }; } } export function createHealthAnalyzer() { return new HealthAnalyzer(); } //# sourceMappingURL=health-analyzer.js.map