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.
- package/README.md +94 -0
- package/dist/cli.js +24 -0
- package/dist/commands/generate.js +95 -0
- package/dist/commands/index.js +24 -0
- package/dist/constants/chronotypes.js +23 -0
- package/dist/constants/colors.js +18 -0
- package/dist/constants/index.js +18 -0
- package/dist/formatters/index.js +17 -0
- package/dist/formatters/timeFormatter.js +29 -0
- package/dist/generators/html/scripts/export.js +125 -0
- package/dist/generators/html/scripts/knowledge.js +120 -0
- package/dist/generators/html/scripts/modal.js +68 -0
- package/dist/generators/html/scripts/navigation.js +156 -0
- package/dist/generators/html/scripts/tabs.js +18 -0
- package/dist/generators/html/scripts/tooltip.js +21 -0
- package/dist/generators/html/styles/achievements.css +387 -0
- package/dist/generators/html/styles/base.css +818 -0
- package/dist/generators/html/styles/components.css +1391 -0
- package/dist/generators/html/styles/knowledge.css +221 -0
- package/dist/generators/html/templates/achievementsSection.js +156 -0
- package/dist/generators/html/templates/commitQualitySection.js +89 -0
- package/dist/generators/html/templates/contributionGraph.js +73 -0
- package/dist/generators/html/templates/impactSection.js +117 -0
- package/dist/generators/html/templates/knowledgeSection.js +226 -0
- package/dist/generators/html/templates/streakSection.js +42 -0
- package/dist/generators/html/templates/timePatternsSection.js +110 -0
- package/dist/generators/html/utils/colorUtils.js +21 -0
- package/dist/generators/html/utils/commitMapBuilder.js +24 -0
- package/dist/generators/html/utils/dateRangeCalculator.js +57 -0
- package/dist/generators/html/utils/developerStatsCalculator.js +29 -0
- package/dist/generators/html/utils/scriptLoader.js +16 -0
- package/dist/generators/html/utils/styleLoader.js +18 -0
- package/dist/generators/html/utils/weekGrouper.js +28 -0
- package/dist/index.js +77 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/achievementDefinitions.js +433 -0
- package/dist/utils/achievementEngine.js +170 -0
- package/dist/utils/commitQualityAnalyzer.js +368 -0
- package/dist/utils/fileHotspotAnalyzer.js +270 -0
- package/dist/utils/gitParser.js +125 -0
- package/dist/utils/htmlGenerator.js +449 -0
- package/dist/utils/impactAnalyzer.js +248 -0
- package/dist/utils/knowledgeDistributionAnalyzer.js +374 -0
- package/dist/utils/matrixGenerator.js +350 -0
- package/dist/utils/slideGenerator.js +313 -0
- package/dist/utils/streakCalculator.js +135 -0
- package/dist/utils/timePatternAnalyzer.js +305 -0
- package/dist/utils/wrappedDisplay.js +115 -0
- package/dist/utils/wrappedGenerator.js +377 -0
- package/dist/utils/wrappedHtmlGenerator.js +552 -0
- 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
|
+
}
|