repo-wrapped 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +94 -0
  2. package/dist/cli.js +24 -0
  3. package/dist/commands/generate.js +95 -0
  4. package/dist/commands/index.js +24 -0
  5. package/dist/constants/chronotypes.js +23 -0
  6. package/dist/constants/colors.js +18 -0
  7. package/dist/constants/index.js +18 -0
  8. package/dist/formatters/index.js +17 -0
  9. package/dist/formatters/timeFormatter.js +29 -0
  10. package/dist/generators/html/scripts/export.js +125 -0
  11. package/dist/generators/html/scripts/knowledge.js +120 -0
  12. package/dist/generators/html/scripts/modal.js +68 -0
  13. package/dist/generators/html/scripts/navigation.js +156 -0
  14. package/dist/generators/html/scripts/tabs.js +18 -0
  15. package/dist/generators/html/scripts/tooltip.js +21 -0
  16. package/dist/generators/html/styles/achievements.css +387 -0
  17. package/dist/generators/html/styles/base.css +818 -0
  18. package/dist/generators/html/styles/components.css +1391 -0
  19. package/dist/generators/html/styles/knowledge.css +221 -0
  20. package/dist/generators/html/templates/achievementsSection.js +156 -0
  21. package/dist/generators/html/templates/commitQualitySection.js +89 -0
  22. package/dist/generators/html/templates/contributionGraph.js +73 -0
  23. package/dist/generators/html/templates/impactSection.js +117 -0
  24. package/dist/generators/html/templates/knowledgeSection.js +226 -0
  25. package/dist/generators/html/templates/streakSection.js +42 -0
  26. package/dist/generators/html/templates/timePatternsSection.js +110 -0
  27. package/dist/generators/html/utils/colorUtils.js +21 -0
  28. package/dist/generators/html/utils/commitMapBuilder.js +24 -0
  29. package/dist/generators/html/utils/dateRangeCalculator.js +57 -0
  30. package/dist/generators/html/utils/developerStatsCalculator.js +29 -0
  31. package/dist/generators/html/utils/scriptLoader.js +16 -0
  32. package/dist/generators/html/utils/styleLoader.js +18 -0
  33. package/dist/generators/html/utils/weekGrouper.js +28 -0
  34. package/dist/index.js +77 -0
  35. package/dist/types/index.js +2 -0
  36. package/dist/utils/achievementDefinitions.js +433 -0
  37. package/dist/utils/achievementEngine.js +170 -0
  38. package/dist/utils/commitQualityAnalyzer.js +368 -0
  39. package/dist/utils/fileHotspotAnalyzer.js +270 -0
  40. package/dist/utils/gitParser.js +125 -0
  41. package/dist/utils/htmlGenerator.js +449 -0
  42. package/dist/utils/impactAnalyzer.js +248 -0
  43. package/dist/utils/knowledgeDistributionAnalyzer.js +374 -0
  44. package/dist/utils/matrixGenerator.js +350 -0
  45. package/dist/utils/slideGenerator.js +313 -0
  46. package/dist/utils/streakCalculator.js +135 -0
  47. package/dist/utils/timePatternAnalyzer.js +305 -0
  48. package/dist/utils/wrappedDisplay.js +115 -0
  49. package/dist/utils/wrappedGenerator.js +377 -0
  50. package/dist/utils/wrappedHtmlGenerator.js +552 -0
  51. package/package.json +55 -0
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyzeImpact = void 0;
4
+ // File weight patterns based on criticality
5
+ const FILE_WEIGHTS = [
6
+ { pattern: /^src\/(core|lib)\//, weight: 1.5, category: 'core' },
7
+ { pattern: /^(src|lib)\/.*\.(ts|js|tsx|jsx)$/, weight: 1.0, category: 'feature' },
8
+ { pattern: /(test|spec|__tests__)\/|\.test\.|\.spec\./, weight: 0.8, category: 'test' },
9
+ { pattern: /\.(config|rc)\.|\.env|tsconfig|webpack|vite|eslint|prettier/, weight: 0.6, category: 'config' },
10
+ { pattern: /\.md$|^docs\/|^README/, weight: 0.4, category: 'docs' },
11
+ { pattern: /package\.json|\.lock$|yarn\.lock|pnpm-lock/, weight: 0.3, category: 'config' },
12
+ ];
13
+ // Commit type weights based on conventional commits
14
+ const TYPE_WEIGHTS = {
15
+ feat: 1.5,
16
+ fix: 1.2,
17
+ refactor: 1.0,
18
+ perf: 1.3,
19
+ test: 0.9,
20
+ docs: 0.5,
21
+ chore: 0.4,
22
+ style: 0.3,
23
+ ci: 0.4,
24
+ build: 0.5,
25
+ };
26
+ /**
27
+ * Get file weight and category based on path
28
+ */
29
+ function getFileInfo(filePath) {
30
+ const normalizedPath = filePath.toLowerCase().replace(/\\/g, '/');
31
+ for (const { pattern, weight, category } of FILE_WEIGHTS) {
32
+ if (pattern.test(normalizedPath)) {
33
+ return { weight, category };
34
+ }
35
+ }
36
+ // Default for unknown files
37
+ return { weight: 0.7, category: 'feature' };
38
+ }
39
+ /**
40
+ * Extract commit type from conventional commit message
41
+ */
42
+ function getCommitType(message) {
43
+ const match = message.match(/^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?!?:/i);
44
+ return match ? match[1].toLowerCase() : null;
45
+ }
46
+ /**
47
+ * Calculate recency factor (recent changes weighted higher)
48
+ */
49
+ function getRecencyFactor(commitDate, now = new Date()) {
50
+ const daysSinceCommit = Math.floor((now.getTime() - commitDate.getTime()) / (1000 * 60 * 60 * 24));
51
+ // Exponential decay over 90 days
52
+ if (daysSinceCommit <= 7)
53
+ return 1.0;
54
+ if (daysSinceCommit <= 30)
55
+ return 0.9;
56
+ if (daysSinceCommit <= 60)
57
+ return 0.7;
58
+ if (daysSinceCommit <= 90)
59
+ return 0.5;
60
+ return 0.3;
61
+ }
62
+ /**
63
+ * Extract files changed from commit message (simplified - assumes file paths in message)
64
+ * In a real implementation, this would come from git diff
65
+ */
66
+ function extractFilesFromCommit(commit) {
67
+ // For now, return empty - in a real implementation we'd parse git log --name-only
68
+ // This is a placeholder that allows the analyzer to work with existing data
69
+ return [];
70
+ }
71
+ /**
72
+ * Analyze impact of commits
73
+ */
74
+ function analyzeImpact(commits, changedFiles) {
75
+ if (commits.length === 0) {
76
+ return getEmptyImpactAnalysis();
77
+ }
78
+ const now = new Date();
79
+ const fileImpacts = new Map();
80
+ let coreContributions = 0;
81
+ let featureWork = 0;
82
+ let maintenanceWork = 0;
83
+ let documentationWork = 0;
84
+ let totalRawScore = 0;
85
+ let recentScore = 0; // Last 30 days
86
+ let olderScore = 0; // 30-90 days ago
87
+ // If we have file change data from fileHotspotAnalyzer, use it
88
+ if (changedFiles && changedFiles.size > 0) {
89
+ changedFiles.forEach((data, filePath) => {
90
+ const { weight, category } = getFileInfo(filePath);
91
+ const impactScore = data.count * weight;
92
+ fileImpacts.set(filePath, {
93
+ score: impactScore,
94
+ count: data.count,
95
+ lastChanged: data.lastChanged,
96
+ category
97
+ });
98
+ totalRawScore += impactScore;
99
+ // Categorize contributions
100
+ switch (category) {
101
+ case 'core':
102
+ coreContributions += impactScore;
103
+ break;
104
+ case 'test':
105
+ case 'config':
106
+ maintenanceWork += impactScore;
107
+ break;
108
+ case 'docs':
109
+ documentationWork += impactScore;
110
+ break;
111
+ default:
112
+ featureWork += impactScore;
113
+ }
114
+ });
115
+ }
116
+ // Process commits for type-based weighting and recency
117
+ commits.forEach(commit => {
118
+ const commitDate = new Date(commit.date);
119
+ const commitType = getCommitType(commit.message);
120
+ const typeWeight = commitType ? (TYPE_WEIGHTS[commitType] || 1.0) : 1.0;
121
+ const recencyFactor = getRecencyFactor(commitDate, now);
122
+ const daysSinceCommit = Math.floor((now.getTime() - commitDate.getTime()) / (1000 * 60 * 60 * 24));
123
+ const baseScore = typeWeight * recencyFactor;
124
+ if (daysSinceCommit <= 30) {
125
+ recentScore += baseScore;
126
+ }
127
+ else if (daysSinceCommit <= 90) {
128
+ olderScore += baseScore;
129
+ }
130
+ // Additional category scoring based on commit type
131
+ if (commitType === 'feat') {
132
+ featureWork += typeWeight;
133
+ }
134
+ else if (commitType === 'fix' || commitType === 'refactor') {
135
+ maintenanceWork += typeWeight;
136
+ }
137
+ else if (commitType === 'docs') {
138
+ documentationWork += typeWeight;
139
+ }
140
+ });
141
+ // Normalize overall score to 0-100
142
+ const maxPossibleScore = commits.length * 1.5 * 1.0; // Max type weight * max recency
143
+ const normalizedScore = Math.min(100, Math.round((totalRawScore + recentScore) / Math.max(maxPossibleScore, 1) * 100));
144
+ // Get top 10 impact files
145
+ const topImpactFiles = Array.from(fileImpacts.entries())
146
+ .sort((a, b) => b[1].score - a[1].score)
147
+ .slice(0, 10)
148
+ .map(([path, data]) => ({
149
+ path,
150
+ impactScore: Math.round(data.score * 10) / 10,
151
+ changeCount: data.count,
152
+ lastChanged: data.lastChanged,
153
+ category: data.category
154
+ }));
155
+ // Determine trend
156
+ let impactTrend = 'stable';
157
+ if (recentScore > olderScore * 1.2) {
158
+ impactTrend = 'increasing';
159
+ }
160
+ else if (recentScore < olderScore * 0.8) {
161
+ impactTrend = 'decreasing';
162
+ }
163
+ // Generate insights
164
+ const insights = generateInsights({
165
+ coreContributions,
166
+ featureWork,
167
+ maintenanceWork,
168
+ documentationWork,
169
+ topImpactFiles,
170
+ impactTrend,
171
+ totalCommits: commits.length
172
+ });
173
+ // Normalize breakdown scores
174
+ const breakdownTotal = coreContributions + featureWork + maintenanceWork + documentationWork || 1;
175
+ return {
176
+ overallScore: normalizedScore,
177
+ scoreBreakdown: {
178
+ coreContributions: Math.round((coreContributions / breakdownTotal) * 100),
179
+ featureWork: Math.round((featureWork / breakdownTotal) * 100),
180
+ maintenanceWork: Math.round((maintenanceWork / breakdownTotal) * 100),
181
+ documentationWork: Math.round((documentationWork / breakdownTotal) * 100),
182
+ },
183
+ topImpactFiles,
184
+ impactTrend,
185
+ insights
186
+ };
187
+ }
188
+ exports.analyzeImpact = analyzeImpact;
189
+ /**
190
+ * Generate meaningful insights from impact data
191
+ */
192
+ function generateInsights(data) {
193
+ const insights = [];
194
+ const total = data.coreContributions + data.featureWork + data.maintenanceWork + data.documentationWork || 1;
195
+ // Core contributions insight
196
+ const corePercentage = (data.coreContributions / total) * 100;
197
+ if (corePercentage > 30) {
198
+ insights.push(`Strong focus on core modules — ${corePercentage.toFixed(0)}% of impact from critical paths`);
199
+ }
200
+ // Feature work insight
201
+ const featurePercentage = (data.featureWork / total) * 100;
202
+ if (featurePercentage > 50) {
203
+ insights.push(`Feature-focused development — ${featurePercentage.toFixed(0)}% of contributions are new functionality`);
204
+ }
205
+ // Maintenance insight
206
+ const maintenancePercentage = (data.maintenanceWork / total) * 100;
207
+ if (maintenancePercentage > 40) {
208
+ insights.push(`Strong maintenance focus — keeping the codebase healthy`);
209
+ }
210
+ // Documentation insight
211
+ const docPercentage = (data.documentationWork / total) * 100;
212
+ if (docPercentage < 5 && data.totalCommits > 20) {
213
+ insights.push(`Documentation contributions below average — consider adding READMEs or inline docs`);
214
+ }
215
+ else if (docPercentage > 15) {
216
+ insights.push(`Good documentation practices — ${docPercentage.toFixed(0)}% of work includes docs`);
217
+ }
218
+ // Trend insight
219
+ if (data.impactTrend === 'increasing') {
220
+ insights.push(`Impact trending upward — recent contributions are higher value`);
221
+ }
222
+ else if (data.impactTrend === 'decreasing') {
223
+ insights.push(`Recent activity lower than historical average`);
224
+ }
225
+ // Top files insight
226
+ if (data.topImpactFiles.length > 0) {
227
+ const topFile = data.topImpactFiles[0];
228
+ insights.push(`Highest impact: ${topFile.path.split('/').pop()} (${topFile.changeCount} changes)`);
229
+ }
230
+ return insights.slice(0, 5); // Limit to 5 insights
231
+ }
232
+ /**
233
+ * Return empty analysis for no commits
234
+ */
235
+ function getEmptyImpactAnalysis() {
236
+ return {
237
+ overallScore: 0,
238
+ scoreBreakdown: {
239
+ coreContributions: 0,
240
+ featureWork: 0,
241
+ maintenanceWork: 0,
242
+ documentationWork: 0,
243
+ },
244
+ topImpactFiles: [],
245
+ impactTrend: 'stable',
246
+ insights: ['No commit data available for analysis']
247
+ };
248
+ }
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyzeKnowledgeDistribution = void 0;
4
+ const child_process_1 = require("child_process");
5
+ /**
6
+ * Extract directory from file path
7
+ */
8
+ function getDirectory(filePath) {
9
+ const parts = filePath.replace(/\\/g, '/').split('/');
10
+ if (parts.length <= 1)
11
+ return '/';
12
+ return parts.slice(0, -1).join('/');
13
+ }
14
+ /**
15
+ * Calculate bus factor risk score
16
+ * 1 owner = 10 (critical), 2 = 7 (high), 3 = 4 (medium), 4+ = 1 (low)
17
+ */
18
+ function calculateRiskScore(significantOwners) {
19
+ if (significantOwners === 0)
20
+ return 10;
21
+ if (significantOwners === 1)
22
+ return 10;
23
+ if (significantOwners === 2)
24
+ return 7;
25
+ if (significantOwners === 3)
26
+ return 4;
27
+ return 1;
28
+ }
29
+ /**
30
+ * Determine ownership type based on contribution percentages
31
+ */
32
+ function determineOwnershipType(owners) {
33
+ if (owners.length === 0)
34
+ return 'solo';
35
+ const topOwnerPercentage = owners[0]?.percentage || 0;
36
+ if (topOwnerPercentage >= 100)
37
+ return 'solo';
38
+ if (topOwnerPercentage >= 70)
39
+ return 'primary';
40
+ if (topOwnerPercentage >= 40)
41
+ return 'shared';
42
+ return 'collaborative';
43
+ }
44
+ /**
45
+ * Get risk level from score
46
+ */
47
+ function getRiskLevel(score) {
48
+ if (score >= 9)
49
+ return 'critical';
50
+ if (score >= 6)
51
+ return 'high';
52
+ if (score >= 3)
53
+ return 'medium';
54
+ return 'low';
55
+ }
56
+ /**
57
+ * Calculate knowledge age in human-readable format
58
+ */
59
+ function calculateKnowledgeAge(lastActive) {
60
+ const now = new Date();
61
+ const diffMs = now.getTime() - lastActive.getTime();
62
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
63
+ if (diffDays < 7)
64
+ return `${diffDays}d`;
65
+ if (diffDays < 30)
66
+ return `${Math.floor(diffDays / 7)}w`;
67
+ if (diffDays < 365)
68
+ return `${Math.floor(diffDays / 30)}mo`;
69
+ return `${Math.floor(diffDays / 365)}y`;
70
+ }
71
+ /**
72
+ * Determine if an area is high risk
73
+ * High risk = bus factor 1 OR (bus factor ≤ 2 AND knowledge age > 3 months)
74
+ */
75
+ function isHighRisk(busFactorRisk, lastActive) {
76
+ const daysSinceActive = Math.floor((Date.now() - lastActive.getTime()) / (1000 * 60 * 60 * 24));
77
+ const threeMonths = 90;
78
+ if (busFactorRisk >= 10)
79
+ return true; // Bus factor 1
80
+ if (busFactorRisk >= 7 && daysSinceActive > threeMonths)
81
+ return true; // Bus factor 2 + stale
82
+ return false;
83
+ }
84
+ /**
85
+ * Parse git log with file names for deep analysis
86
+ */
87
+ function parseGitLogWithFiles(repoPath) {
88
+ const fileStats = new Map();
89
+ try {
90
+ // Get git log with files: format is "hash|author|date" followed by file names
91
+ const gitLog = (0, child_process_1.execSync)('git log --name-only --pretty=format:"%H|%an|%ad" --date=iso', { cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
92
+ const lines = gitLog.split('\n');
93
+ let currentAuthor = '';
94
+ let currentDate = new Date();
95
+ for (const line of lines) {
96
+ if (line.includes('|')) {
97
+ // This is a commit header line
98
+ const parts = line.split('|');
99
+ currentAuthor = parts[1]?.trim() || 'Unknown';
100
+ currentDate = new Date(parts[2]?.trim() || Date.now());
101
+ }
102
+ else if (line.trim()) {
103
+ // This is a file path
104
+ const filePath = line.trim().replace(/\\/g, '/');
105
+ // Skip common non-code files
106
+ if (filePath.includes('node_modules/') ||
107
+ filePath.includes('.git/') ||
108
+ filePath.endsWith('.lock') ||
109
+ filePath.endsWith('.log')) {
110
+ continue;
111
+ }
112
+ if (!fileStats.has(filePath)) {
113
+ fileStats.set(filePath, { authors: new Map() });
114
+ }
115
+ const fileData = fileStats.get(filePath);
116
+ const authorData = fileData.authors.get(currentAuthor) || { commits: 0, lastActive: currentDate };
117
+ fileData.authors.set(currentAuthor, {
118
+ commits: authorData.commits + 1,
119
+ lastActive: currentDate > authorData.lastActive ? currentDate : authorData.lastActive
120
+ });
121
+ }
122
+ }
123
+ }
124
+ catch (error) {
125
+ // Silent fail - return empty map if git log fails
126
+ }
127
+ return fileStats;
128
+ }
129
+ /**
130
+ * Build file ownership data for a directory
131
+ */
132
+ function buildFileOwnership(fileStats, directoryPath) {
133
+ const files = [];
134
+ fileStats.forEach((data, filePath) => {
135
+ const dir = getDirectory(filePath);
136
+ if (dir !== directoryPath && !filePath.startsWith(directoryPath + '/')) {
137
+ return;
138
+ }
139
+ const totalCommits = Array.from(data.authors.values()).reduce((sum, a) => sum + a.commits, 0);
140
+ let primaryOwner = 'Unknown';
141
+ let maxCommits = 0;
142
+ let lastModified = new Date(0);
143
+ data.authors.forEach((authorData, author) => {
144
+ if (authorData.commits > maxCommits) {
145
+ maxCommits = authorData.commits;
146
+ primaryOwner = author;
147
+ }
148
+ if (authorData.lastActive > lastModified) {
149
+ lastModified = authorData.lastActive;
150
+ }
151
+ });
152
+ const ownershipPercentage = Math.round((maxCommits / totalCommits) * 100);
153
+ const knowledgeAge = calculateKnowledgeAge(lastModified);
154
+ const significantOwners = Array.from(data.authors.values()).filter(a => (a.commits / totalCommits) >= 0.2).length;
155
+ const busFactorRiskScore = calculateRiskScore(significantOwners);
156
+ const riskLevel = getRiskLevel(busFactorRiskScore);
157
+ files.push({
158
+ path: filePath,
159
+ primaryOwner,
160
+ ownershipPercentage,
161
+ lastModified,
162
+ knowledgeAge,
163
+ riskLevel,
164
+ busFactorRisk: busFactorRiskScore >= 7
165
+ });
166
+ });
167
+ // Sort by risk (high risk first), then by ownership percentage (high percentage = more concentrated = more risk)
168
+ return files
169
+ .filter(f => f.path.startsWith(directoryPath + '/') || getDirectory(f.path) === directoryPath)
170
+ .sort((a, b) => {
171
+ if (a.busFactorRisk !== b.busFactorRisk)
172
+ return a.busFactorRisk ? -1 : 1;
173
+ return b.ownershipPercentage - a.ownershipPercentage;
174
+ })
175
+ .slice(0, 20); // Limit to 20 files per directory
176
+ }
177
+ /**
178
+ * Analyze knowledge distribution across the codebase
179
+ */
180
+ function analyzeKnowledgeDistribution(commits, repoPath, deepAnalysis = false) {
181
+ if (commits.length === 0) {
182
+ return getEmptyKnowledgeDistribution();
183
+ }
184
+ // For deep analysis, parse git log with file names
185
+ let fileStats = null;
186
+ if (deepAnalysis && repoPath) {
187
+ fileStats = parseGitLogWithFiles(repoPath);
188
+ }
189
+ // Build directory → author → commits mapping
190
+ const directoryStats = new Map();
191
+ if (fileStats && fileStats.size > 0) {
192
+ // Use actual file data for directory stats
193
+ fileStats.forEach((data, filePath) => {
194
+ const dir = getDirectory(filePath);
195
+ if (!directoryStats.has(dir)) {
196
+ directoryStats.set(dir, new Map());
197
+ }
198
+ const dirAuthors = directoryStats.get(dir);
199
+ data.authors.forEach((authorData, author) => {
200
+ const existing = dirAuthors.get(author) || { commits: 0, lastActive: authorData.lastActive };
201
+ dirAuthors.set(author, {
202
+ commits: existing.commits + authorData.commits,
203
+ lastActive: authorData.lastActive > existing.lastActive ? authorData.lastActive : existing.lastActive
204
+ });
205
+ });
206
+ });
207
+ }
208
+ else {
209
+ // Fallback: use heuristics based on commit messages
210
+ commits.forEach(commit => {
211
+ const author = commit.author;
212
+ const commitDate = new Date(commit.date);
213
+ const directories = new Set();
214
+ // Try to extract directories from commit message (simplified)
215
+ const pathMatch = commit.message.match(/(?:in|for|to)\s+([a-zA-Z0-9_\-./]+)/g);
216
+ if (pathMatch) {
217
+ pathMatch.forEach(match => {
218
+ const path = match.replace(/^(?:in|for|to)\s+/, '');
219
+ if (path.includes('/')) {
220
+ directories.add(getDirectory(path));
221
+ }
222
+ });
223
+ }
224
+ // Default directory based on commit type
225
+ if (directories.size === 0) {
226
+ const type = commit.message.match(/^(feat|fix|docs|test|refactor)/i)?.[1]?.toLowerCase();
227
+ switch (type) {
228
+ case 'feat':
229
+ case 'fix':
230
+ case 'refactor':
231
+ directories.add('src');
232
+ break;
233
+ case 'docs':
234
+ directories.add('docs');
235
+ break;
236
+ case 'test':
237
+ directories.add('test');
238
+ break;
239
+ default:
240
+ directories.add('src');
241
+ }
242
+ }
243
+ directories.forEach(dir => {
244
+ if (!directoryStats.has(dir)) {
245
+ directoryStats.set(dir, new Map());
246
+ }
247
+ const dirAuthors = directoryStats.get(dir);
248
+ const existing = dirAuthors.get(author) || { commits: 0, lastActive: commitDate };
249
+ dirAuthors.set(author, {
250
+ commits: existing.commits + 1,
251
+ lastActive: commitDate > existing.lastActive ? commitDate : existing.lastActive
252
+ });
253
+ });
254
+ });
255
+ }
256
+ // Build directory ownership array
257
+ const directories = [];
258
+ const knowledgeSilos = [];
259
+ const sharedKnowledge = [];
260
+ const criticalPaths = [];
261
+ let totalRiskScore = 0;
262
+ let dirCount = 0;
263
+ directoryStats.forEach((authorStats, dirPath) => {
264
+ const totalCommits = Array.from(authorStats.values()).reduce((sum, a) => sum + a.commits, 0);
265
+ // Get the latest activity date for the directory
266
+ let latestActivity = new Date(0);
267
+ authorStats.forEach(stats => {
268
+ if (stats.lastActive > latestActivity) {
269
+ latestActivity = stats.lastActive;
270
+ }
271
+ });
272
+ const owners = Array.from(authorStats.entries())
273
+ .map(([author, stats]) => ({
274
+ author,
275
+ commits: stats.commits,
276
+ percentage: Math.round((stats.commits / totalCommits) * 100),
277
+ lastActive: stats.lastActive,
278
+ knowledgeAge: calculateKnowledgeAge(stats.lastActive)
279
+ }))
280
+ .sort((a, b) => b.commits - a.commits);
281
+ // Calculate significant owners (>= 20% contribution)
282
+ const significantOwners = owners.filter(o => o.percentage >= 20).length;
283
+ const busFactorRisk = calculateRiskScore(significantOwners);
284
+ const ownershipType = determineOwnershipType(owners);
285
+ // Build high-risk files list if deep analysis is enabled
286
+ let highRiskFiles;
287
+ if (deepAnalysis && fileStats && isHighRisk(busFactorRisk, latestActivity)) {
288
+ highRiskFiles = buildFileOwnership(fileStats, dirPath);
289
+ }
290
+ directories.push({
291
+ path: dirPath,
292
+ totalCommits,
293
+ owners,
294
+ ownershipType,
295
+ busFactorRisk,
296
+ highRiskFiles
297
+ });
298
+ // Track silos and shared knowledge
299
+ if (ownershipType === 'solo') {
300
+ knowledgeSilos.push(`${dirPath} — ${owners[0]?.author || 'Unknown'} (100%)`);
301
+ criticalPaths.push(dirPath);
302
+ }
303
+ else if (ownershipType === 'collaborative' || significantOwners >= 3) {
304
+ sharedKnowledge.push(dirPath);
305
+ }
306
+ totalRiskScore += busFactorRisk;
307
+ dirCount++;
308
+ });
309
+ // Calculate overall bus factor risk
310
+ const overallRisk = dirCount > 0 ? Math.round(totalRiskScore / dirCount) : 0;
311
+ // Generate recommendations
312
+ const recommendations = generateRecommendations({
313
+ knowledgeSilos,
314
+ sharedKnowledge,
315
+ directories,
316
+ overallRisk
317
+ });
318
+ return {
319
+ directories: directories.sort((a, b) => b.busFactorRisk - a.busFactorRisk).slice(0, 15),
320
+ busFactorRisk: {
321
+ overall: overallRisk,
322
+ level: getRiskLevel(overallRisk),
323
+ criticalPaths
324
+ },
325
+ knowledgeSilos: knowledgeSilos.slice(0, 10),
326
+ sharedKnowledge: sharedKnowledge.slice(0, 10),
327
+ recommendations,
328
+ isDeepAnalysis: deepAnalysis
329
+ };
330
+ }
331
+ exports.analyzeKnowledgeDistribution = analyzeKnowledgeDistribution;
332
+ /**
333
+ * Generate recommendations based on knowledge distribution
334
+ */
335
+ function generateRecommendations(data) {
336
+ const recommendations = [];
337
+ if (data.knowledgeSilos.length > 0) {
338
+ recommendations.push(`Consider cross-training on ${data.knowledgeSilos.length} knowledge silo(s) to reduce bus factor risk`);
339
+ }
340
+ if (data.overallRisk >= 7) {
341
+ recommendations.push('High bus factor risk detected — prioritize knowledge sharing sessions');
342
+ }
343
+ const soloDirectories = data.directories.filter(d => d.ownershipType === 'solo');
344
+ if (soloDirectories.length > 3) {
345
+ recommendations.push(`${soloDirectories.length} directories have single owners — pair programming recommended`);
346
+ }
347
+ if (data.sharedKnowledge.length > 0) {
348
+ recommendations.push(`Good knowledge distribution in ${data.sharedKnowledge.length} area(s) — maintain this pattern`);
349
+ }
350
+ const inactiveOwners = data.directories.flatMap(d => d.owners.filter(o => {
351
+ const daysSinceActive = Math.floor((Date.now() - o.lastActive.getTime()) / (1000 * 60 * 60 * 24));
352
+ return daysSinceActive > 60 && o.percentage > 30;
353
+ }));
354
+ if (inactiveOwners.length > 0) {
355
+ recommendations.push('Some significant contributors have been inactive — ensure knowledge transfer');
356
+ }
357
+ return recommendations.slice(0, 5);
358
+ }
359
+ /**
360
+ * Return empty distribution for no commits
361
+ */
362
+ function getEmptyKnowledgeDistribution() {
363
+ return {
364
+ directories: [],
365
+ busFactorRisk: {
366
+ overall: 0,
367
+ level: 'low',
368
+ criticalPaths: []
369
+ },
370
+ knowledgeSilos: [],
371
+ sharedKnowledge: [],
372
+ recommendations: ['No commit data available for analysis']
373
+ };
374
+ }