repo-wrapped 0.0.2 → 0.0.4
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/dist/commands/generate.js +104 -95
- package/dist/constants/chronotypes.js +23 -23
- package/dist/constants/colors.js +18 -18
- package/dist/constants/index.js +18 -18
- package/dist/formatters/index.js +17 -17
- package/dist/formatters/timeFormatter.js +28 -29
- package/dist/generators/html/templates/achievementsSection.js +42 -43
- package/dist/generators/html/templates/commitQualitySection.js +25 -26
- package/dist/generators/html/templates/contributionGraph.js +47 -48
- package/dist/generators/html/templates/impactSection.js +19 -20
- package/dist/generators/html/templates/knowledgeSection.js +86 -87
- package/dist/generators/html/templates/streakSection.js +8 -9
- package/dist/generators/html/templates/timePatternsSection.js +45 -46
- package/dist/generators/html/utils/colorUtils.js +21 -21
- package/dist/generators/html/utils/commitMapBuilder.js +23 -24
- package/dist/generators/html/utils/dateRangeCalculator.js +56 -57
- package/dist/generators/html/utils/developerStatsCalculator.js +28 -29
- package/dist/generators/html/utils/scriptLoader.js +15 -16
- package/dist/generators/html/utils/styleLoader.js +17 -18
- package/dist/generators/html/utils/weekGrouper.js +27 -28
- package/dist/index.js +99 -77
- package/dist/types/index.js +2 -2
- package/dist/utils/achievementDefinitions.js +433 -433
- package/dist/utils/achievementEngine.js +169 -170
- package/dist/utils/commitQualityAnalyzer.js +367 -368
- package/dist/utils/fileHotspotAnalyzer.js +269 -270
- package/dist/utils/gitParser.js +136 -125
- package/dist/utils/htmlGenerator.js +232 -233
- package/dist/utils/impactAnalyzer.js +247 -248
- package/dist/utils/knowledgeDistributionAnalyzer.js +373 -374
- package/dist/utils/matrixGenerator.js +349 -350
- package/dist/utils/slideGenerator.js +170 -171
- package/dist/utils/streakCalculator.js +134 -135
- package/dist/utils/timePatternAnalyzer.js +304 -305
- package/dist/utils/wrappedDisplay.js +124 -115
- package/dist/utils/wrappedGenerator.js +376 -377
- package/dist/utils/wrappedHtmlGenerator.js +105 -106
- package/package.json +10 -10
|
@@ -1,374 +1,373 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.analyzeKnowledgeDistribution =
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeKnowledgeDistribution = analyzeKnowledgeDistribution;
|
|
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
|
+
/**
|
|
332
|
+
* Generate recommendations based on knowledge distribution
|
|
333
|
+
*/
|
|
334
|
+
function generateRecommendations(data) {
|
|
335
|
+
const recommendations = [];
|
|
336
|
+
if (data.knowledgeSilos.length > 0) {
|
|
337
|
+
recommendations.push(`Consider cross-training on ${data.knowledgeSilos.length} knowledge silo(s) to reduce bus factor risk`);
|
|
338
|
+
}
|
|
339
|
+
if (data.overallRisk >= 7) {
|
|
340
|
+
recommendations.push('High bus factor risk detected — prioritize knowledge sharing sessions');
|
|
341
|
+
}
|
|
342
|
+
const soloDirectories = data.directories.filter(d => d.ownershipType === 'solo');
|
|
343
|
+
if (soloDirectories.length > 3) {
|
|
344
|
+
recommendations.push(`${soloDirectories.length} directories have single owners — pair programming recommended`);
|
|
345
|
+
}
|
|
346
|
+
if (data.sharedKnowledge.length > 0) {
|
|
347
|
+
recommendations.push(`Good knowledge distribution in ${data.sharedKnowledge.length} area(s) — maintain this pattern`);
|
|
348
|
+
}
|
|
349
|
+
const inactiveOwners = data.directories.flatMap(d => d.owners.filter(o => {
|
|
350
|
+
const daysSinceActive = Math.floor((Date.now() - o.lastActive.getTime()) / (1000 * 60 * 60 * 24));
|
|
351
|
+
return daysSinceActive > 60 && o.percentage > 30;
|
|
352
|
+
}));
|
|
353
|
+
if (inactiveOwners.length > 0) {
|
|
354
|
+
recommendations.push('Some significant contributors have been inactive — ensure knowledge transfer');
|
|
355
|
+
}
|
|
356
|
+
return recommendations.slice(0, 5);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Return empty distribution for no commits
|
|
360
|
+
*/
|
|
361
|
+
function getEmptyKnowledgeDistribution() {
|
|
362
|
+
return {
|
|
363
|
+
directories: [],
|
|
364
|
+
busFactorRisk: {
|
|
365
|
+
overall: 0,
|
|
366
|
+
level: 'low',
|
|
367
|
+
criticalPaths: []
|
|
368
|
+
},
|
|
369
|
+
knowledgeSilos: [],
|
|
370
|
+
sharedKnowledge: [],
|
|
371
|
+
recommendations: ['No commit data available for analysis']
|
|
372
|
+
};
|
|
373
|
+
}
|