gitfamiliar 0.1.1 → 0.3.0
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/bin/gitfamiliar.js +1852 -79
- package/dist/bin/gitfamiliar.js.map +1 -1
- package/dist/{chunk-DW2PHZVZ.js → chunk-N6J2OJKF.js} +67 -19
- package/dist/chunk-N6J2OJKF.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-DW2PHZVZ.js.map +0 -1
package/dist/bin/gitfamiliar.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
GitClient,
|
|
4
|
+
GitHubClient,
|
|
5
|
+
buildFileTree,
|
|
3
6
|
computeFamiliarity,
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
createFilter,
|
|
8
|
+
parseExpirationConfig,
|
|
9
|
+
processBatch,
|
|
10
|
+
resolveGitHubToken,
|
|
11
|
+
resolveUser,
|
|
12
|
+
walkFiles
|
|
13
|
+
} from "../chunk-N6J2OJKF.js";
|
|
6
14
|
|
|
7
15
|
// src/cli/index.ts
|
|
8
16
|
import { Command } from "commander";
|
|
@@ -26,34 +34,66 @@ function parseOptions(raw, repoPath) {
|
|
|
26
34
|
weights = parseWeights(raw.weights);
|
|
27
35
|
}
|
|
28
36
|
const expiration = raw.expiration ? parseExpirationConfig(raw.expiration) : DEFAULT_EXPIRATION;
|
|
37
|
+
let user;
|
|
38
|
+
if (raw.user && raw.user.length === 1) {
|
|
39
|
+
user = raw.user[0];
|
|
40
|
+
} else if (raw.user && raw.user.length > 1) {
|
|
41
|
+
user = raw.user;
|
|
42
|
+
}
|
|
43
|
+
let hotspot;
|
|
44
|
+
if (raw.hotspot !== void 0 && raw.hotspot !== false) {
|
|
45
|
+
if (raw.hotspot === "team") {
|
|
46
|
+
hotspot = "team";
|
|
47
|
+
} else {
|
|
48
|
+
hotspot = "personal";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const windowDays = raw.window ? parseInt(raw.window, 10) : void 0;
|
|
29
52
|
return {
|
|
30
53
|
mode,
|
|
31
|
-
user
|
|
54
|
+
user,
|
|
32
55
|
filter,
|
|
33
56
|
expiration,
|
|
34
57
|
html: raw.html || false,
|
|
35
58
|
weights,
|
|
36
|
-
repoPath
|
|
59
|
+
repoPath,
|
|
60
|
+
team: raw.team || false,
|
|
61
|
+
teamCoverage: raw.teamCoverage || false,
|
|
62
|
+
hotspot,
|
|
63
|
+
window: windowDays,
|
|
64
|
+
githubUrl: raw.githubUrl,
|
|
65
|
+
checkGithub: raw.checkGithub || false
|
|
37
66
|
};
|
|
38
67
|
}
|
|
39
68
|
function validateMode(mode) {
|
|
40
|
-
const valid = [
|
|
69
|
+
const valid = [
|
|
70
|
+
"binary",
|
|
71
|
+
"authorship",
|
|
72
|
+
"review-coverage",
|
|
73
|
+
"weighted"
|
|
74
|
+
];
|
|
41
75
|
if (!valid.includes(mode)) {
|
|
42
|
-
throw new Error(
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Invalid mode: "${mode}". Valid modes: ${valid.join(", ")}`
|
|
78
|
+
);
|
|
43
79
|
}
|
|
44
80
|
return mode;
|
|
45
81
|
}
|
|
46
82
|
function validateFilter(filter) {
|
|
47
83
|
const valid = ["all", "written", "reviewed"];
|
|
48
84
|
if (!valid.includes(filter)) {
|
|
49
|
-
throw new Error(
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Invalid filter: "${filter}". Valid filters: ${valid.join(", ")}`
|
|
87
|
+
);
|
|
50
88
|
}
|
|
51
89
|
return filter;
|
|
52
90
|
}
|
|
53
91
|
function parseWeights(s) {
|
|
54
92
|
const parts = s.split(",").map(Number);
|
|
55
93
|
if (parts.length !== 3 || parts.some(isNaN)) {
|
|
56
|
-
throw new Error(
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Invalid weights: "${s}". Expected format: "0.5,0.35,0.15"`
|
|
96
|
+
);
|
|
57
97
|
}
|
|
58
98
|
const sum = parts[0] + parts[1] + parts[2];
|
|
59
99
|
if (Math.abs(sum - 1) > 0.01) {
|
|
@@ -299,11 +339,11 @@ function scoreColor(score) {
|
|
|
299
339
|
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
300
340
|
}
|
|
301
341
|
|
|
302
|
-
function
|
|
303
|
-
if (mode !== 'binary') return
|
|
304
|
-
if (currentFilter === 'written') return
|
|
305
|
-
if (currentFilter === 'reviewed') return
|
|
306
|
-
return
|
|
342
|
+
function getNodeScore(node) {
|
|
343
|
+
if (mode !== 'binary') return node.score;
|
|
344
|
+
if (currentFilter === 'written') return node.isWritten ? 1 : 0;
|
|
345
|
+
if (currentFilter === 'reviewed') return node.isReviewed ? 1 : 0;
|
|
346
|
+
return node.score;
|
|
307
347
|
}
|
|
308
348
|
|
|
309
349
|
function findNode(node, path) {
|
|
@@ -317,17 +357,23 @@ function findNode(node, path) {
|
|
|
317
357
|
return null;
|
|
318
358
|
}
|
|
319
359
|
|
|
320
|
-
function
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
360
|
+
function totalLines(node) {
|
|
361
|
+
if (node.type === 'file') return Math.max(1, node.lines);
|
|
362
|
+
if (!node.children) return 1;
|
|
363
|
+
let sum = 0;
|
|
364
|
+
for (const c of node.children) sum += totalLines(c);
|
|
365
|
+
return Math.max(1, sum);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function buildHierarchy(node) {
|
|
369
|
+
if (node.type === 'file') {
|
|
370
|
+
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
328
371
|
}
|
|
329
|
-
|
|
330
|
-
|
|
372
|
+
return {
|
|
373
|
+
name: node.path.split('/').pop() || node.path,
|
|
374
|
+
data: node,
|
|
375
|
+
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
376
|
+
};
|
|
331
377
|
}
|
|
332
378
|
|
|
333
379
|
function render() {
|
|
@@ -344,18 +390,13 @@ function render() {
|
|
|
344
390
|
const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
|
|
345
391
|
if (!targetNode) return;
|
|
346
392
|
|
|
393
|
+
const children = targetNode.children || [];
|
|
394
|
+
if (children.length === 0) return;
|
|
395
|
+
|
|
396
|
+
// Build full nested hierarchy from the current target
|
|
347
397
|
const hierarchyData = {
|
|
348
398
|
name: targetNode.path || 'root',
|
|
349
|
-
children:
|
|
350
|
-
if (c.type === 'file') {
|
|
351
|
-
return { name: c.path.split('/').pop(), data: c, value: Math.max(1, c.lines) };
|
|
352
|
-
}
|
|
353
|
-
return {
|
|
354
|
-
name: c.path.split('/').pop(),
|
|
355
|
-
data: c,
|
|
356
|
-
children: (c.children || []).map(buildChild),
|
|
357
|
-
};
|
|
358
|
-
}),
|
|
399
|
+
children: children.map(c => buildHierarchy(c)),
|
|
359
400
|
};
|
|
360
401
|
|
|
361
402
|
const root = d3.hierarchy(hierarchyData)
|
|
@@ -365,7 +406,7 @@ function render() {
|
|
|
365
406
|
d3.treemap()
|
|
366
407
|
.size([width, height])
|
|
367
408
|
.padding(2)
|
|
368
|
-
.paddingTop(
|
|
409
|
+
.paddingTop(20)
|
|
369
410
|
.round(true)(root);
|
|
370
411
|
|
|
371
412
|
const svg = d3.select('#treemap')
|
|
@@ -375,77 +416,94 @@ function render() {
|
|
|
375
416
|
|
|
376
417
|
const tooltip = document.getElementById('tooltip');
|
|
377
418
|
|
|
378
|
-
|
|
419
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
420
|
+
|
|
379
421
|
const groups = svg.selectAll('g')
|
|
380
|
-
.data(
|
|
422
|
+
.data(nodes)
|
|
381
423
|
.join('g')
|
|
382
424
|
.attr('transform', d => \`translate(\${d.x0},\${d.y0})\`);
|
|
383
425
|
|
|
426
|
+
// Rect
|
|
384
427
|
groups.append('rect')
|
|
385
428
|
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
386
429
|
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
387
430
|
.attr('fill', d => {
|
|
388
|
-
if (d.data.data)
|
|
389
|
-
|
|
390
|
-
return scoreColor(score);
|
|
391
|
-
}
|
|
392
|
-
return '#333';
|
|
431
|
+
if (!d.data.data) return '#333';
|
|
432
|
+
return scoreColor(getNodeScore(d.data.data));
|
|
393
433
|
})
|
|
394
|
-
.attr('opacity', d => d.children ? 0.
|
|
434
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
395
435
|
.attr('stroke', '#1a1a2e')
|
|
396
|
-
.attr('stroke-width', 1)
|
|
436
|
+
.attr('stroke-width', d => d.children ? 1 : 0.5)
|
|
397
437
|
.attr('rx', 2)
|
|
398
|
-
.style('cursor', d => d.
|
|
438
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
399
439
|
.on('click', (event, d) => {
|
|
400
|
-
if (d.
|
|
440
|
+
if (d.data.data && d.data.data.type === 'folder') {
|
|
441
|
+
event.stopPropagation();
|
|
401
442
|
zoomTo(d.data.data.path);
|
|
402
443
|
}
|
|
403
444
|
})
|
|
404
|
-
.on('mouseover', (event, d)
|
|
445
|
+
.on('mouseover', function(event, d) {
|
|
405
446
|
if (!d.data.data) return;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const score = d.children ? data.score : getFileScore(data);
|
|
409
|
-
let html = \`<strong>\${name}</strong><br>Score: \${Math.round(score * 100)}%<br>Lines: \${data.lines}\`;
|
|
410
|
-
if (data.type === 'folder') {
|
|
411
|
-
html += \`<br>Files: \${data.fileCount}\`;
|
|
412
|
-
}
|
|
413
|
-
if (data.blameScore !== undefined) {
|
|
414
|
-
html += \`<br>Blame: \${Math.round(data.blameScore * 100)}%\`;
|
|
415
|
-
}
|
|
416
|
-
if (data.commitScore !== undefined) {
|
|
417
|
-
html += \`<br>Commit: \${Math.round(data.commitScore * 100)}%\`;
|
|
418
|
-
}
|
|
419
|
-
if (data.reviewScore !== undefined) {
|
|
420
|
-
html += \`<br>Review: \${Math.round(data.reviewScore * 100)}%\`;
|
|
421
|
-
}
|
|
422
|
-
tooltip.innerHTML = html;
|
|
423
|
-
tooltip.style.display = 'block';
|
|
447
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
448
|
+
showTooltip(d.data.data, event);
|
|
424
449
|
})
|
|
425
450
|
.on('mousemove', (event) => {
|
|
426
|
-
tooltip.style.left = (event.pageX +
|
|
427
|
-
tooltip.style.top = (event.pageY -
|
|
451
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
452
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
428
453
|
})
|
|
429
|
-
.on('mouseout', ()
|
|
454
|
+
.on('mouseout', function(event, d) {
|
|
455
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
430
456
|
tooltip.style.display = 'none';
|
|
431
457
|
});
|
|
432
458
|
|
|
433
459
|
// Labels
|
|
434
460
|
groups.append('text')
|
|
435
461
|
.attr('x', 4)
|
|
436
|
-
.attr('y',
|
|
462
|
+
.attr('y', 14)
|
|
437
463
|
.attr('fill', '#fff')
|
|
438
|
-
.attr('font-size', '11px')
|
|
464
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
439
465
|
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
466
|
+
.style('pointer-events', 'none')
|
|
440
467
|
.text(d => {
|
|
441
|
-
const w =
|
|
468
|
+
const w = d.x1 - d.x0;
|
|
469
|
+
const h = d.y1 - d.y0;
|
|
442
470
|
const name = d.data.name || '';
|
|
443
|
-
if (w <
|
|
444
|
-
|
|
471
|
+
if (w < 36 || h < 18) return '';
|
|
472
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
473
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
445
474
|
return name;
|
|
446
475
|
});
|
|
447
476
|
}
|
|
448
477
|
|
|
478
|
+
function showTooltip(data, event) {
|
|
479
|
+
const tooltip = document.getElementById('tooltip');
|
|
480
|
+
const name = data.path || '';
|
|
481
|
+
const score = getNodeScore(data);
|
|
482
|
+
let html = '<strong>' + name + '</strong>';
|
|
483
|
+
html += '<br>Score: ' + Math.round(score * 100) + '%';
|
|
484
|
+
html += '<br>Lines: ' + data.lines.toLocaleString();
|
|
485
|
+
if (data.type === 'folder') {
|
|
486
|
+
html += '<br>Files: ' + data.fileCount;
|
|
487
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
488
|
+
}
|
|
489
|
+
if (data.blameScore !== undefined) {
|
|
490
|
+
html += '<br>Blame: ' + Math.round(data.blameScore * 100) + '%';
|
|
491
|
+
}
|
|
492
|
+
if (data.commitScore !== undefined) {
|
|
493
|
+
html += '<br>Commit: ' + Math.round(data.commitScore * 100) + '%';
|
|
494
|
+
}
|
|
495
|
+
if (data.reviewScore !== undefined) {
|
|
496
|
+
html += '<br>Review: ' + Math.round(data.reviewScore * 100) + '%';
|
|
497
|
+
}
|
|
498
|
+
if (data.isExpired) {
|
|
499
|
+
html += '<br><span style="color:#e94560">Expired</span>';
|
|
500
|
+
}
|
|
501
|
+
tooltip.innerHTML = html;
|
|
502
|
+
tooltip.style.display = 'block';
|
|
503
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
504
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
505
|
+
}
|
|
506
|
+
|
|
449
507
|
function zoomTo(path) {
|
|
450
508
|
currentPath = path;
|
|
451
509
|
updateBreadcrumb();
|
|
@@ -487,13 +545,1728 @@ async function generateAndOpenHTML(result, repoPath) {
|
|
|
487
545
|
await openBrowser(outputPath);
|
|
488
546
|
}
|
|
489
547
|
|
|
490
|
-
// src/
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
548
|
+
// src/git/contributors.ts
|
|
549
|
+
var COMMIT_SEP = "GITFAMILIAR_SEP";
|
|
550
|
+
async function getAllContributors(gitClient, minCommits = 1) {
|
|
551
|
+
const output = await gitClient.getLog([
|
|
552
|
+
"--all",
|
|
553
|
+
`--format=%aN|%aE`
|
|
554
|
+
]);
|
|
555
|
+
const counts = /* @__PURE__ */ new Map();
|
|
556
|
+
for (const line of output.trim().split("\n")) {
|
|
557
|
+
if (!line.includes("|")) continue;
|
|
558
|
+
const [name, email] = line.split("|", 2);
|
|
559
|
+
if (!name || !email) continue;
|
|
560
|
+
const key = email.toLowerCase();
|
|
561
|
+
const existing = counts.get(key);
|
|
562
|
+
if (existing) {
|
|
563
|
+
existing.count++;
|
|
564
|
+
} else {
|
|
565
|
+
counts.set(key, { name: name.trim(), email: email.trim(), count: 1 });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return Array.from(counts.values()).filter((c) => c.count >= minCommits).sort((a, b) => b.count - a.count).map((c) => ({ name: c.name, email: c.email }));
|
|
569
|
+
}
|
|
570
|
+
async function bulkGetFileContributors(gitClient, trackedFiles) {
|
|
571
|
+
const output = await gitClient.getLog([
|
|
572
|
+
"--all",
|
|
573
|
+
"--name-only",
|
|
574
|
+
`--format=${COMMIT_SEP}%aN|%aE`
|
|
575
|
+
]);
|
|
576
|
+
const result = /* @__PURE__ */ new Map();
|
|
577
|
+
let currentAuthor = "";
|
|
578
|
+
for (const line of output.split("\n")) {
|
|
579
|
+
if (line.startsWith(COMMIT_SEP)) {
|
|
580
|
+
const parts = line.slice(COMMIT_SEP.length).split("|", 2);
|
|
581
|
+
currentAuthor = parts[0]?.trim() || "";
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const filePath = line.trim();
|
|
585
|
+
if (!filePath || !currentAuthor) continue;
|
|
586
|
+
if (!trackedFiles.has(filePath)) continue;
|
|
587
|
+
let contributors = result.get(filePath);
|
|
588
|
+
if (!contributors) {
|
|
589
|
+
contributors = /* @__PURE__ */ new Set();
|
|
590
|
+
result.set(filePath, contributors);
|
|
591
|
+
}
|
|
592
|
+
contributors.add(currentAuthor);
|
|
593
|
+
}
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/core/team-coverage.ts
|
|
598
|
+
async function computeTeamCoverage(options) {
|
|
599
|
+
const gitClient = new GitClient(options.repoPath);
|
|
600
|
+
if (!await gitClient.isRepo()) {
|
|
601
|
+
throw new Error(`"${options.repoPath}" is not a git repository.`);
|
|
602
|
+
}
|
|
603
|
+
const repoRoot = await gitClient.getRepoRoot();
|
|
604
|
+
const repoName = await gitClient.getRepoName();
|
|
605
|
+
const filter = createFilter(repoRoot);
|
|
606
|
+
const tree = await buildFileTree(gitClient, filter);
|
|
607
|
+
const trackedFiles = /* @__PURE__ */ new Set();
|
|
608
|
+
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
609
|
+
const fileContributors = await bulkGetFileContributors(gitClient, trackedFiles);
|
|
610
|
+
const allContributors = await getAllContributors(gitClient);
|
|
611
|
+
const coverageTree = buildCoverageTree(tree, fileContributors);
|
|
612
|
+
const riskFiles = [];
|
|
613
|
+
walkCoverageFiles(coverageTree, (f) => {
|
|
614
|
+
if (f.contributorCount <= 1) {
|
|
615
|
+
riskFiles.push(f);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
riskFiles.sort((a, b) => a.contributorCount - b.contributorCount);
|
|
619
|
+
return {
|
|
620
|
+
tree: coverageTree,
|
|
621
|
+
repoName,
|
|
622
|
+
totalContributors: allContributors.length,
|
|
623
|
+
totalFiles: tree.fileCount,
|
|
624
|
+
riskFiles,
|
|
625
|
+
overallBusFactor: calculateBusFactor(fileContributors)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function classifyRisk(contributorCount) {
|
|
629
|
+
if (contributorCount <= 1) return "risk";
|
|
630
|
+
if (contributorCount <= 3) return "moderate";
|
|
631
|
+
return "safe";
|
|
632
|
+
}
|
|
633
|
+
function buildCoverageTree(node, fileContributors) {
|
|
634
|
+
const children = [];
|
|
635
|
+
for (const child of node.children) {
|
|
636
|
+
if (child.type === "file") {
|
|
637
|
+
const contributors = fileContributors.get(child.path);
|
|
638
|
+
const names = contributors ? Array.from(contributors) : [];
|
|
639
|
+
children.push({
|
|
640
|
+
type: "file",
|
|
641
|
+
path: child.path,
|
|
642
|
+
lines: child.lines,
|
|
643
|
+
contributorCount: names.length,
|
|
644
|
+
contributors: names,
|
|
645
|
+
riskLevel: classifyRisk(names.length)
|
|
646
|
+
});
|
|
647
|
+
} else {
|
|
648
|
+
children.push(buildCoverageTree(child, fileContributors));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const fileScores = [];
|
|
652
|
+
walkCoverageFiles({ type: "folder", path: "", lines: 0, fileCount: 0, avgContributors: 0, busFactor: 0, riskLevel: "safe", children }, (f) => {
|
|
653
|
+
fileScores.push(f);
|
|
654
|
+
});
|
|
655
|
+
const totalContributors = fileScores.reduce((sum, f) => sum + f.contributorCount, 0);
|
|
656
|
+
const avgContributors = fileScores.length > 0 ? totalContributors / fileScores.length : 0;
|
|
657
|
+
const folderFileContributors = /* @__PURE__ */ new Map();
|
|
658
|
+
for (const f of fileScores) {
|
|
659
|
+
folderFileContributors.set(f.path, new Set(f.contributors));
|
|
660
|
+
}
|
|
661
|
+
const busFactor = calculateBusFactor(folderFileContributors);
|
|
662
|
+
return {
|
|
663
|
+
type: "folder",
|
|
664
|
+
path: node.path,
|
|
665
|
+
lines: node.lines,
|
|
666
|
+
fileCount: node.fileCount,
|
|
667
|
+
avgContributors: Math.round(avgContributors * 10) / 10,
|
|
668
|
+
busFactor,
|
|
669
|
+
riskLevel: classifyRisk(busFactor),
|
|
670
|
+
children
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function walkCoverageFiles(node, visitor) {
|
|
674
|
+
if (node.type === "file") {
|
|
675
|
+
visitor(node);
|
|
676
|
+
} else {
|
|
677
|
+
for (const child of node.children) {
|
|
678
|
+
walkCoverageFiles(child, visitor);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function calculateBusFactor(fileContributors) {
|
|
683
|
+
const totalFiles = fileContributors.size;
|
|
684
|
+
if (totalFiles === 0) return 0;
|
|
685
|
+
const target = Math.ceil(totalFiles * 0.5);
|
|
686
|
+
const contributorFiles = /* @__PURE__ */ new Map();
|
|
687
|
+
for (const [file, contributors] of fileContributors) {
|
|
688
|
+
for (const contributor of contributors) {
|
|
689
|
+
let files = contributorFiles.get(contributor);
|
|
690
|
+
if (!files) {
|
|
691
|
+
files = /* @__PURE__ */ new Set();
|
|
692
|
+
contributorFiles.set(contributor, files);
|
|
693
|
+
}
|
|
694
|
+
files.add(file);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const coveredFiles = /* @__PURE__ */ new Set();
|
|
698
|
+
let count = 0;
|
|
699
|
+
while (coveredFiles.size < target && contributorFiles.size > 0) {
|
|
700
|
+
let bestContributor = "";
|
|
701
|
+
let bestNewFiles = 0;
|
|
702
|
+
for (const [contributor, files2] of contributorFiles) {
|
|
703
|
+
let newFiles = 0;
|
|
704
|
+
for (const file of files2) {
|
|
705
|
+
if (!coveredFiles.has(file)) newFiles++;
|
|
706
|
+
}
|
|
707
|
+
if (newFiles > bestNewFiles) {
|
|
708
|
+
bestNewFiles = newFiles;
|
|
709
|
+
bestContributor = contributor;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (bestNewFiles === 0) break;
|
|
713
|
+
const files = contributorFiles.get(bestContributor);
|
|
714
|
+
for (const file of files) {
|
|
715
|
+
coveredFiles.add(file);
|
|
716
|
+
}
|
|
717
|
+
contributorFiles.delete(bestContributor);
|
|
718
|
+
count++;
|
|
719
|
+
}
|
|
720
|
+
return count;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/cli/output/coverage-terminal.ts
|
|
724
|
+
import chalk2 from "chalk";
|
|
725
|
+
function riskBadge(level) {
|
|
726
|
+
switch (level) {
|
|
727
|
+
case "risk":
|
|
728
|
+
return chalk2.bgRed.white(" RISK ");
|
|
729
|
+
case "moderate":
|
|
730
|
+
return chalk2.bgYellow.black(" MOD ");
|
|
731
|
+
case "safe":
|
|
732
|
+
return chalk2.bgGreen.black(" SAFE ");
|
|
733
|
+
default:
|
|
734
|
+
return level;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function riskColor(level) {
|
|
738
|
+
switch (level) {
|
|
739
|
+
case "risk":
|
|
740
|
+
return chalk2.red;
|
|
741
|
+
case "moderate":
|
|
742
|
+
return chalk2.yellow;
|
|
743
|
+
default:
|
|
744
|
+
return chalk2.green;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function renderFolder2(node, indent, maxDepth) {
|
|
748
|
+
const lines = [];
|
|
749
|
+
const sorted = [...node.children].sort((a, b) => {
|
|
750
|
+
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
751
|
+
return a.path.localeCompare(b.path);
|
|
752
|
+
});
|
|
753
|
+
for (const child of sorted) {
|
|
754
|
+
if (child.type === "folder") {
|
|
755
|
+
const prefix = " ".repeat(indent);
|
|
756
|
+
const name = (child.path.split("/").pop() || child.path) + "/";
|
|
757
|
+
const color = riskColor(child.riskLevel);
|
|
758
|
+
lines.push(
|
|
759
|
+
`${prefix}${chalk2.bold(name.padEnd(24))} ${String(child.avgContributors).padStart(4)} avg ${String(child.busFactor).padStart(2)} ${riskBadge(child.riskLevel)}`
|
|
760
|
+
);
|
|
761
|
+
if (indent < maxDepth) {
|
|
762
|
+
lines.push(...renderFolder2(child, indent + 1, maxDepth));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return lines;
|
|
767
|
+
}
|
|
768
|
+
function renderCoverageTerminal(result) {
|
|
769
|
+
console.log("");
|
|
770
|
+
console.log(
|
|
771
|
+
chalk2.bold(
|
|
772
|
+
`GitFamiliar \u2014 Team Coverage (${result.totalFiles} files, ${result.totalContributors} contributors)`
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
console.log("");
|
|
776
|
+
const bfColor = result.overallBusFactor <= 1 ? chalk2.red : result.overallBusFactor <= 2 ? chalk2.yellow : chalk2.green;
|
|
777
|
+
console.log(`Overall Bus Factor: ${bfColor.bold(String(result.overallBusFactor))}`);
|
|
778
|
+
console.log("");
|
|
779
|
+
if (result.riskFiles.length > 0) {
|
|
780
|
+
console.log(chalk2.red.bold(`Risk Files (0-1 contributors):`));
|
|
781
|
+
const displayFiles = result.riskFiles.slice(0, 20);
|
|
782
|
+
for (const file of displayFiles) {
|
|
783
|
+
const count = file.contributorCount;
|
|
784
|
+
const names = file.contributors.join(", ");
|
|
785
|
+
const label = count === 0 ? chalk2.red("0 people") : chalk2.yellow(`1 person (${names})`);
|
|
786
|
+
console.log(` ${file.path.padEnd(40)} ${label}`);
|
|
787
|
+
}
|
|
788
|
+
if (result.riskFiles.length > 20) {
|
|
789
|
+
console.log(
|
|
790
|
+
chalk2.gray(` ... and ${result.riskFiles.length - 20} more`)
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
console.log("");
|
|
794
|
+
} else {
|
|
795
|
+
console.log(chalk2.green("No high-risk files found."));
|
|
796
|
+
console.log("");
|
|
797
|
+
}
|
|
798
|
+
console.log(chalk2.bold("Folder Coverage:"));
|
|
799
|
+
console.log(
|
|
800
|
+
chalk2.gray(
|
|
801
|
+
` ${"Folder".padEnd(24)} ${"Avg Contrib".padStart(11)} ${"Bus Factor".padStart(10)} Risk`
|
|
802
|
+
)
|
|
803
|
+
);
|
|
804
|
+
const folderLines = renderFolder2(result.tree, 1, 2);
|
|
805
|
+
for (const line of folderLines) {
|
|
806
|
+
console.log(line);
|
|
807
|
+
}
|
|
808
|
+
console.log("");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/cli/output/coverage-html.ts
|
|
812
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
813
|
+
import { join as join2 } from "path";
|
|
814
|
+
function generateCoverageHTML(result) {
|
|
815
|
+
const dataJson = JSON.stringify(result.tree);
|
|
816
|
+
const riskFilesJson = JSON.stringify(result.riskFiles);
|
|
817
|
+
return `<!DOCTYPE html>
|
|
818
|
+
<html lang="en">
|
|
819
|
+
<head>
|
|
820
|
+
<meta charset="UTF-8">
|
|
821
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
822
|
+
<title>GitFamiliar \u2014 Team Coverage \u2014 ${result.repoName}</title>
|
|
823
|
+
<style>
|
|
824
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
825
|
+
body {
|
|
826
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
827
|
+
background: #1a1a2e;
|
|
828
|
+
color: #e0e0e0;
|
|
829
|
+
overflow: hidden;
|
|
830
|
+
}
|
|
831
|
+
#header {
|
|
832
|
+
padding: 16px 24px;
|
|
833
|
+
background: #16213e;
|
|
834
|
+
border-bottom: 1px solid #0f3460;
|
|
835
|
+
display: flex;
|
|
836
|
+
align-items: center;
|
|
837
|
+
justify-content: space-between;
|
|
838
|
+
}
|
|
839
|
+
#header h1 { font-size: 18px; color: #e94560; }
|
|
840
|
+
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
841
|
+
#breadcrumb {
|
|
842
|
+
padding: 8px 24px;
|
|
843
|
+
background: #16213e;
|
|
844
|
+
font-size: 13px;
|
|
845
|
+
border-bottom: 1px solid #0f3460;
|
|
846
|
+
}
|
|
847
|
+
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
848
|
+
#breadcrumb span:hover { text-decoration: underline; }
|
|
849
|
+
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
850
|
+
#main { display: flex; height: calc(100vh - 90px); }
|
|
851
|
+
#treemap { flex: 1; }
|
|
852
|
+
#sidebar {
|
|
853
|
+
width: 300px;
|
|
854
|
+
background: #16213e;
|
|
855
|
+
border-left: 1px solid #0f3460;
|
|
856
|
+
overflow-y: auto;
|
|
857
|
+
padding: 16px;
|
|
858
|
+
}
|
|
859
|
+
#sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
|
|
860
|
+
#sidebar .risk-file {
|
|
861
|
+
padding: 6px 0;
|
|
862
|
+
border-bottom: 1px solid #0f3460;
|
|
863
|
+
font-size: 12px;
|
|
864
|
+
}
|
|
865
|
+
#sidebar .risk-file .path { color: #e0e0e0; word-break: break-all; }
|
|
866
|
+
#sidebar .risk-file .meta { color: #888; margin-top: 2px; }
|
|
867
|
+
#tooltip {
|
|
868
|
+
position: absolute;
|
|
869
|
+
pointer-events: none;
|
|
870
|
+
background: rgba(22, 33, 62, 0.95);
|
|
871
|
+
border: 1px solid #0f3460;
|
|
872
|
+
border-radius: 6px;
|
|
873
|
+
padding: 10px 14px;
|
|
874
|
+
font-size: 13px;
|
|
875
|
+
line-height: 1.6;
|
|
876
|
+
display: none;
|
|
877
|
+
z-index: 100;
|
|
878
|
+
max-width: 320px;
|
|
879
|
+
}
|
|
880
|
+
#legend {
|
|
881
|
+
position: absolute;
|
|
882
|
+
bottom: 16px;
|
|
883
|
+
left: 16px;
|
|
884
|
+
background: rgba(22, 33, 62, 0.9);
|
|
885
|
+
border: 1px solid #0f3460;
|
|
886
|
+
border-radius: 6px;
|
|
887
|
+
padding: 10px;
|
|
888
|
+
font-size: 12px;
|
|
889
|
+
}
|
|
890
|
+
#legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
|
891
|
+
#legend .swatch { width: 14px; height: 14px; border-radius: 3px; }
|
|
892
|
+
</style>
|
|
893
|
+
</head>
|
|
894
|
+
<body>
|
|
895
|
+
<div id="header">
|
|
896
|
+
<h1>GitFamiliar \u2014 Team Coverage \u2014 ${result.repoName}</h1>
|
|
897
|
+
<div class="info">${result.totalFiles} files | ${result.totalContributors} contributors | Bus Factor: ${result.overallBusFactor}</div>
|
|
898
|
+
</div>
|
|
899
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
900
|
+
<div id="main">
|
|
901
|
+
<div id="treemap"></div>
|
|
902
|
+
<div id="sidebar">
|
|
903
|
+
<h3>Risk Files (0-1 contributors)</h3>
|
|
904
|
+
<div id="risk-list"></div>
|
|
905
|
+
</div>
|
|
906
|
+
</div>
|
|
907
|
+
<div id="tooltip"></div>
|
|
908
|
+
<div id="legend">
|
|
909
|
+
<div>Contributors</div>
|
|
910
|
+
<div class="row"><div class="swatch" style="background:#e94560"></div> 0\u20131 (Risk)</div>
|
|
911
|
+
<div class="row"><div class="swatch" style="background:#f5a623"></div> 2\u20133 (Moderate)</div>
|
|
912
|
+
<div class="row"><div class="swatch" style="background:#27ae60"></div> 4+ (Safe)</div>
|
|
913
|
+
</div>
|
|
914
|
+
|
|
915
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
916
|
+
<script>
|
|
917
|
+
const rawData = ${dataJson};
|
|
918
|
+
const riskFiles = ${riskFilesJson};
|
|
919
|
+
let currentPath = '';
|
|
920
|
+
|
|
921
|
+
function coverageColor(count) {
|
|
922
|
+
if (count <= 0) return '#e94560';
|
|
923
|
+
if (count === 1) return '#d63c57';
|
|
924
|
+
if (count <= 3) return '#f5a623';
|
|
925
|
+
return '#27ae60';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function folderColor(riskLevel) {
|
|
929
|
+
switch (riskLevel) {
|
|
930
|
+
case 'risk': return '#e94560';
|
|
931
|
+
case 'moderate': return '#f5a623';
|
|
932
|
+
default: return '#27ae60';
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function findNode(node, path) {
|
|
937
|
+
if (node.path === path) return node;
|
|
938
|
+
if (node.children) {
|
|
939
|
+
for (const child of node.children) {
|
|
940
|
+
const found = findNode(child, path);
|
|
941
|
+
if (found) return found;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function buildHierarchy(node) {
|
|
948
|
+
if (node.type === 'file') {
|
|
949
|
+
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
name: node.path.split('/').pop() || node.path,
|
|
953
|
+
data: node,
|
|
954
|
+
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function render() {
|
|
959
|
+
const container = document.getElementById('treemap');
|
|
960
|
+
container.innerHTML = '';
|
|
961
|
+
|
|
962
|
+
const headerH = document.getElementById('header').offsetHeight;
|
|
963
|
+
const breadcrumbH = document.getElementById('breadcrumb').offsetHeight;
|
|
964
|
+
const width = container.offsetWidth;
|
|
965
|
+
const height = window.innerHeight - headerH - breadcrumbH;
|
|
966
|
+
|
|
967
|
+
const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
|
|
968
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
969
|
+
|
|
970
|
+
const hierarchyData = {
|
|
971
|
+
name: targetNode.path || 'root',
|
|
972
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const root = d3.hierarchy(hierarchyData)
|
|
976
|
+
.sum(d => d.value || 0)
|
|
977
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
978
|
+
|
|
979
|
+
d3.treemap()
|
|
980
|
+
.size([width, height])
|
|
981
|
+
.padding(2)
|
|
982
|
+
.paddingTop(20)
|
|
983
|
+
.round(true)(root);
|
|
984
|
+
|
|
985
|
+
const svg = d3.select('#treemap')
|
|
986
|
+
.append('svg')
|
|
987
|
+
.attr('width', width)
|
|
988
|
+
.attr('height', height);
|
|
989
|
+
|
|
990
|
+
const tooltip = document.getElementById('tooltip');
|
|
991
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
992
|
+
|
|
993
|
+
const groups = svg.selectAll('g')
|
|
994
|
+
.data(nodes)
|
|
995
|
+
.join('g')
|
|
996
|
+
.attr('transform', d => \`translate(\${d.x0},\${d.y0})\`);
|
|
997
|
+
|
|
998
|
+
groups.append('rect')
|
|
999
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
1000
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
1001
|
+
.attr('fill', d => {
|
|
1002
|
+
if (!d.data.data) return '#333';
|
|
1003
|
+
if (d.data.data.type === 'file') return coverageColor(d.data.data.contributorCount);
|
|
1004
|
+
return folderColor(d.data.data.riskLevel);
|
|
1005
|
+
})
|
|
1006
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
1007
|
+
.attr('stroke', '#1a1a2e')
|
|
1008
|
+
.attr('stroke-width', d => d.children ? 1 : 0.5)
|
|
1009
|
+
.attr('rx', 2)
|
|
1010
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
1011
|
+
.on('click', (event, d) => {
|
|
1012
|
+
if (d.data.data && d.data.data.type === 'folder') {
|
|
1013
|
+
event.stopPropagation();
|
|
1014
|
+
zoomTo(d.data.data.path);
|
|
1015
|
+
}
|
|
1016
|
+
})
|
|
1017
|
+
.on('mouseover', function(event, d) {
|
|
1018
|
+
if (!d.data.data) return;
|
|
1019
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
1020
|
+
showTooltip(d.data.data, event);
|
|
1021
|
+
})
|
|
1022
|
+
.on('mousemove', (event) => {
|
|
1023
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
1024
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1025
|
+
})
|
|
1026
|
+
.on('mouseout', function(event, d) {
|
|
1027
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
1028
|
+
tooltip.style.display = 'none';
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
groups.append('text')
|
|
1032
|
+
.attr('x', 4)
|
|
1033
|
+
.attr('y', 14)
|
|
1034
|
+
.attr('fill', '#fff')
|
|
1035
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
1036
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
1037
|
+
.style('pointer-events', 'none')
|
|
1038
|
+
.text(d => {
|
|
1039
|
+
const w = d.x1 - d.x0;
|
|
1040
|
+
const h = d.y1 - d.y0;
|
|
1041
|
+
const name = d.data.name || '';
|
|
1042
|
+
if (w < 36 || h < 18) return '';
|
|
1043
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
1044
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
1045
|
+
return name;
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function showTooltip(data, event) {
|
|
1050
|
+
const tooltip = document.getElementById('tooltip');
|
|
1051
|
+
let html = '<strong>' + data.path + '</strong>';
|
|
1052
|
+
if (data.type === 'file') {
|
|
1053
|
+
html += '<br>Contributors: ' + data.contributorCount;
|
|
1054
|
+
if (data.contributors.length > 0) {
|
|
1055
|
+
html += '<br>' + data.contributors.slice(0, 8).join(', ');
|
|
1056
|
+
if (data.contributors.length > 8) html += ', ...';
|
|
1057
|
+
}
|
|
1058
|
+
html += '<br>Lines: ' + data.lines.toLocaleString();
|
|
1059
|
+
} else {
|
|
1060
|
+
html += '<br>Files: ' + data.fileCount;
|
|
1061
|
+
html += '<br>Avg Contributors: ' + data.avgContributors;
|
|
1062
|
+
html += '<br>Bus Factor: ' + data.busFactor;
|
|
1063
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
1064
|
+
}
|
|
1065
|
+
tooltip.innerHTML = html;
|
|
1066
|
+
tooltip.style.display = 'block';
|
|
1067
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
1068
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function zoomTo(path) {
|
|
1072
|
+
currentPath = path;
|
|
1073
|
+
const el = document.getElementById('breadcrumb');
|
|
1074
|
+
const parts = path ? path.split('/') : [];
|
|
1075
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
1076
|
+
let accumulated = '';
|
|
1077
|
+
for (const part of parts) {
|
|
1078
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
1079
|
+
const p = accumulated;
|
|
1080
|
+
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
1081
|
+
}
|
|
1082
|
+
el.innerHTML = html;
|
|
1083
|
+
render();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Render risk sidebar
|
|
1087
|
+
function renderRiskSidebar() {
|
|
1088
|
+
const container = document.getElementById('risk-list');
|
|
1089
|
+
if (riskFiles.length === 0) {
|
|
1090
|
+
container.innerHTML = '<div style="color:#888">No high-risk files found.</div>';
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
let html = '';
|
|
1094
|
+
for (const f of riskFiles.slice(0, 50)) {
|
|
1095
|
+
const countLabel = f.contributorCount === 0 ? '0 people' : '1 person (' + f.contributors[0] + ')';
|
|
1096
|
+
html += '<div class="risk-file"><div class="path">' + f.path + '</div><div class="meta">' + countLabel + '</div></div>';
|
|
1097
|
+
}
|
|
1098
|
+
if (riskFiles.length > 50) {
|
|
1099
|
+
html += '<div style="color:#888;padding:8px 0">... and ' + (riskFiles.length - 50) + ' more</div>';
|
|
1100
|
+
}
|
|
1101
|
+
container.innerHTML = html;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
window.addEventListener('resize', render);
|
|
1105
|
+
renderRiskSidebar();
|
|
1106
|
+
render();
|
|
1107
|
+
</script>
|
|
1108
|
+
</body>
|
|
1109
|
+
</html>`;
|
|
1110
|
+
}
|
|
1111
|
+
async function generateAndOpenCoverageHTML(result, repoPath) {
|
|
1112
|
+
const html = generateCoverageHTML(result);
|
|
1113
|
+
const outputPath = join2(repoPath, "gitfamiliar-coverage.html");
|
|
1114
|
+
writeFileSync2(outputPath, html, "utf-8");
|
|
1115
|
+
console.log(`Coverage report generated: ${outputPath}`);
|
|
1116
|
+
await openBrowser(outputPath);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// src/core/multi-user.ts
|
|
1120
|
+
async function computeMultiUser(options) {
|
|
1121
|
+
const gitClient = new GitClient(options.repoPath);
|
|
1122
|
+
if (!await gitClient.isRepo()) {
|
|
1123
|
+
throw new Error(`"${options.repoPath}" is not a git repository.`);
|
|
1124
|
+
}
|
|
1125
|
+
const repoName = await gitClient.getRepoName();
|
|
1126
|
+
let userNames;
|
|
1127
|
+
if (options.team) {
|
|
1128
|
+
const contributors = await getAllContributors(gitClient, 3);
|
|
1129
|
+
userNames = contributors.map((c) => c.name);
|
|
1130
|
+
if (userNames.length === 0) {
|
|
1131
|
+
throw new Error("No contributors found with 3+ commits.");
|
|
1132
|
+
}
|
|
1133
|
+
console.log(`Found ${userNames.length} contributors with 3+ commits`);
|
|
1134
|
+
} else if (Array.isArray(options.user)) {
|
|
1135
|
+
userNames = options.user;
|
|
1136
|
+
} else if (options.user) {
|
|
1137
|
+
userNames = [options.user];
|
|
1138
|
+
} else {
|
|
1139
|
+
const user = await resolveUser(gitClient);
|
|
1140
|
+
userNames = [user.name || user.email];
|
|
1141
|
+
}
|
|
1142
|
+
const results = [];
|
|
1143
|
+
await processBatch(
|
|
1144
|
+
userNames,
|
|
1145
|
+
async (userName) => {
|
|
1146
|
+
const userOptions = {
|
|
1147
|
+
...options,
|
|
1148
|
+
user: userName,
|
|
1149
|
+
team: false,
|
|
1150
|
+
teamCoverage: false
|
|
1151
|
+
};
|
|
1152
|
+
const result = await computeFamiliarity(userOptions);
|
|
1153
|
+
results.push({ userName, result });
|
|
1154
|
+
},
|
|
1155
|
+
3
|
|
1156
|
+
);
|
|
1157
|
+
const users = results.map((r) => ({
|
|
1158
|
+
name: r.result.userName,
|
|
1159
|
+
email: ""
|
|
1160
|
+
}));
|
|
1161
|
+
const tree = mergeResults(results);
|
|
1162
|
+
const userSummaries = results.map((r) => ({
|
|
1163
|
+
user: { name: r.result.userName, email: "" },
|
|
1164
|
+
writtenCount: r.result.writtenCount,
|
|
1165
|
+
reviewedCount: r.result.reviewedCount,
|
|
1166
|
+
overallScore: r.result.tree.score
|
|
1167
|
+
}));
|
|
1168
|
+
return {
|
|
1169
|
+
tree,
|
|
1170
|
+
repoName,
|
|
1171
|
+
users,
|
|
1172
|
+
mode: options.mode,
|
|
1173
|
+
totalFiles: results[0]?.result.totalFiles || 0,
|
|
1174
|
+
userSummaries
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function mergeResults(results) {
|
|
1178
|
+
if (results.length === 0) {
|
|
1179
|
+
return {
|
|
1180
|
+
type: "folder",
|
|
1181
|
+
path: "",
|
|
1182
|
+
lines: 0,
|
|
1183
|
+
score: 0,
|
|
1184
|
+
fileCount: 0,
|
|
1185
|
+
userScores: [],
|
|
1186
|
+
children: []
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const baseTree = results[0].result.tree;
|
|
1190
|
+
const fileScoresMap = /* @__PURE__ */ new Map();
|
|
1191
|
+
for (const { result } of results) {
|
|
1192
|
+
const userName = result.userName;
|
|
1193
|
+
walkFiles(result.tree, (file) => {
|
|
1194
|
+
let scores = fileScoresMap.get(file.path);
|
|
1195
|
+
if (!scores) {
|
|
1196
|
+
scores = [];
|
|
1197
|
+
fileScoresMap.set(file.path, scores);
|
|
1198
|
+
}
|
|
1199
|
+
scores.push({
|
|
1200
|
+
user: { name: userName, email: "" },
|
|
1201
|
+
score: file.score,
|
|
1202
|
+
isWritten: file.isWritten,
|
|
1203
|
+
isReviewed: file.isReviewed
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return convertFolder(baseTree, fileScoresMap, results);
|
|
1208
|
+
}
|
|
1209
|
+
function convertFolder(node, fileScoresMap, results) {
|
|
1210
|
+
const children = [];
|
|
1211
|
+
for (const child of node.children) {
|
|
1212
|
+
if (child.type === "file") {
|
|
1213
|
+
const userScores2 = fileScoresMap.get(child.path) || [];
|
|
1214
|
+
const avgScore2 = userScores2.length > 0 ? userScores2.reduce((sum, s) => sum + s.score, 0) / userScores2.length : 0;
|
|
1215
|
+
children.push({
|
|
1216
|
+
type: "file",
|
|
1217
|
+
path: child.path,
|
|
1218
|
+
lines: child.lines,
|
|
1219
|
+
score: avgScore2,
|
|
1220
|
+
userScores: userScores2
|
|
1221
|
+
});
|
|
1222
|
+
} else {
|
|
1223
|
+
children.push(convertFolder(child, fileScoresMap, results));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const userScores = results.map(({ result }) => {
|
|
1227
|
+
const folderNode = findFolderInTree(result.tree, node.path);
|
|
1228
|
+
return {
|
|
1229
|
+
user: { name: result.userName, email: "" },
|
|
1230
|
+
score: folderNode?.score || 0
|
|
1231
|
+
};
|
|
1232
|
+
});
|
|
1233
|
+
const avgScore = userScores.length > 0 ? userScores.reduce((sum, s) => sum + s.score, 0) / userScores.length : 0;
|
|
1234
|
+
return {
|
|
1235
|
+
type: "folder",
|
|
1236
|
+
path: node.path,
|
|
1237
|
+
lines: node.lines,
|
|
1238
|
+
score: avgScore,
|
|
1239
|
+
fileCount: node.fileCount,
|
|
1240
|
+
userScores,
|
|
1241
|
+
children
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function findFolderInTree(node, targetPath) {
|
|
1245
|
+
if (node.type === "folder") {
|
|
1246
|
+
if (node.path === targetPath) return node;
|
|
1247
|
+
for (const child of node.children) {
|
|
1248
|
+
const found = findFolderInTree(child, targetPath);
|
|
1249
|
+
if (found) return found;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/cli/output/multi-user-terminal.ts
|
|
1256
|
+
import chalk3 from "chalk";
|
|
1257
|
+
var BAR_WIDTH2 = 20;
|
|
1258
|
+
var FILLED_CHAR2 = "\u2588";
|
|
1259
|
+
var EMPTY_CHAR2 = "\u2591";
|
|
1260
|
+
function makeBar2(score, width = BAR_WIDTH2) {
|
|
1261
|
+
const filled = Math.round(score * width);
|
|
1262
|
+
const empty = width - filled;
|
|
1263
|
+
const bar = FILLED_CHAR2.repeat(filled) + EMPTY_CHAR2.repeat(empty);
|
|
1264
|
+
if (score >= 0.8) return chalk3.green(bar);
|
|
1265
|
+
if (score >= 0.5) return chalk3.yellow(bar);
|
|
1266
|
+
if (score > 0) return chalk3.red(bar);
|
|
1267
|
+
return chalk3.gray(bar);
|
|
1268
|
+
}
|
|
1269
|
+
function formatPercent2(score) {
|
|
1270
|
+
return `${Math.round(score * 100)}%`;
|
|
1271
|
+
}
|
|
1272
|
+
function getModeLabel2(mode) {
|
|
1273
|
+
switch (mode) {
|
|
1274
|
+
case "binary":
|
|
1275
|
+
return "Binary mode";
|
|
1276
|
+
case "authorship":
|
|
1277
|
+
return "Authorship mode";
|
|
1278
|
+
case "review-coverage":
|
|
1279
|
+
return "Review Coverage mode";
|
|
1280
|
+
case "weighted":
|
|
1281
|
+
return "Weighted mode";
|
|
1282
|
+
default:
|
|
1283
|
+
return mode;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function truncateName(name, maxLen) {
|
|
1287
|
+
if (name.length <= maxLen) return name;
|
|
1288
|
+
return name.slice(0, maxLen - 1) + "\u2026";
|
|
1289
|
+
}
|
|
1290
|
+
function renderFolder3(node, indent, maxDepth, nameWidth) {
|
|
1291
|
+
const lines = [];
|
|
1292
|
+
const sorted = [...node.children].sort((a, b) => {
|
|
1293
|
+
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
1294
|
+
return a.path.localeCompare(b.path);
|
|
1295
|
+
});
|
|
1296
|
+
for (const child of sorted) {
|
|
1297
|
+
if (child.type === "folder") {
|
|
1298
|
+
const prefix = " ".repeat(indent);
|
|
1299
|
+
const name = (child.path.split("/").pop() || child.path) + "/";
|
|
1300
|
+
const displayName = truncateName(name, nameWidth).padEnd(nameWidth);
|
|
1301
|
+
const scores = child.userScores.map((s) => formatPercent2(s.score).padStart(5)).join(" ");
|
|
1302
|
+
lines.push(`${prefix}${chalk3.bold(displayName)} ${scores}`);
|
|
1303
|
+
if (indent < maxDepth) {
|
|
1304
|
+
lines.push(...renderFolder3(child, indent + 1, maxDepth, nameWidth));
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return lines;
|
|
1309
|
+
}
|
|
1310
|
+
function renderMultiUserTerminal(result) {
|
|
1311
|
+
const { tree, repoName, mode, userSummaries, totalFiles } = result;
|
|
1312
|
+
console.log("");
|
|
1313
|
+
console.log(
|
|
1314
|
+
chalk3.bold(
|
|
1315
|
+
`GitFamiliar \u2014 ${repoName} (${getModeLabel2(mode)}, ${userSummaries.length} users)`
|
|
1316
|
+
)
|
|
1317
|
+
);
|
|
1318
|
+
console.log("");
|
|
1319
|
+
console.log(chalk3.bold("Overall:"));
|
|
1320
|
+
for (const summary of userSummaries) {
|
|
1321
|
+
const name = truncateName(summary.user.name, 14).padEnd(14);
|
|
1322
|
+
const bar = makeBar2(summary.overallScore);
|
|
1323
|
+
const pct = formatPercent2(summary.overallScore);
|
|
1324
|
+
if (mode === "binary") {
|
|
1325
|
+
console.log(
|
|
1326
|
+
` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount + summary.reviewedCount}/${totalFiles} files)`
|
|
1327
|
+
);
|
|
1328
|
+
} else {
|
|
1329
|
+
console.log(` ${name} ${bar} ${pct.padStart(4)}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
console.log("");
|
|
1333
|
+
const nameWidth = 20;
|
|
1334
|
+
const headerNames = userSummaries.map((s) => truncateName(s.user.name, 7).padStart(7)).join(" ");
|
|
1335
|
+
console.log(
|
|
1336
|
+
chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames
|
|
1337
|
+
);
|
|
1338
|
+
const folderLines = renderFolder3(tree, 1, 2, nameWidth);
|
|
1339
|
+
for (const line of folderLines) {
|
|
1340
|
+
console.log(line);
|
|
1341
|
+
}
|
|
1342
|
+
console.log("");
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/cli/output/multi-user-html.ts
|
|
1346
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
1347
|
+
import { join as join3 } from "path";
|
|
1348
|
+
function generateMultiUserHTML(result) {
|
|
1349
|
+
const dataJson = JSON.stringify(result.tree);
|
|
1350
|
+
const summariesJson = JSON.stringify(result.userSummaries);
|
|
1351
|
+
const usersJson = JSON.stringify(result.users.map((u) => u.name));
|
|
1352
|
+
return `<!DOCTYPE html>
|
|
1353
|
+
<html lang="en">
|
|
1354
|
+
<head>
|
|
1355
|
+
<meta charset="UTF-8">
|
|
1356
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1357
|
+
<title>GitFamiliar \u2014 ${result.repoName} \u2014 Multi-User</title>
|
|
1358
|
+
<style>
|
|
1359
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1360
|
+
body {
|
|
1361
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1362
|
+
background: #1a1a2e;
|
|
1363
|
+
color: #e0e0e0;
|
|
1364
|
+
overflow: hidden;
|
|
1365
|
+
}
|
|
1366
|
+
#header {
|
|
1367
|
+
padding: 16px 24px;
|
|
1368
|
+
background: #16213e;
|
|
1369
|
+
border-bottom: 1px solid #0f3460;
|
|
1370
|
+
display: flex;
|
|
1371
|
+
align-items: center;
|
|
1372
|
+
justify-content: space-between;
|
|
1373
|
+
}
|
|
1374
|
+
#header h1 { font-size: 18px; color: #e94560; }
|
|
1375
|
+
#header .controls { display: flex; align-items: center; gap: 12px; }
|
|
1376
|
+
#header select {
|
|
1377
|
+
padding: 4px 12px;
|
|
1378
|
+
border: 1px solid #0f3460;
|
|
1379
|
+
background: #1a1a2e;
|
|
1380
|
+
color: #e0e0e0;
|
|
1381
|
+
border-radius: 4px;
|
|
1382
|
+
font-size: 13px;
|
|
1383
|
+
}
|
|
1384
|
+
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
1385
|
+
#breadcrumb {
|
|
1386
|
+
padding: 8px 24px;
|
|
1387
|
+
background: #16213e;
|
|
1388
|
+
font-size: 13px;
|
|
1389
|
+
border-bottom: 1px solid #0f3460;
|
|
1390
|
+
}
|
|
1391
|
+
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
1392
|
+
#breadcrumb span:hover { text-decoration: underline; }
|
|
1393
|
+
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
1394
|
+
#treemap { width: 100%; }
|
|
1395
|
+
#tooltip {
|
|
1396
|
+
position: absolute;
|
|
1397
|
+
pointer-events: none;
|
|
1398
|
+
background: rgba(22, 33, 62, 0.95);
|
|
1399
|
+
border: 1px solid #0f3460;
|
|
1400
|
+
border-radius: 6px;
|
|
1401
|
+
padding: 10px 14px;
|
|
1402
|
+
font-size: 13px;
|
|
1403
|
+
line-height: 1.6;
|
|
1404
|
+
display: none;
|
|
1405
|
+
z-index: 100;
|
|
1406
|
+
max-width: 350px;
|
|
1407
|
+
}
|
|
1408
|
+
#legend {
|
|
1409
|
+
position: absolute;
|
|
1410
|
+
bottom: 16px;
|
|
1411
|
+
right: 16px;
|
|
1412
|
+
background: rgba(22, 33, 62, 0.9);
|
|
1413
|
+
border: 1px solid #0f3460;
|
|
1414
|
+
border-radius: 6px;
|
|
1415
|
+
padding: 10px;
|
|
1416
|
+
font-size: 12px;
|
|
1417
|
+
}
|
|
1418
|
+
#legend .gradient-bar {
|
|
1419
|
+
width: 120px; height: 12px;
|
|
1420
|
+
background: linear-gradient(to right, #e94560, #f5a623, #27ae60);
|
|
1421
|
+
border-radius: 3px; margin: 4px 0;
|
|
1422
|
+
}
|
|
1423
|
+
#legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
|
|
1424
|
+
</style>
|
|
1425
|
+
</head>
|
|
1426
|
+
<body>
|
|
1427
|
+
<div id="header">
|
|
1428
|
+
<h1>GitFamiliar \u2014 ${result.repoName}</h1>
|
|
1429
|
+
<div class="controls">
|
|
1430
|
+
<span style="color:#888;font-size:13px;">View as:</span>
|
|
1431
|
+
<select id="userSelect" onchange="changeUser()"></select>
|
|
1432
|
+
<div class="info">${result.mode} mode | ${result.totalFiles} files</div>
|
|
1433
|
+
</div>
|
|
1434
|
+
</div>
|
|
1435
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
1436
|
+
<div id="treemap"></div>
|
|
1437
|
+
<div id="tooltip"></div>
|
|
1438
|
+
<div id="legend">
|
|
1439
|
+
<div>Familiarity</div>
|
|
1440
|
+
<div class="gradient-bar"></div>
|
|
1441
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
1442
|
+
</div>
|
|
1443
|
+
|
|
1444
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
1445
|
+
<script>
|
|
1446
|
+
const rawData = ${dataJson};
|
|
1447
|
+
const userNames = ${usersJson};
|
|
1448
|
+
const summaries = ${summariesJson};
|
|
1449
|
+
let currentUser = 0;
|
|
1450
|
+
let currentPath = '';
|
|
1451
|
+
|
|
1452
|
+
// Populate user selector
|
|
1453
|
+
const select = document.getElementById('userSelect');
|
|
1454
|
+
userNames.forEach((name, i) => {
|
|
1455
|
+
const opt = document.createElement('option');
|
|
1456
|
+
opt.value = i;
|
|
1457
|
+
const summary = summaries[i];
|
|
1458
|
+
opt.textContent = name + ' (' + Math.round(summary.overallScore * 100) + '%)';
|
|
1459
|
+
select.appendChild(opt);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
function changeUser() {
|
|
1463
|
+
currentUser = parseInt(select.value);
|
|
1464
|
+
render();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function scoreColor(score) {
|
|
1468
|
+
if (score <= 0) return '#e94560';
|
|
1469
|
+
if (score >= 1) return '#27ae60';
|
|
1470
|
+
if (score < 0.5) {
|
|
1471
|
+
const t = score / 0.5;
|
|
1472
|
+
return d3.interpolateRgb('#e94560', '#f5a623')(t);
|
|
1473
|
+
}
|
|
1474
|
+
const t = (score - 0.5) / 0.5;
|
|
1475
|
+
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function getUserScore(node) {
|
|
1479
|
+
if (!node.userScores || node.userScores.length === 0) return node.score;
|
|
1480
|
+
const s = node.userScores[currentUser];
|
|
1481
|
+
return s ? s.score : 0;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function findNode(node, path) {
|
|
1485
|
+
if (node.path === path) return node;
|
|
1486
|
+
if (node.children) {
|
|
1487
|
+
for (const child of node.children) {
|
|
1488
|
+
const found = findNode(child, path);
|
|
1489
|
+
if (found) return found;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
return null;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function buildHierarchy(node) {
|
|
1496
|
+
if (node.type === 'file') {
|
|
1497
|
+
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
1498
|
+
}
|
|
1499
|
+
return {
|
|
1500
|
+
name: node.path.split('/').pop() || node.path,
|
|
1501
|
+
data: node,
|
|
1502
|
+
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function render() {
|
|
1507
|
+
const container = document.getElementById('treemap');
|
|
1508
|
+
container.innerHTML = '';
|
|
1509
|
+
|
|
1510
|
+
const headerH = document.getElementById('header').offsetHeight;
|
|
1511
|
+
const breadcrumbH = document.getElementById('breadcrumb').offsetHeight;
|
|
1512
|
+
const width = window.innerWidth;
|
|
1513
|
+
const height = window.innerHeight - headerH - breadcrumbH;
|
|
1514
|
+
|
|
1515
|
+
const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
|
|
1516
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
1517
|
+
|
|
1518
|
+
const hierarchyData = {
|
|
1519
|
+
name: targetNode.path || 'root',
|
|
1520
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
const root = d3.hierarchy(hierarchyData)
|
|
1524
|
+
.sum(d => d.value || 0)
|
|
1525
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
1526
|
+
|
|
1527
|
+
d3.treemap()
|
|
1528
|
+
.size([width, height])
|
|
1529
|
+
.padding(2)
|
|
1530
|
+
.paddingTop(20)
|
|
1531
|
+
.round(true)(root);
|
|
1532
|
+
|
|
1533
|
+
const svg = d3.select('#treemap')
|
|
1534
|
+
.append('svg')
|
|
1535
|
+
.attr('width', width)
|
|
1536
|
+
.attr('height', height);
|
|
1537
|
+
|
|
1538
|
+
const tooltip = document.getElementById('tooltip');
|
|
1539
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
1540
|
+
|
|
1541
|
+
const groups = svg.selectAll('g')
|
|
1542
|
+
.data(nodes)
|
|
1543
|
+
.join('g')
|
|
1544
|
+
.attr('transform', d => \`translate(\${d.x0},\${d.y0})\`);
|
|
1545
|
+
|
|
1546
|
+
groups.append('rect')
|
|
1547
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
1548
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
1549
|
+
.attr('fill', d => {
|
|
1550
|
+
if (!d.data.data) return '#333';
|
|
1551
|
+
return scoreColor(getUserScore(d.data.data));
|
|
1552
|
+
})
|
|
1553
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
1554
|
+
.attr('stroke', '#1a1a2e')
|
|
1555
|
+
.attr('stroke-width', d => d.children ? 1 : 0.5)
|
|
1556
|
+
.attr('rx', 2)
|
|
1557
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
1558
|
+
.on('click', (event, d) => {
|
|
1559
|
+
if (d.data.data && d.data.data.type === 'folder') {
|
|
1560
|
+
event.stopPropagation();
|
|
1561
|
+
zoomTo(d.data.data.path);
|
|
1562
|
+
}
|
|
1563
|
+
})
|
|
1564
|
+
.on('mouseover', function(event, d) {
|
|
1565
|
+
if (!d.data.data) return;
|
|
1566
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
1567
|
+
showTooltip(d.data.data, event);
|
|
1568
|
+
})
|
|
1569
|
+
.on('mousemove', (event) => {
|
|
1570
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
1571
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1572
|
+
})
|
|
1573
|
+
.on('mouseout', function(event, d) {
|
|
1574
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
1575
|
+
tooltip.style.display = 'none';
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
groups.append('text')
|
|
1579
|
+
.attr('x', 4)
|
|
1580
|
+
.attr('y', 14)
|
|
1581
|
+
.attr('fill', '#fff')
|
|
1582
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
1583
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
1584
|
+
.style('pointer-events', 'none')
|
|
1585
|
+
.text(d => {
|
|
1586
|
+
const w = d.x1 - d.x0;
|
|
1587
|
+
const h = d.y1 - d.y0;
|
|
1588
|
+
const name = d.data.name || '';
|
|
1589
|
+
if (w < 36 || h < 18) return '';
|
|
1590
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
1591
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
1592
|
+
return name;
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function showTooltip(data, event) {
|
|
1597
|
+
const tooltip = document.getElementById('tooltip');
|
|
1598
|
+
let html = '<strong>' + (data.path || 'root') + '</strong>';
|
|
1599
|
+
|
|
1600
|
+
if (data.userScores && data.userScores.length > 0) {
|
|
1601
|
+
html += '<table style="margin-top:6px;width:100%">';
|
|
1602
|
+
data.userScores.forEach((s, i) => {
|
|
1603
|
+
const isCurrent = (i === currentUser);
|
|
1604
|
+
const style = isCurrent ? 'font-weight:bold;color:#5eadf7' : '';
|
|
1605
|
+
html += '<tr style="' + style + '"><td>' + userNames[i] + '</td><td style="text-align:right">' + Math.round(s.score * 100) + '%</td></tr>';
|
|
1606
|
+
});
|
|
1607
|
+
html += '</table>';
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (data.type === 'folder') {
|
|
1611
|
+
html += '<br>Files: ' + data.fileCount;
|
|
1612
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
1613
|
+
} else {
|
|
1614
|
+
html += '<br>Lines: ' + data.lines.toLocaleString();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
tooltip.innerHTML = html;
|
|
1618
|
+
tooltip.style.display = 'block';
|
|
1619
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
1620
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function zoomTo(path) {
|
|
1624
|
+
currentPath = path;
|
|
1625
|
+
const el = document.getElementById('breadcrumb');
|
|
1626
|
+
const parts = path ? path.split('/') : [];
|
|
1627
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
1628
|
+
let accumulated = '';
|
|
1629
|
+
for (const part of parts) {
|
|
1630
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
1631
|
+
const p = accumulated;
|
|
1632
|
+
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
1633
|
+
}
|
|
1634
|
+
el.innerHTML = html;
|
|
1635
|
+
render();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
window.addEventListener('resize', render);
|
|
1639
|
+
render();
|
|
1640
|
+
</script>
|
|
1641
|
+
</body>
|
|
1642
|
+
</html>`;
|
|
1643
|
+
}
|
|
1644
|
+
async function generateAndOpenMultiUserHTML(result, repoPath) {
|
|
1645
|
+
const html = generateMultiUserHTML(result);
|
|
1646
|
+
const outputPath = join3(repoPath, "gitfamiliar-multiuser.html");
|
|
1647
|
+
writeFileSync3(outputPath, html, "utf-8");
|
|
1648
|
+
console.log(`Multi-user report generated: ${outputPath}`);
|
|
1649
|
+
await openBrowser(outputPath);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/git/change-frequency.ts
|
|
1653
|
+
var COMMIT_SEP2 = "GITFAMILIAR_FREQ_SEP";
|
|
1654
|
+
async function bulkGetChangeFrequency(gitClient, days, trackedFiles) {
|
|
1655
|
+
const sinceDate = `${days} days ago`;
|
|
1656
|
+
const output = await gitClient.getLog([
|
|
1657
|
+
"--all",
|
|
1658
|
+
`--since=${sinceDate}`,
|
|
1659
|
+
"--name-only",
|
|
1660
|
+
`--format=${COMMIT_SEP2}%aI`
|
|
1661
|
+
]);
|
|
1662
|
+
const result = /* @__PURE__ */ new Map();
|
|
1663
|
+
let currentDate = null;
|
|
1664
|
+
for (const line of output.split("\n")) {
|
|
1665
|
+
if (line.startsWith(COMMIT_SEP2)) {
|
|
1666
|
+
const dateStr = line.slice(COMMIT_SEP2.length).trim();
|
|
1667
|
+
currentDate = dateStr ? new Date(dateStr) : null;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const filePath = line.trim();
|
|
1671
|
+
if (!filePath || !trackedFiles.has(filePath)) continue;
|
|
1672
|
+
let entry = result.get(filePath);
|
|
1673
|
+
if (!entry) {
|
|
1674
|
+
entry = { commitCount: 0, lastChanged: null };
|
|
1675
|
+
result.set(filePath, entry);
|
|
1676
|
+
}
|
|
1677
|
+
entry.commitCount++;
|
|
1678
|
+
if (currentDate && (!entry.lastChanged || currentDate > entry.lastChanged)) {
|
|
1679
|
+
entry.lastChanged = currentDate;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return result;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/core/hotspot.ts
|
|
1686
|
+
var DEFAULT_WINDOW = 90;
|
|
1687
|
+
async function computeHotspots(options) {
|
|
1688
|
+
const gitClient = new GitClient(options.repoPath);
|
|
1689
|
+
if (!await gitClient.isRepo()) {
|
|
1690
|
+
throw new Error(`"${options.repoPath}" is not a git repository.`);
|
|
1691
|
+
}
|
|
1692
|
+
const repoName = await gitClient.getRepoName();
|
|
1693
|
+
const repoRoot = await gitClient.getRepoRoot();
|
|
1694
|
+
const filter = createFilter(repoRoot);
|
|
1695
|
+
const tree = await buildFileTree(gitClient, filter);
|
|
1696
|
+
const timeWindow = options.window || DEFAULT_WINDOW;
|
|
1697
|
+
const isTeamMode = options.hotspot === "team";
|
|
1698
|
+
const trackedFiles = /* @__PURE__ */ new Set();
|
|
1699
|
+
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
1700
|
+
const changeFreqMap = await bulkGetChangeFrequency(gitClient, timeWindow, trackedFiles);
|
|
1701
|
+
let familiarityMap;
|
|
1702
|
+
let userName;
|
|
1703
|
+
if (isTeamMode) {
|
|
1704
|
+
familiarityMap = await computeTeamAvgFamiliarity(gitClient, trackedFiles, options);
|
|
1705
|
+
} else {
|
|
1706
|
+
const userFlag = Array.isArray(options.user) ? options.user[0] : options.user;
|
|
1707
|
+
const result = await computeFamiliarity({ ...options, team: false, teamCoverage: false });
|
|
1708
|
+
userName = result.userName;
|
|
1709
|
+
familiarityMap = /* @__PURE__ */ new Map();
|
|
1710
|
+
walkFiles(result.tree, (f) => {
|
|
1711
|
+
familiarityMap.set(f.path, f.score);
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
let maxFreq = 0;
|
|
1715
|
+
for (const entry of changeFreqMap.values()) {
|
|
1716
|
+
if (entry.commitCount > maxFreq) maxFreq = entry.commitCount;
|
|
1717
|
+
}
|
|
1718
|
+
const hotspotFiles = [];
|
|
1719
|
+
for (const filePath of trackedFiles) {
|
|
1720
|
+
const freq = changeFreqMap.get(filePath);
|
|
1721
|
+
const changeFrequency = freq?.commitCount || 0;
|
|
1722
|
+
const lastChanged = freq?.lastChanged || null;
|
|
1723
|
+
const familiarity = familiarityMap.get(filePath) || 0;
|
|
1724
|
+
const normalizedFreq = maxFreq > 0 ? changeFrequency / maxFreq : 0;
|
|
1725
|
+
const risk = normalizedFreq * (1 - familiarity);
|
|
1726
|
+
let lines = 0;
|
|
1727
|
+
walkFiles(tree, (f) => {
|
|
1728
|
+
if (f.path === filePath) lines = f.lines;
|
|
1729
|
+
});
|
|
1730
|
+
hotspotFiles.push({
|
|
1731
|
+
path: filePath,
|
|
1732
|
+
lines,
|
|
1733
|
+
familiarity,
|
|
1734
|
+
changeFrequency,
|
|
1735
|
+
lastChanged,
|
|
1736
|
+
risk,
|
|
1737
|
+
riskLevel: classifyHotspotRisk(risk)
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
hotspotFiles.sort((a, b) => b.risk - a.risk);
|
|
1741
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
1742
|
+
for (const f of hotspotFiles) {
|
|
1743
|
+
summary[f.riskLevel]++;
|
|
1744
|
+
}
|
|
1745
|
+
return {
|
|
1746
|
+
files: hotspotFiles,
|
|
1747
|
+
repoName,
|
|
1748
|
+
userName,
|
|
1749
|
+
hotspotMode: isTeamMode ? "team" : "personal",
|
|
1750
|
+
timeWindow,
|
|
1751
|
+
summary
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
function classifyHotspotRisk(risk) {
|
|
1755
|
+
if (risk >= 0.6) return "critical";
|
|
1756
|
+
if (risk >= 0.4) return "high";
|
|
1757
|
+
if (risk >= 0.2) return "medium";
|
|
1758
|
+
return "low";
|
|
1759
|
+
}
|
|
1760
|
+
async function computeTeamAvgFamiliarity(gitClient, trackedFiles, options) {
|
|
1761
|
+
const contributors = await getAllContributors(gitClient, 1);
|
|
1762
|
+
const totalContributors = Math.max(1, contributors.length);
|
|
1763
|
+
const fileContributors = await bulkGetFileContributors(gitClient, trackedFiles);
|
|
1764
|
+
const result = /* @__PURE__ */ new Map();
|
|
1765
|
+
for (const filePath of trackedFiles) {
|
|
1766
|
+
const contribs = fileContributors.get(filePath);
|
|
1767
|
+
const count = contribs ? contribs.size : 0;
|
|
1768
|
+
result.set(filePath, Math.min(1, count / Math.max(1, totalContributors * 0.3)));
|
|
1769
|
+
}
|
|
1770
|
+
return result;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// src/cli/output/hotspot-terminal.ts
|
|
1774
|
+
import chalk4 from "chalk";
|
|
1775
|
+
function riskBadge2(level) {
|
|
1776
|
+
switch (level) {
|
|
1777
|
+
case "critical":
|
|
1778
|
+
return chalk4.bgRed.white.bold(" CRIT ");
|
|
1779
|
+
case "high":
|
|
1780
|
+
return chalk4.bgRedBright.white(" HIGH ");
|
|
1781
|
+
case "medium":
|
|
1782
|
+
return chalk4.bgYellow.black(" MED ");
|
|
1783
|
+
case "low":
|
|
1784
|
+
return chalk4.bgGreen.black(" LOW ");
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
function riskColor2(level) {
|
|
1788
|
+
switch (level) {
|
|
1789
|
+
case "critical":
|
|
1790
|
+
return chalk4.red;
|
|
1791
|
+
case "high":
|
|
1792
|
+
return chalk4.redBright;
|
|
1793
|
+
case "medium":
|
|
1794
|
+
return chalk4.yellow;
|
|
1795
|
+
case "low":
|
|
1796
|
+
return chalk4.green;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
function renderHotspotTerminal(result) {
|
|
1800
|
+
const { files, repoName, hotspotMode, timeWindow, summary, userName } = result;
|
|
1801
|
+
console.log("");
|
|
1802
|
+
const modeLabel = hotspotMode === "team" ? "Team Hotspots" : "Personal Hotspots";
|
|
1803
|
+
const userLabel = userName ? ` (${userName})` : "";
|
|
1804
|
+
console.log(
|
|
1805
|
+
chalk4.bold(`GitFamiliar \u2014 ${modeLabel}${userLabel} \u2014 ${repoName}`)
|
|
1806
|
+
);
|
|
1807
|
+
console.log(chalk4.gray(` Time window: last ${timeWindow} days`));
|
|
1808
|
+
console.log("");
|
|
1809
|
+
const activeFiles = files.filter((f) => f.changeFrequency > 0);
|
|
1810
|
+
if (activeFiles.length === 0) {
|
|
1811
|
+
console.log(chalk4.gray(" No files changed in the time window."));
|
|
1812
|
+
console.log("");
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
const displayCount = Math.min(30, activeFiles.length);
|
|
1816
|
+
const topFiles = activeFiles.slice(0, displayCount);
|
|
1817
|
+
console.log(
|
|
1818
|
+
chalk4.gray(
|
|
1819
|
+
` ${"Rank".padEnd(5)} ${"File".padEnd(42)} ${"Familiarity".padStart(11)} ${"Changes".padStart(8)} ${"Risk".padStart(6)} Level`
|
|
1820
|
+
)
|
|
1821
|
+
);
|
|
1822
|
+
console.log(chalk4.gray(" " + "\u2500".repeat(90)));
|
|
1823
|
+
for (let i = 0; i < topFiles.length; i++) {
|
|
1824
|
+
const f = topFiles[i];
|
|
1825
|
+
const rank = String(i + 1).padEnd(5);
|
|
1826
|
+
const path = truncate(f.path, 42).padEnd(42);
|
|
1827
|
+
const fam = `${Math.round(f.familiarity * 100)}%`.padStart(11);
|
|
1828
|
+
const changes = String(f.changeFrequency).padStart(8);
|
|
1829
|
+
const risk = f.risk.toFixed(2).padStart(6);
|
|
1830
|
+
const color = riskColor2(f.riskLevel);
|
|
1831
|
+
const badge = riskBadge2(f.riskLevel);
|
|
1832
|
+
console.log(
|
|
1833
|
+
` ${color(rank)}${path} ${fam} ${changes} ${color(risk)} ${badge}`
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
if (activeFiles.length > displayCount) {
|
|
1837
|
+
console.log(
|
|
1838
|
+
chalk4.gray(` ... and ${activeFiles.length - displayCount} more files`)
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
console.log("");
|
|
1842
|
+
console.log(chalk4.bold("Summary:"));
|
|
1843
|
+
if (summary.critical > 0) {
|
|
1844
|
+
console.log(
|
|
1845
|
+
` ${chalk4.red.bold(`\u{1F534} Critical Risk: ${summary.critical} files`)}`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
if (summary.high > 0) {
|
|
1849
|
+
console.log(
|
|
1850
|
+
` ${chalk4.redBright(`\u{1F7E0} High Risk: ${summary.high} files`)}`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
if (summary.medium > 0) {
|
|
1854
|
+
console.log(
|
|
1855
|
+
` ${chalk4.yellow(`\u{1F7E1} Medium Risk: ${summary.medium} files`)}`
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
console.log(
|
|
1859
|
+
` ${chalk4.green(`\u{1F7E2} Low Risk: ${summary.low} files`)}`
|
|
1860
|
+
);
|
|
1861
|
+
console.log("");
|
|
1862
|
+
if (summary.critical > 0 || summary.high > 0) {
|
|
1863
|
+
console.log(
|
|
1864
|
+
chalk4.gray(
|
|
1865
|
+
" Recommendation: Focus code review and knowledge transfer on critical/high risk files."
|
|
1866
|
+
)
|
|
1867
|
+
);
|
|
1868
|
+
console.log("");
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function truncate(s, maxLen) {
|
|
1872
|
+
if (s.length <= maxLen) return s;
|
|
1873
|
+
return s.slice(0, maxLen - 1) + "\u2026";
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/cli/output/hotspot-html.ts
|
|
1877
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
1878
|
+
import { join as join4 } from "path";
|
|
1879
|
+
function generateHotspotHTML(result) {
|
|
1880
|
+
const activeFiles = result.files.filter((f) => f.changeFrequency > 0);
|
|
1881
|
+
const dataJson = JSON.stringify(
|
|
1882
|
+
activeFiles.map((f) => ({
|
|
1883
|
+
path: f.path,
|
|
1884
|
+
lines: f.lines,
|
|
1885
|
+
familiarity: f.familiarity,
|
|
1886
|
+
changeFrequency: f.changeFrequency,
|
|
1887
|
+
risk: f.risk,
|
|
1888
|
+
riskLevel: f.riskLevel
|
|
1889
|
+
}))
|
|
1890
|
+
);
|
|
1891
|
+
const modeLabel = result.hotspotMode === "team" ? "Team Hotspots" : "Personal Hotspots";
|
|
1892
|
+
const userLabel = result.userName ? ` (${result.userName})` : "";
|
|
1893
|
+
return `<!DOCTYPE html>
|
|
1894
|
+
<html lang="en">
|
|
1895
|
+
<head>
|
|
1896
|
+
<meta charset="UTF-8">
|
|
1897
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1898
|
+
<title>GitFamiliar \u2014 ${modeLabel} \u2014 ${result.repoName}</title>
|
|
1899
|
+
<style>
|
|
1900
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1901
|
+
body {
|
|
1902
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1903
|
+
background: #1a1a2e;
|
|
1904
|
+
color: #e0e0e0;
|
|
1905
|
+
overflow: hidden;
|
|
1906
|
+
}
|
|
1907
|
+
#header {
|
|
1908
|
+
padding: 16px 24px;
|
|
1909
|
+
background: #16213e;
|
|
1910
|
+
border-bottom: 1px solid #0f3460;
|
|
1911
|
+
display: flex;
|
|
1912
|
+
align-items: center;
|
|
1913
|
+
justify-content: space-between;
|
|
1914
|
+
}
|
|
1915
|
+
#header h1 { font-size: 18px; color: #e94560; }
|
|
1916
|
+
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
1917
|
+
#main { display: flex; height: calc(100vh - 60px); }
|
|
1918
|
+
#chart { flex: 1; position: relative; }
|
|
1919
|
+
#sidebar {
|
|
1920
|
+
width: 320px;
|
|
1921
|
+
background: #16213e;
|
|
1922
|
+
border-left: 1px solid #0f3460;
|
|
1923
|
+
overflow-y: auto;
|
|
1924
|
+
padding: 16px;
|
|
1925
|
+
}
|
|
1926
|
+
#sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
|
|
1927
|
+
.hotspot-item {
|
|
1928
|
+
padding: 8px 0;
|
|
1929
|
+
border-bottom: 1px solid #0f3460;
|
|
1930
|
+
font-size: 12px;
|
|
1931
|
+
}
|
|
1932
|
+
.hotspot-item .path { color: #e0e0e0; word-break: break-all; }
|
|
1933
|
+
.hotspot-item .meta { color: #888; margin-top: 2px; }
|
|
1934
|
+
.hotspot-item .risk-badge {
|
|
1935
|
+
display: inline-block;
|
|
1936
|
+
padding: 1px 6px;
|
|
1937
|
+
border-radius: 3px;
|
|
1938
|
+
font-size: 10px;
|
|
1939
|
+
font-weight: bold;
|
|
1940
|
+
margin-left: 4px;
|
|
1941
|
+
}
|
|
1942
|
+
.risk-critical { background: #e94560; color: white; }
|
|
1943
|
+
.risk-high { background: #f07040; color: white; }
|
|
1944
|
+
.risk-medium { background: #f5a623; color: black; }
|
|
1945
|
+
.risk-low { background: #27ae60; color: white; }
|
|
1946
|
+
#tooltip {
|
|
1947
|
+
position: absolute;
|
|
1948
|
+
pointer-events: none;
|
|
1949
|
+
background: rgba(22, 33, 62, 0.95);
|
|
1950
|
+
border: 1px solid #0f3460;
|
|
1951
|
+
border-radius: 6px;
|
|
1952
|
+
padding: 10px 14px;
|
|
1953
|
+
font-size: 13px;
|
|
1954
|
+
line-height: 1.6;
|
|
1955
|
+
display: none;
|
|
1956
|
+
z-index: 100;
|
|
1957
|
+
max-width: 350px;
|
|
1958
|
+
}
|
|
1959
|
+
#zone-labels { position: absolute; pointer-events: none; }
|
|
1960
|
+
.zone-label {
|
|
1961
|
+
position: absolute;
|
|
1962
|
+
font-size: 12px;
|
|
1963
|
+
color: rgba(255,255,255,0.15);
|
|
1964
|
+
font-weight: bold;
|
|
1965
|
+
}
|
|
1966
|
+
</style>
|
|
1967
|
+
</head>
|
|
1968
|
+
<body>
|
|
1969
|
+
<div id="header">
|
|
1970
|
+
<h1>GitFamiliar \u2014 ${modeLabel}${userLabel} \u2014 ${result.repoName}</h1>
|
|
1971
|
+
<div class="info">${result.timeWindow}-day window | ${activeFiles.length} active files | Summary: ${result.summary.critical} critical, ${result.summary.high} high</div>
|
|
1972
|
+
</div>
|
|
1973
|
+
<div id="main">
|
|
1974
|
+
<div id="chart">
|
|
1975
|
+
<div id="zone-labels"></div>
|
|
1976
|
+
</div>
|
|
1977
|
+
<div id="sidebar">
|
|
1978
|
+
<h3>Top Hotspots</h3>
|
|
1979
|
+
<div id="hotspot-list"></div>
|
|
1980
|
+
</div>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div id="tooltip"></div>
|
|
1983
|
+
|
|
1984
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
1985
|
+
<script>
|
|
1986
|
+
const data = ${dataJson};
|
|
1987
|
+
const margin = { top: 30, right: 30, bottom: 60, left: 70 };
|
|
1988
|
+
|
|
1989
|
+
function riskColor(level) {
|
|
1990
|
+
switch(level) {
|
|
1991
|
+
case 'critical': return '#e94560';
|
|
1992
|
+
case 'high': return '#f07040';
|
|
1993
|
+
case 'medium': return '#f5a623';
|
|
1994
|
+
default: return '#27ae60';
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function render() {
|
|
1999
|
+
const container = document.getElementById('chart');
|
|
2000
|
+
const svg = container.querySelector('svg');
|
|
2001
|
+
if (svg) svg.remove();
|
|
2002
|
+
|
|
2003
|
+
const width = container.offsetWidth;
|
|
2004
|
+
const height = container.offsetHeight;
|
|
2005
|
+
const innerW = width - margin.left - margin.right;
|
|
2006
|
+
const innerH = height - margin.top - margin.bottom;
|
|
2007
|
+
|
|
2008
|
+
const maxFreq = d3.max(data, d => d.changeFrequency) || 1;
|
|
2009
|
+
|
|
2010
|
+
const x = d3.scaleLinear().domain([0, 1]).range([0, innerW]);
|
|
2011
|
+
const y = d3.scaleLinear().domain([0, maxFreq * 1.1]).range([innerH, 0]);
|
|
2012
|
+
const r = d3.scaleSqrt()
|
|
2013
|
+
.domain([0, d3.max(data, d => d.lines) || 1])
|
|
2014
|
+
.range([3, 20]);
|
|
2015
|
+
|
|
2016
|
+
const svgEl = d3.select('#chart')
|
|
2017
|
+
.append('svg')
|
|
2018
|
+
.attr('width', width)
|
|
2019
|
+
.attr('height', height);
|
|
2020
|
+
|
|
2021
|
+
const g = svgEl.append('g')
|
|
2022
|
+
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
|
2023
|
+
|
|
2024
|
+
// Danger zone background (top-left quadrant)
|
|
2025
|
+
g.append('rect')
|
|
2026
|
+
.attr('x', 0)
|
|
2027
|
+
.attr('y', 0)
|
|
2028
|
+
.attr('width', x(0.3))
|
|
2029
|
+
.attr('height', y(maxFreq * 0.3))
|
|
2030
|
+
.attr('fill', 'rgba(233, 69, 96, 0.06)');
|
|
2031
|
+
|
|
2032
|
+
// X axis
|
|
2033
|
+
g.append('g')
|
|
2034
|
+
.attr('transform', 'translate(0,' + innerH + ')')
|
|
2035
|
+
.call(d3.axisBottom(x).ticks(5).tickFormat(d => Math.round(d * 100) + '%'))
|
|
2036
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
2037
|
+
|
|
2038
|
+
svgEl.append('text')
|
|
2039
|
+
.attr('x', margin.left + innerW / 2)
|
|
2040
|
+
.attr('y', height - 10)
|
|
2041
|
+
.attr('text-anchor', 'middle')
|
|
2042
|
+
.attr('fill', '#888')
|
|
2043
|
+
.attr('font-size', '13px')
|
|
2044
|
+
.text('Familiarity \\u2192');
|
|
2045
|
+
|
|
2046
|
+
// Y axis
|
|
2047
|
+
g.append('g')
|
|
2048
|
+
.call(d3.axisLeft(y).ticks(6))
|
|
2049
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
2050
|
+
|
|
2051
|
+
svgEl.append('text')
|
|
2052
|
+
.attr('transform', 'rotate(-90)')
|
|
2053
|
+
.attr('x', -(margin.top + innerH / 2))
|
|
2054
|
+
.attr('y', 16)
|
|
2055
|
+
.attr('text-anchor', 'middle')
|
|
2056
|
+
.attr('fill', '#888')
|
|
2057
|
+
.attr('font-size', '13px')
|
|
2058
|
+
.text('Change Frequency (commits) \\u2192');
|
|
2059
|
+
|
|
2060
|
+
// Zone labels
|
|
2061
|
+
const labels = document.getElementById('zone-labels');
|
|
2062
|
+
labels.innerHTML = '';
|
|
2063
|
+
const dangerLabel = document.createElement('div');
|
|
2064
|
+
dangerLabel.className = 'zone-label';
|
|
2065
|
+
dangerLabel.style.left = (margin.left + 8) + 'px';
|
|
2066
|
+
dangerLabel.style.top = (margin.top + 8) + 'px';
|
|
2067
|
+
dangerLabel.textContent = 'DANGER ZONE';
|
|
2068
|
+
dangerLabel.style.color = 'rgba(233,69,96,0.25)';
|
|
2069
|
+
dangerLabel.style.fontSize = '16px';
|
|
2070
|
+
labels.appendChild(dangerLabel);
|
|
2071
|
+
|
|
2072
|
+
const safeLabel = document.createElement('div');
|
|
2073
|
+
safeLabel.className = 'zone-label';
|
|
2074
|
+
safeLabel.style.right = (320 + 40) + 'px';
|
|
2075
|
+
safeLabel.style.bottom = (margin.bottom + 16) + 'px';
|
|
2076
|
+
safeLabel.textContent = 'SAFE ZONE';
|
|
2077
|
+
safeLabel.style.color = 'rgba(39,174,96,0.2)';
|
|
2078
|
+
safeLabel.style.fontSize = '16px';
|
|
2079
|
+
labels.appendChild(safeLabel);
|
|
2080
|
+
|
|
2081
|
+
const tooltip = document.getElementById('tooltip');
|
|
2082
|
+
|
|
2083
|
+
// Data points
|
|
2084
|
+
g.selectAll('circle')
|
|
2085
|
+
.data(data)
|
|
2086
|
+
.join('circle')
|
|
2087
|
+
.attr('cx', d => x(d.familiarity))
|
|
2088
|
+
.attr('cy', d => y(d.changeFrequency))
|
|
2089
|
+
.attr('r', d => r(d.lines))
|
|
2090
|
+
.attr('fill', d => riskColor(d.riskLevel))
|
|
2091
|
+
.attr('opacity', 0.7)
|
|
2092
|
+
.attr('stroke', 'none')
|
|
2093
|
+
.style('cursor', 'pointer')
|
|
2094
|
+
.on('mouseover', function(event, d) {
|
|
2095
|
+
d3.select(this).attr('opacity', 1).attr('stroke', '#fff').attr('stroke-width', 2);
|
|
2096
|
+
tooltip.innerHTML =
|
|
2097
|
+
'<strong>' + d.path + '</strong>' +
|
|
2098
|
+
'<br>Familiarity: ' + Math.round(d.familiarity * 100) + '%' +
|
|
2099
|
+
'<br>Changes: ' + d.changeFrequency + ' commits' +
|
|
2100
|
+
'<br>Risk: ' + d.risk.toFixed(2) + ' (' + d.riskLevel + ')' +
|
|
2101
|
+
'<br>Lines: ' + d.lines.toLocaleString();
|
|
2102
|
+
tooltip.style.display = 'block';
|
|
2103
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2104
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2105
|
+
})
|
|
2106
|
+
.on('mousemove', (event) => {
|
|
2107
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2108
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2109
|
+
})
|
|
2110
|
+
.on('mouseout', function() {
|
|
2111
|
+
d3.select(this).attr('opacity', 0.7).attr('stroke', 'none');
|
|
2112
|
+
tooltip.style.display = 'none';
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// Sidebar
|
|
2117
|
+
function renderSidebar() {
|
|
2118
|
+
const container = document.getElementById('hotspot-list');
|
|
2119
|
+
const top = data.slice(0, 30);
|
|
2120
|
+
if (top.length === 0) {
|
|
2121
|
+
container.innerHTML = '<div style="color:#888">No active files in time window.</div>';
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
let html = '';
|
|
2125
|
+
for (let i = 0; i < top.length; i++) {
|
|
2126
|
+
const f = top[i];
|
|
2127
|
+
const badgeClass = 'risk-' + f.riskLevel;
|
|
2128
|
+
html += '<div class="hotspot-item">' +
|
|
2129
|
+
'<div class="path">' + (i + 1) + '. ' + f.path +
|
|
2130
|
+
' <span class="risk-badge ' + badgeClass + '">' + f.riskLevel.toUpperCase() + '</span></div>' +
|
|
2131
|
+
'<div class="meta">Fam: ' + Math.round(f.familiarity * 100) + '% | Changes: ' + f.changeFrequency + ' | Risk: ' + f.risk.toFixed(2) + '</div>' +
|
|
2132
|
+
'</div>';
|
|
2133
|
+
}
|
|
2134
|
+
container.innerHTML = html;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
window.addEventListener('resize', render);
|
|
2138
|
+
renderSidebar();
|
|
2139
|
+
render();
|
|
2140
|
+
</script>
|
|
2141
|
+
</body>
|
|
2142
|
+
</html>`;
|
|
2143
|
+
}
|
|
2144
|
+
async function generateAndOpenHotspotHTML(result, repoPath) {
|
|
2145
|
+
const html = generateHotspotHTML(result);
|
|
2146
|
+
const outputPath = join4(repoPath, "gitfamiliar-hotspot.html");
|
|
2147
|
+
writeFileSync4(outputPath, html, "utf-8");
|
|
2148
|
+
console.log(`Hotspot report generated: ${outputPath}`);
|
|
2149
|
+
await openBrowser(outputPath);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// src/github/check.ts
|
|
2153
|
+
async function checkGitHubConnection(repoPath, githubUrl) {
|
|
2154
|
+
const gitClient = new GitClient(repoPath);
|
|
2155
|
+
if (!await gitClient.isRepo()) {
|
|
2156
|
+
console.error("Error: Not a git repository.");
|
|
2157
|
+
process.exit(1);
|
|
2158
|
+
}
|
|
2159
|
+
const remoteUrl = await gitClient.getRemoteUrl();
|
|
2160
|
+
if (!remoteUrl) {
|
|
2161
|
+
console.error("Error: No git remote found.");
|
|
2162
|
+
process.exit(1);
|
|
2163
|
+
}
|
|
2164
|
+
console.log(`Remote URL: ${remoteUrl}`);
|
|
2165
|
+
const parsed = GitHubClient.parseRemoteUrl(remoteUrl, githubUrl);
|
|
2166
|
+
if (!parsed) {
|
|
2167
|
+
console.error("Error: Could not parse remote URL as a GitHub repository.");
|
|
2168
|
+
process.exit(1);
|
|
2169
|
+
}
|
|
2170
|
+
console.log(`Hostname: ${parsed.hostname}`);
|
|
2171
|
+
console.log(`Repository: ${parsed.owner}/${parsed.repo}`);
|
|
2172
|
+
console.log(`API Base URL: ${parsed.apiBaseUrl}`);
|
|
2173
|
+
const token = resolveGitHubToken(parsed.hostname);
|
|
2174
|
+
if (!token) {
|
|
2175
|
+
console.error(
|
|
2176
|
+
"\nNo GitHub token found. Please set GITHUB_TOKEN or run: gh auth login" + (parsed.hostname !== "github.com" ? ` --hostname ${parsed.hostname}` : "")
|
|
2177
|
+
);
|
|
2178
|
+
process.exit(1);
|
|
2179
|
+
}
|
|
2180
|
+
console.log(`Token: ****${token.slice(-4)}`);
|
|
2181
|
+
console.log("\nVerifying API connectivity...");
|
|
2182
|
+
try {
|
|
2183
|
+
const client = new GitHubClient(token, parsed.apiBaseUrl);
|
|
2184
|
+
const user = await client.verifyConnection();
|
|
2185
|
+
console.log(`Authenticated as: ${user.login}${user.name ? ` (${user.name})` : ""}`);
|
|
2186
|
+
console.log("\nGitHub connection OK.");
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
console.error(`
|
|
2189
|
+
API connection failed: ${error.message}`);
|
|
2190
|
+
process.exit(1);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// src/cli/index.ts
|
|
2195
|
+
function collect(value, previous) {
|
|
2196
|
+
return previous.concat([value]);
|
|
2197
|
+
}
|
|
2198
|
+
function createProgram() {
|
|
2199
|
+
const program2 = new Command();
|
|
2200
|
+
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.1").option(
|
|
2201
|
+
"-m, --mode <mode>",
|
|
2202
|
+
"Scoring mode: binary, authorship, review-coverage, weighted",
|
|
2203
|
+
"binary"
|
|
2204
|
+
).option(
|
|
2205
|
+
"-u, --user <user>",
|
|
2206
|
+
"Git user name or email (repeatable for comparison)",
|
|
2207
|
+
collect,
|
|
2208
|
+
[]
|
|
2209
|
+
).option(
|
|
2210
|
+
"-f, --filter <filter>",
|
|
2211
|
+
"Filter mode: all, written, reviewed",
|
|
2212
|
+
"all"
|
|
2213
|
+
).option(
|
|
2214
|
+
"-e, --expiration <policy>",
|
|
2215
|
+
"Expiration policy: never, time:180d, change:50%, combined:365d:50%",
|
|
2216
|
+
"never"
|
|
2217
|
+
).option("--html", "Generate HTML treemap report", false).option(
|
|
2218
|
+
"-w, --weights <weights>",
|
|
2219
|
+
'Weights for weighted mode: blame,commit,review (e.g., "0.5,0.35,0.15")'
|
|
2220
|
+
).option("--team", "Compare all contributors", false).option(
|
|
2221
|
+
"--team-coverage",
|
|
2222
|
+
"Show team coverage map (bus factor analysis)",
|
|
2223
|
+
false
|
|
2224
|
+
).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
|
|
2225
|
+
"--window <days>",
|
|
2226
|
+
"Time window for hotspot analysis in days (default: 90)"
|
|
2227
|
+
).option(
|
|
2228
|
+
"--github-url <hostname>",
|
|
2229
|
+
"GitHub Enterprise hostname (e.g. ghe.example.com). Auto-detected from git remote if omitted."
|
|
2230
|
+
).option(
|
|
2231
|
+
"--check-github",
|
|
2232
|
+
"Verify GitHub API connectivity and show connection info",
|
|
2233
|
+
false
|
|
2234
|
+
).action(async (rawOptions) => {
|
|
2235
|
+
try {
|
|
2236
|
+
const repoPath = process.cwd();
|
|
2237
|
+
const options = parseOptions(rawOptions, repoPath);
|
|
2238
|
+
if (options.checkGithub) {
|
|
2239
|
+
await checkGitHubConnection(repoPath, options.githubUrl);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (options.hotspot) {
|
|
2243
|
+
const result2 = await computeHotspots(options);
|
|
2244
|
+
if (options.html) {
|
|
2245
|
+
await generateAndOpenHotspotHTML(result2, repoPath);
|
|
2246
|
+
} else {
|
|
2247
|
+
renderHotspotTerminal(result2);
|
|
2248
|
+
}
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
if (options.teamCoverage) {
|
|
2252
|
+
const result2 = await computeTeamCoverage(options);
|
|
2253
|
+
if (options.html) {
|
|
2254
|
+
await generateAndOpenCoverageHTML(result2, repoPath);
|
|
2255
|
+
} else {
|
|
2256
|
+
renderCoverageTerminal(result2);
|
|
2257
|
+
}
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
const isMultiUser = options.team || Array.isArray(options.user) && options.user.length > 1;
|
|
2261
|
+
if (isMultiUser) {
|
|
2262
|
+
const result2 = await computeMultiUser(options);
|
|
2263
|
+
if (options.html) {
|
|
2264
|
+
await generateAndOpenMultiUserHTML(result2, repoPath);
|
|
2265
|
+
} else {
|
|
2266
|
+
renderMultiUserTerminal(result2);
|
|
2267
|
+
}
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
497
2270
|
const result = await computeFamiliarity(options);
|
|
498
2271
|
if (options.html) {
|
|
499
2272
|
await generateAndOpenHTML(result, repoPath);
|