gitfamiliar 0.9.0 → 0.11.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/README.md +48 -35
- package/dist/bin/gitfamiliar.js +1095 -695
- package/dist/bin/gitfamiliar.js.map +1 -1
- package/dist/{chunk-NGCQUB2H.js → chunk-3SYLST7V.js} +11 -11
- package/dist/chunk-3SYLST7V.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-NGCQUB2H.js.map +0 -1
package/dist/bin/gitfamiliar.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
processBatch,
|
|
9
9
|
resolveUser,
|
|
10
10
|
walkFiles
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-3SYLST7V.js";
|
|
12
12
|
|
|
13
13
|
// src/cli/index.ts
|
|
14
14
|
import { createRequire } from "module";
|
|
@@ -22,10 +22,23 @@ var DEFAULT_WEIGHTS = {
|
|
|
22
22
|
var DEFAULT_EXPIRATION = {
|
|
23
23
|
policy: "never"
|
|
24
24
|
};
|
|
25
|
+
var HOTSPOT_RISK_THRESHOLDS = {
|
|
26
|
+
critical: 0.6,
|
|
27
|
+
high: 0.4,
|
|
28
|
+
medium: 0.2
|
|
29
|
+
};
|
|
30
|
+
var COVERAGE_RISK_THRESHOLDS = {
|
|
31
|
+
risk: 1,
|
|
32
|
+
// <= 1 contributor
|
|
33
|
+
moderate: 3
|
|
34
|
+
// <= 3 contributors
|
|
35
|
+
};
|
|
36
|
+
var DEFAULT_HOTSPOT_WINDOW = 90;
|
|
37
|
+
var BUS_FACTOR_TARGET = 0.5;
|
|
25
38
|
|
|
26
39
|
// src/cli/options.ts
|
|
27
40
|
function parseOptions(raw, repoPath) {
|
|
28
|
-
const mode = validateMode(raw.mode || "
|
|
41
|
+
const mode = validateMode(raw.mode || "committed");
|
|
29
42
|
let weights = DEFAULT_WEIGHTS;
|
|
30
43
|
if (raw.weights) {
|
|
31
44
|
weights = parseWeights(raw.weights);
|
|
@@ -45,7 +58,8 @@ function parseOptions(raw, repoPath) {
|
|
|
45
58
|
hotspot = "personal";
|
|
46
59
|
}
|
|
47
60
|
}
|
|
48
|
-
const
|
|
61
|
+
const sinceRaw = raw.since || raw.window;
|
|
62
|
+
const sinceDays = sinceRaw ? parseInt(sinceRaw, 10) : void 0;
|
|
49
63
|
return {
|
|
50
64
|
mode,
|
|
51
65
|
user,
|
|
@@ -54,13 +68,21 @@ function parseOptions(raw, repoPath) {
|
|
|
54
68
|
weights,
|
|
55
69
|
repoPath,
|
|
56
70
|
team: raw.team || false,
|
|
57
|
-
|
|
71
|
+
contributorsPerFile: raw.contributorsPerFile || raw.contributors || raw.teamCoverage || false,
|
|
58
72
|
hotspot,
|
|
59
|
-
|
|
73
|
+
since: sinceDays,
|
|
74
|
+
demo: raw.demo || false
|
|
60
75
|
};
|
|
61
76
|
}
|
|
77
|
+
var MODE_ALIASES = {
|
|
78
|
+
binary: "committed",
|
|
79
|
+
authorship: "code-coverage"
|
|
80
|
+
};
|
|
62
81
|
function validateMode(mode) {
|
|
63
|
-
|
|
82
|
+
if (mode in MODE_ALIASES) {
|
|
83
|
+
return MODE_ALIASES[mode];
|
|
84
|
+
}
|
|
85
|
+
const valid = ["committed", "code-coverage", "weighted"];
|
|
64
86
|
if (!valid.includes(mode)) {
|
|
65
87
|
throw new Error(
|
|
66
88
|
`Invalid mode: "${mode}". Valid modes: ${valid.join(", ")}`
|
|
@@ -81,13 +103,15 @@ function parseWeights(s) {
|
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
// src/cli/output/terminal.ts
|
|
106
|
+
import chalk2 from "chalk";
|
|
107
|
+
|
|
108
|
+
// src/cli/output/terminal-utils.ts
|
|
84
109
|
import chalk from "chalk";
|
|
85
|
-
var BAR_WIDTH = 10;
|
|
86
110
|
var FILLED_CHAR = "\u2588";
|
|
87
111
|
var EMPTY_CHAR = "\u2591";
|
|
88
|
-
function makeBar(score) {
|
|
89
|
-
const filled = Math.round(score *
|
|
90
|
-
const empty =
|
|
112
|
+
function makeBar(score, width = 10) {
|
|
113
|
+
const filled = Math.round(score * width);
|
|
114
|
+
const empty = width - filled;
|
|
91
115
|
const bar = FILLED_CHAR.repeat(filled) + EMPTY_CHAR.repeat(empty);
|
|
92
116
|
if (score >= 0.8) return chalk.green(bar);
|
|
93
117
|
if (score >= 0.5) return chalk.yellow(bar);
|
|
@@ -99,16 +123,18 @@ function formatPercent(score) {
|
|
|
99
123
|
}
|
|
100
124
|
function getModeLabel(mode) {
|
|
101
125
|
switch (mode) {
|
|
102
|
-
case "
|
|
103
|
-
return "
|
|
104
|
-
case "
|
|
105
|
-
return "
|
|
126
|
+
case "committed":
|
|
127
|
+
return "Committed mode";
|
|
128
|
+
case "code-coverage":
|
|
129
|
+
return "Code Coverage mode";
|
|
106
130
|
case "weighted":
|
|
107
131
|
return "Weighted mode";
|
|
108
132
|
default:
|
|
109
133
|
return mode;
|
|
110
134
|
}
|
|
111
135
|
}
|
|
136
|
+
|
|
137
|
+
// src/cli/output/terminal.ts
|
|
112
138
|
var NAME_COLUMN_WIDTH = 24;
|
|
113
139
|
function renderFolder(node, indent, mode, maxDepth) {
|
|
114
140
|
const lines = [];
|
|
@@ -129,14 +155,14 @@ function renderFolder(node, indent, mode, maxDepth) {
|
|
|
129
155
|
NAME_COLUMN_WIDTH - prefixWidth - name.length
|
|
130
156
|
);
|
|
131
157
|
const padding = " ".repeat(padWidth);
|
|
132
|
-
if (mode === "
|
|
158
|
+
if (mode === "committed") {
|
|
133
159
|
const readCount = folder.readCount || 0;
|
|
134
160
|
lines.push(
|
|
135
|
-
`${prefix}${
|
|
161
|
+
`${prefix}${chalk2.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
|
|
136
162
|
);
|
|
137
163
|
} else {
|
|
138
164
|
lines.push(
|
|
139
|
-
`${prefix}${
|
|
165
|
+
`${prefix}${chalk2.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
|
|
140
166
|
);
|
|
141
167
|
}
|
|
142
168
|
if (indent < maxDepth) {
|
|
@@ -150,10 +176,10 @@ function renderTerminal(result) {
|
|
|
150
176
|
const { tree, repoName, mode } = result;
|
|
151
177
|
console.log("");
|
|
152
178
|
console.log(
|
|
153
|
-
|
|
179
|
+
chalk2.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`)
|
|
154
180
|
);
|
|
155
181
|
console.log("");
|
|
156
|
-
if (mode === "
|
|
182
|
+
if (mode === "committed") {
|
|
157
183
|
const readCount = tree.readCount || 0;
|
|
158
184
|
const pct = formatPercent(tree.score);
|
|
159
185
|
console.log(`Overall: ${readCount}/${tree.fileCount} files (${pct})`);
|
|
@@ -167,7 +193,7 @@ function renderTerminal(result) {
|
|
|
167
193
|
console.log(line);
|
|
168
194
|
}
|
|
169
195
|
console.log("");
|
|
170
|
-
if (mode === "
|
|
196
|
+
if (mode === "committed") {
|
|
171
197
|
const { writtenCount } = result;
|
|
172
198
|
console.log(`Written: ${writtenCount} files`);
|
|
173
199
|
console.log("");
|
|
@@ -189,18 +215,9 @@ async function openBrowser(filePath) {
|
|
|
189
215
|
}
|
|
190
216
|
}
|
|
191
217
|
|
|
192
|
-
// src/cli/output/html.ts
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
const mode = result.mode;
|
|
196
|
-
const repoName = result.repoName;
|
|
197
|
-
return `<!DOCTYPE html>
|
|
198
|
-
<html lang="en">
|
|
199
|
-
<head>
|
|
200
|
-
<meta charset="UTF-8">
|
|
201
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
202
|
-
<title>GitFamiliar \u2014 ${repoName}</title>
|
|
203
|
-
<style>
|
|
218
|
+
// src/cli/output/html-shared.ts
|
|
219
|
+
function getBaseStyles() {
|
|
220
|
+
return `
|
|
204
221
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
205
222
|
body {
|
|
206
223
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
@@ -218,17 +235,6 @@ function generateTreemapHTML(result) {
|
|
|
218
235
|
}
|
|
219
236
|
#header h1 { font-size: 18px; color: #e94560; }
|
|
220
237
|
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
221
|
-
#breadcrumb {
|
|
222
|
-
padding: 8px 24px;
|
|
223
|
-
background: #16213e;
|
|
224
|
-
font-size: 13px;
|
|
225
|
-
border-bottom: 1px solid #0f3460;
|
|
226
|
-
}
|
|
227
|
-
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
228
|
-
#breadcrumb span:hover { text-decoration: underline; }
|
|
229
|
-
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
230
|
-
|
|
231
|
-
#treemap { width: 100%; }
|
|
232
238
|
#tooltip {
|
|
233
239
|
position: absolute;
|
|
234
240
|
pointer-events: none;
|
|
@@ -240,8 +246,23 @@ function generateTreemapHTML(result) {
|
|
|
240
246
|
line-height: 1.6;
|
|
241
247
|
display: none;
|
|
242
248
|
z-index: 100;
|
|
243
|
-
max-width:
|
|
249
|
+
max-width: 350px;
|
|
250
|
+
}`;
|
|
251
|
+
}
|
|
252
|
+
function getBreadcrumbStyles() {
|
|
253
|
+
return `
|
|
254
|
+
#breadcrumb {
|
|
255
|
+
padding: 8px 24px;
|
|
256
|
+
background: #16213e;
|
|
257
|
+
font-size: 13px;
|
|
258
|
+
border-bottom: 1px solid #0f3460;
|
|
244
259
|
}
|
|
260
|
+
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
261
|
+
#breadcrumb span:hover { text-decoration: underline; }
|
|
262
|
+
#breadcrumb .sep { color: #666; margin: 0 4px; }`;
|
|
263
|
+
}
|
|
264
|
+
function getGradientLegendStyles() {
|
|
265
|
+
return `
|
|
245
266
|
#legend {
|
|
246
267
|
position: absolute;
|
|
247
268
|
bottom: 16px;
|
|
@@ -259,45 +280,22 @@ function generateTreemapHTML(result) {
|
|
|
259
280
|
border-radius: 3px;
|
|
260
281
|
margin: 4px 0;
|
|
261
282
|
}
|
|
262
|
-
#legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
|
|
263
|
-
</style>
|
|
264
|
-
</head>
|
|
265
|
-
<body>
|
|
266
|
-
<div id="header">
|
|
267
|
-
<h1>GitFamiliar \u2014 ${repoName}</h1>
|
|
268
|
-
<div class="info">${mode.charAt(0).toUpperCase() + mode.slice(1)} mode | ${result.totalFiles} files</div>
|
|
269
|
-
</div>
|
|
270
|
-
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
271
|
-
|
|
272
|
-
<div id="treemap"></div>
|
|
273
|
-
<div id="tooltip"></div>
|
|
274
|
-
<div id="legend">
|
|
275
|
-
<div>Familiarity</div>
|
|
276
|
-
<div class="gradient-bar"></div>
|
|
277
|
-
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
278
|
-
</div>
|
|
279
|
-
|
|
280
|
-
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
281
|
-
<script>
|
|
282
|
-
const rawData = ${dataJson};
|
|
283
|
-
const mode = "${mode}";
|
|
284
|
-
let currentPath = '';
|
|
285
|
-
|
|
286
|
-
function scoreColor(score) {
|
|
287
|
-
if (score <= 0) return '#e94560';
|
|
288
|
-
if (score >= 1) return '#27ae60';
|
|
289
|
-
if (score < 0.5) {
|
|
290
|
-
const t = score / 0.5;
|
|
291
|
-
return d3.interpolateRgb('#e94560', '#f5a623')(t);
|
|
292
|
-
}
|
|
293
|
-
const t = (score - 0.5) / 0.5;
|
|
294
|
-
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
283
|
+
#legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }`;
|
|
295
284
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
285
|
+
function getSidebarStyles() {
|
|
286
|
+
return `
|
|
287
|
+
#main { display: flex; height: calc(100vh - 90px); }
|
|
288
|
+
#sidebar {
|
|
289
|
+
width: 300px;
|
|
290
|
+
background: #16213e;
|
|
291
|
+
border-left: 1px solid #0f3460;
|
|
292
|
+
overflow-y: auto;
|
|
293
|
+
padding: 16px;
|
|
294
|
+
}
|
|
295
|
+
#sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }`;
|
|
299
296
|
}
|
|
300
|
-
|
|
297
|
+
function getTreemapUtilsScript() {
|
|
298
|
+
return `
|
|
301
299
|
function findNode(node, path) {
|
|
302
300
|
if (node.path === path) return node;
|
|
303
301
|
if (node.children) {
|
|
@@ -309,14 +307,6 @@ function findNode(node, path) {
|
|
|
309
307
|
return null;
|
|
310
308
|
}
|
|
311
309
|
|
|
312
|
-
function totalLines(node) {
|
|
313
|
-
if (node.type === 'file') return Math.max(1, node.lines);
|
|
314
|
-
if (!node.children) return 1;
|
|
315
|
-
let sum = 0;
|
|
316
|
-
for (const c of node.children) sum += totalLines(c);
|
|
317
|
-
return Math.max(1, sum);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
310
|
function buildHierarchy(node) {
|
|
321
311
|
if (node.type === 'file') {
|
|
322
312
|
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
@@ -328,6 +318,84 @@ function buildHierarchy(node) {
|
|
|
328
318
|
};
|
|
329
319
|
}
|
|
330
320
|
|
|
321
|
+
function truncateLabel(name, w, h) {
|
|
322
|
+
if (w < 36 || h < 18) return '';
|
|
323
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
324
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
325
|
+
return name;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function zoomTo(path) {
|
|
329
|
+
currentPath = path;
|
|
330
|
+
const el = document.getElementById('breadcrumb');
|
|
331
|
+
const parts = path ? path.split('/') : [];
|
|
332
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
333
|
+
let accumulated = '';
|
|
334
|
+
for (const part of parts) {
|
|
335
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
336
|
+
const p = accumulated;
|
|
337
|
+
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
338
|
+
}
|
|
339
|
+
el.innerHTML = html;
|
|
340
|
+
render();
|
|
341
|
+
}`;
|
|
342
|
+
}
|
|
343
|
+
function getScoreColorScript() {
|
|
344
|
+
return `
|
|
345
|
+
function scoreColor(score) {
|
|
346
|
+
if (score <= 0) return '#e94560';
|
|
347
|
+
if (score >= 1) return '#27ae60';
|
|
348
|
+
if (score < 0.5) {
|
|
349
|
+
const t = score / 0.5;
|
|
350
|
+
return d3.interpolateRgb('#e94560', '#f5a623')(t);
|
|
351
|
+
}
|
|
352
|
+
const t = (score - 0.5) / 0.5;
|
|
353
|
+
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
354
|
+
}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/cli/output/html.ts
|
|
358
|
+
function generateTreemapHTML(result) {
|
|
359
|
+
const dataJson = JSON.stringify(result.tree);
|
|
360
|
+
const mode = result.mode;
|
|
361
|
+
const repoName = result.repoName;
|
|
362
|
+
return `<!DOCTYPE html>
|
|
363
|
+
<html lang="en">
|
|
364
|
+
<head>
|
|
365
|
+
<meta charset="UTF-8">
|
|
366
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
367
|
+
<title>GitFamiliar \u2014 ${repoName}</title>
|
|
368
|
+
<style>
|
|
369
|
+
${getBaseStyles()}
|
|
370
|
+
${getBreadcrumbStyles()}
|
|
371
|
+
#treemap { width: 100%; }
|
|
372
|
+
${getGradientLegendStyles()}
|
|
373
|
+
</style>
|
|
374
|
+
</head>
|
|
375
|
+
<body>
|
|
376
|
+
<div id="header">
|
|
377
|
+
<h1>GitFamiliar \u2014 ${repoName}</h1>
|
|
378
|
+
<div class="info">${mode.charAt(0).toUpperCase() + mode.slice(1)} mode | ${result.totalFiles} files</div>
|
|
379
|
+
</div>
|
|
380
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
381
|
+
|
|
382
|
+
<div id="treemap"></div>
|
|
383
|
+
<div id="tooltip"></div>
|
|
384
|
+
<div id="legend">
|
|
385
|
+
<div>Familiarity</div>
|
|
386
|
+
<div class="gradient-bar"></div>
|
|
387
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
391
|
+
<script>
|
|
392
|
+
const rawData = ${dataJson};
|
|
393
|
+
const mode = "${mode}";
|
|
394
|
+
let currentPath = '';
|
|
395
|
+
|
|
396
|
+
${getScoreColorScript()}
|
|
397
|
+
${getTreemapUtilsScript()}
|
|
398
|
+
|
|
331
399
|
function render() {
|
|
332
400
|
const container = document.getElementById('treemap');
|
|
333
401
|
container.innerHTML = '';
|
|
@@ -343,7 +411,6 @@ function render() {
|
|
|
343
411
|
const children = targetNode.children || [];
|
|
344
412
|
if (children.length === 0) return;
|
|
345
413
|
|
|
346
|
-
// Build full nested hierarchy from the current target
|
|
347
414
|
const hierarchyData = {
|
|
348
415
|
name: targetNode.path || 'root',
|
|
349
416
|
children: children.map(c => buildHierarchy(c)),
|
|
@@ -373,13 +440,12 @@ function render() {
|
|
|
373
440
|
.join('g')
|
|
374
441
|
.attr('transform', d => \`translate(\${d.x0},\${d.y0})\`);
|
|
375
442
|
|
|
376
|
-
// Rect
|
|
377
443
|
groups.append('rect')
|
|
378
444
|
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
379
445
|
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
380
446
|
.attr('fill', d => {
|
|
381
447
|
if (!d.data.data) return '#333';
|
|
382
|
-
return scoreColor(
|
|
448
|
+
return scoreColor(d.data.data.score);
|
|
383
449
|
})
|
|
384
450
|
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
385
451
|
.attr('stroke', '#1a1a2e')
|
|
@@ -406,7 +472,6 @@ function render() {
|
|
|
406
472
|
tooltip.style.display = 'none';
|
|
407
473
|
});
|
|
408
474
|
|
|
409
|
-
// Labels
|
|
410
475
|
groups.append('text')
|
|
411
476
|
.attr('x', 4)
|
|
412
477
|
.attr('y', 14)
|
|
@@ -414,21 +479,13 @@ function render() {
|
|
|
414
479
|
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
415
480
|
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
416
481
|
.style('pointer-events', 'none')
|
|
417
|
-
.text(d =>
|
|
418
|
-
const w = d.x1 - d.x0;
|
|
419
|
-
const h = d.y1 - d.y0;
|
|
420
|
-
const name = d.data.name || '';
|
|
421
|
-
if (w < 36 || h < 18) return '';
|
|
422
|
-
const maxChars = Math.floor((w - 8) / 6.5);
|
|
423
|
-
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
424
|
-
return name;
|
|
425
|
-
});
|
|
482
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
426
483
|
}
|
|
427
484
|
|
|
428
485
|
function showTooltip(data, event) {
|
|
429
486
|
const tooltip = document.getElementById('tooltip');
|
|
430
487
|
const name = data.path || '';
|
|
431
|
-
const score =
|
|
488
|
+
const score = data.score;
|
|
432
489
|
let html = '<strong>' + name + '</strong>';
|
|
433
490
|
html += '<br>Score: ' + Math.round(score * 100) + '%';
|
|
434
491
|
html += '<br>Lines: ' + data.lines.toLocaleString();
|
|
@@ -442,7 +499,6 @@ function showTooltip(data, event) {
|
|
|
442
499
|
if (data.commitScore !== undefined) {
|
|
443
500
|
html += '<br>Commit: ' + Math.round(data.commitScore * 100) + '%';
|
|
444
501
|
}
|
|
445
|
-
|
|
446
502
|
if (data.isExpired) {
|
|
447
503
|
html += '<br><span style="color:#e94560">Expired</span>';
|
|
448
504
|
}
|
|
@@ -452,25 +508,6 @@ function showTooltip(data, event) {
|
|
|
452
508
|
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
453
509
|
}
|
|
454
510
|
|
|
455
|
-
function zoomTo(path) {
|
|
456
|
-
currentPath = path;
|
|
457
|
-
updateBreadcrumb();
|
|
458
|
-
render();
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function updateBreadcrumb() {
|
|
462
|
-
const el = document.getElementById('breadcrumb');
|
|
463
|
-
const parts = currentPath ? currentPath.split('/') : [];
|
|
464
|
-
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
465
|
-
let accumulated = '';
|
|
466
|
-
for (const part of parts) {
|
|
467
|
-
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
468
|
-
const p = accumulated;
|
|
469
|
-
html += \`<span class="sep">/</span><span onclick="zoomTo('\${p}')">\${part}</span>\`;
|
|
470
|
-
}
|
|
471
|
-
el.innerHTML = html;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
511
|
window.addEventListener('resize', render);
|
|
475
512
|
render();
|
|
476
513
|
</script>
|
|
@@ -485,6 +522,19 @@ async function generateAndOpenHTML(result, repoPath) {
|
|
|
485
522
|
await openBrowser(outputPath);
|
|
486
523
|
}
|
|
487
524
|
|
|
525
|
+
// src/core/risk.ts
|
|
526
|
+
function classifyHotspotRisk(risk) {
|
|
527
|
+
if (risk >= HOTSPOT_RISK_THRESHOLDS.critical) return "critical";
|
|
528
|
+
if (risk >= HOTSPOT_RISK_THRESHOLDS.high) return "high";
|
|
529
|
+
if (risk >= HOTSPOT_RISK_THRESHOLDS.medium) return "medium";
|
|
530
|
+
return "low";
|
|
531
|
+
}
|
|
532
|
+
function classifyCoverageRisk(contributorCount) {
|
|
533
|
+
if (contributorCount <= COVERAGE_RISK_THRESHOLDS.risk) return "risk";
|
|
534
|
+
if (contributorCount <= COVERAGE_RISK_THRESHOLDS.moderate) return "moderate";
|
|
535
|
+
return "safe";
|
|
536
|
+
}
|
|
537
|
+
|
|
488
538
|
// src/git/contributors.ts
|
|
489
539
|
var COMMIT_SEP = "GITFAMILIAR_SEP";
|
|
490
540
|
async function getAllContributors(gitClient, minCommits = 1) {
|
|
@@ -546,7 +596,10 @@ async function computeTeamCoverage(options) {
|
|
|
546
596
|
const tree = await buildFileTree(gitClient, filter);
|
|
547
597
|
const trackedFiles = /* @__PURE__ */ new Set();
|
|
548
598
|
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
549
|
-
const fileContributors = await bulkGetFileContributors(
|
|
599
|
+
const fileContributors = await bulkGetFileContributors(
|
|
600
|
+
gitClient,
|
|
601
|
+
trackedFiles
|
|
602
|
+
);
|
|
550
603
|
const allContributors = await getAllContributors(gitClient);
|
|
551
604
|
const coverageTree = buildCoverageTree(tree, fileContributors);
|
|
552
605
|
const riskFiles = [];
|
|
@@ -565,11 +618,6 @@ async function computeTeamCoverage(options) {
|
|
|
565
618
|
overallBusFactor: calculateBusFactor(fileContributors)
|
|
566
619
|
};
|
|
567
620
|
}
|
|
568
|
-
function classifyRisk(contributorCount) {
|
|
569
|
-
if (contributorCount <= 1) return "risk";
|
|
570
|
-
if (contributorCount <= 3) return "moderate";
|
|
571
|
-
return "safe";
|
|
572
|
-
}
|
|
573
621
|
function buildCoverageTree(node, fileContributors) {
|
|
574
622
|
const children = [];
|
|
575
623
|
for (const child of node.children) {
|
|
@@ -582,17 +630,32 @@ function buildCoverageTree(node, fileContributors) {
|
|
|
582
630
|
lines: child.lines,
|
|
583
631
|
contributorCount: names.length,
|
|
584
632
|
contributors: names,
|
|
585
|
-
riskLevel:
|
|
633
|
+
riskLevel: classifyCoverageRisk(names.length)
|
|
586
634
|
});
|
|
587
635
|
} else {
|
|
588
636
|
children.push(buildCoverageTree(child, fileContributors));
|
|
589
637
|
}
|
|
590
638
|
}
|
|
591
639
|
const fileScores = [];
|
|
592
|
-
walkCoverageFiles(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
640
|
+
walkCoverageFiles(
|
|
641
|
+
{
|
|
642
|
+
type: "folder",
|
|
643
|
+
path: "",
|
|
644
|
+
lines: 0,
|
|
645
|
+
fileCount: 0,
|
|
646
|
+
avgContributors: 0,
|
|
647
|
+
busFactor: 0,
|
|
648
|
+
riskLevel: "safe",
|
|
649
|
+
children
|
|
650
|
+
},
|
|
651
|
+
(f) => {
|
|
652
|
+
fileScores.push(f);
|
|
653
|
+
}
|
|
654
|
+
);
|
|
655
|
+
const totalContributors = fileScores.reduce(
|
|
656
|
+
(sum, f) => sum + f.contributorCount,
|
|
657
|
+
0
|
|
658
|
+
);
|
|
596
659
|
const avgContributors = fileScores.length > 0 ? totalContributors / fileScores.length : 0;
|
|
597
660
|
const folderFileContributors = /* @__PURE__ */ new Map();
|
|
598
661
|
for (const f of fileScores) {
|
|
@@ -606,7 +669,7 @@ function buildCoverageTree(node, fileContributors) {
|
|
|
606
669
|
fileCount: node.fileCount,
|
|
607
670
|
avgContributors: Math.round(avgContributors * 10) / 10,
|
|
608
671
|
busFactor,
|
|
609
|
-
riskLevel:
|
|
672
|
+
riskLevel: classifyCoverageRisk(busFactor),
|
|
610
673
|
children
|
|
611
674
|
};
|
|
612
675
|
}
|
|
@@ -622,7 +685,7 @@ function walkCoverageFiles(node, visitor) {
|
|
|
622
685
|
function calculateBusFactor(fileContributors) {
|
|
623
686
|
const totalFiles = fileContributors.size;
|
|
624
687
|
if (totalFiles === 0) return 0;
|
|
625
|
-
const target = Math.ceil(totalFiles *
|
|
688
|
+
const target = Math.ceil(totalFiles * BUS_FACTOR_TARGET);
|
|
626
689
|
const contributorFiles = /* @__PURE__ */ new Map();
|
|
627
690
|
for (const [file, contributors] of fileContributors) {
|
|
628
691
|
for (const contributor of contributors) {
|
|
@@ -661,15 +724,15 @@ function calculateBusFactor(fileContributors) {
|
|
|
661
724
|
}
|
|
662
725
|
|
|
663
726
|
// src/cli/output/coverage-terminal.ts
|
|
664
|
-
import
|
|
727
|
+
import chalk3 from "chalk";
|
|
665
728
|
function riskBadge(level) {
|
|
666
729
|
switch (level) {
|
|
667
730
|
case "risk":
|
|
668
|
-
return
|
|
731
|
+
return chalk3.bgRed.white(" RISK ");
|
|
669
732
|
case "moderate":
|
|
670
|
-
return
|
|
733
|
+
return chalk3.bgYellow.black(" MOD ");
|
|
671
734
|
case "safe":
|
|
672
|
-
return
|
|
735
|
+
return chalk3.bgGreen.black(" SAFE ");
|
|
673
736
|
default:
|
|
674
737
|
return level;
|
|
675
738
|
}
|
|
@@ -677,11 +740,11 @@ function riskBadge(level) {
|
|
|
677
740
|
function riskColor(level) {
|
|
678
741
|
switch (level) {
|
|
679
742
|
case "risk":
|
|
680
|
-
return
|
|
743
|
+
return chalk3.red;
|
|
681
744
|
case "moderate":
|
|
682
|
-
return
|
|
745
|
+
return chalk3.yellow;
|
|
683
746
|
default:
|
|
684
|
-
return
|
|
747
|
+
return chalk3.green;
|
|
685
748
|
}
|
|
686
749
|
}
|
|
687
750
|
function renderFolder2(node, indent, maxDepth) {
|
|
@@ -696,7 +759,7 @@ function renderFolder2(node, indent, maxDepth) {
|
|
|
696
759
|
const name = (child.path.split("/").pop() || child.path) + "/";
|
|
697
760
|
const color = riskColor(child.riskLevel);
|
|
698
761
|
lines.push(
|
|
699
|
-
`${prefix}${
|
|
762
|
+
`${prefix}${chalk3.bold(name.padEnd(24))} ${String(child.avgContributors).padStart(4)} avg ${String(child.busFactor).padStart(2)} ${riskBadge(child.riskLevel)}`
|
|
700
763
|
);
|
|
701
764
|
if (indent < maxDepth) {
|
|
702
765
|
lines.push(...renderFolder2(child, indent + 1, maxDepth));
|
|
@@ -708,36 +771,36 @@ function renderFolder2(node, indent, maxDepth) {
|
|
|
708
771
|
function renderCoverageTerminal(result) {
|
|
709
772
|
console.log("");
|
|
710
773
|
console.log(
|
|
711
|
-
|
|
774
|
+
chalk3.bold(
|
|
712
775
|
`GitFamiliar \u2014 Team Coverage (${result.totalFiles} files, ${result.totalContributors} contributors)`
|
|
713
776
|
)
|
|
714
777
|
);
|
|
715
778
|
console.log("");
|
|
716
|
-
const bfColor = result.overallBusFactor <= 1 ?
|
|
779
|
+
const bfColor = result.overallBusFactor <= 1 ? chalk3.red : result.overallBusFactor <= 2 ? chalk3.yellow : chalk3.green;
|
|
717
780
|
console.log(`Overall Bus Factor: ${bfColor.bold(String(result.overallBusFactor))}`);
|
|
718
781
|
console.log("");
|
|
719
782
|
if (result.riskFiles.length > 0) {
|
|
720
|
-
console.log(
|
|
783
|
+
console.log(chalk3.red.bold(`Risk Files (0-1 contributors):`));
|
|
721
784
|
const displayFiles = result.riskFiles.slice(0, 20);
|
|
722
785
|
for (const file of displayFiles) {
|
|
723
786
|
const count = file.contributorCount;
|
|
724
787
|
const names = file.contributors.join(", ");
|
|
725
|
-
const label = count === 0 ?
|
|
788
|
+
const label = count === 0 ? chalk3.red("0 people") : chalk3.yellow(`1 person (${names})`);
|
|
726
789
|
console.log(` ${file.path.padEnd(40)} ${label}`);
|
|
727
790
|
}
|
|
728
791
|
if (result.riskFiles.length > 20) {
|
|
729
792
|
console.log(
|
|
730
|
-
|
|
793
|
+
chalk3.gray(` ... and ${result.riskFiles.length - 20} more`)
|
|
731
794
|
);
|
|
732
795
|
}
|
|
733
796
|
console.log("");
|
|
734
797
|
} else {
|
|
735
|
-
console.log(
|
|
798
|
+
console.log(chalk3.green("No high-risk files found."));
|
|
736
799
|
console.log("");
|
|
737
800
|
}
|
|
738
|
-
console.log(
|
|
801
|
+
console.log(chalk3.bold("Folder Coverage:"));
|
|
739
802
|
console.log(
|
|
740
|
-
|
|
803
|
+
chalk3.gray(
|
|
741
804
|
` ${"Folder".padEnd(24)} ${"Avg Contrib".padStart(11)} ${"Bus Factor".padStart(10)} Risk`
|
|
742
805
|
)
|
|
743
806
|
);
|
|
@@ -761,42 +824,9 @@ function generateCoverageHTML(result) {
|
|
|
761
824
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
762
825
|
<title>GitFamiliar \u2014 Team Coverage \u2014 ${result.repoName}</title>
|
|
763
826
|
<style>
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
background: #1a1a2e;
|
|
768
|
-
color: #e0e0e0;
|
|
769
|
-
overflow: hidden;
|
|
770
|
-
}
|
|
771
|
-
#header {
|
|
772
|
-
padding: 16px 24px;
|
|
773
|
-
background: #16213e;
|
|
774
|
-
border-bottom: 1px solid #0f3460;
|
|
775
|
-
display: flex;
|
|
776
|
-
align-items: center;
|
|
777
|
-
justify-content: space-between;
|
|
778
|
-
}
|
|
779
|
-
#header h1 { font-size: 18px; color: #e94560; }
|
|
780
|
-
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
781
|
-
#breadcrumb {
|
|
782
|
-
padding: 8px 24px;
|
|
783
|
-
background: #16213e;
|
|
784
|
-
font-size: 13px;
|
|
785
|
-
border-bottom: 1px solid #0f3460;
|
|
786
|
-
}
|
|
787
|
-
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
788
|
-
#breadcrumb span:hover { text-decoration: underline; }
|
|
789
|
-
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
790
|
-
#main { display: flex; height: calc(100vh - 90px); }
|
|
791
|
-
#treemap { flex: 1; }
|
|
792
|
-
#sidebar {
|
|
793
|
-
width: 300px;
|
|
794
|
-
background: #16213e;
|
|
795
|
-
border-left: 1px solid #0f3460;
|
|
796
|
-
overflow-y: auto;
|
|
797
|
-
padding: 16px;
|
|
798
|
-
}
|
|
799
|
-
#sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
|
|
827
|
+
${getBaseStyles()}
|
|
828
|
+
${getBreadcrumbStyles()}
|
|
829
|
+
${getSidebarStyles()}
|
|
800
830
|
#sidebar .risk-file {
|
|
801
831
|
padding: 6px 0;
|
|
802
832
|
border-bottom: 1px solid #0f3460;
|
|
@@ -804,19 +834,7 @@ function generateCoverageHTML(result) {
|
|
|
804
834
|
}
|
|
805
835
|
#sidebar .risk-file .path { color: #e0e0e0; word-break: break-all; }
|
|
806
836
|
#sidebar .risk-file .meta { color: #888; margin-top: 2px; }
|
|
807
|
-
#
|
|
808
|
-
position: absolute;
|
|
809
|
-
pointer-events: none;
|
|
810
|
-
background: rgba(22, 33, 62, 0.95);
|
|
811
|
-
border: 1px solid #0f3460;
|
|
812
|
-
border-radius: 6px;
|
|
813
|
-
padding: 10px 14px;
|
|
814
|
-
font-size: 13px;
|
|
815
|
-
line-height: 1.6;
|
|
816
|
-
display: none;
|
|
817
|
-
z-index: 100;
|
|
818
|
-
max-width: 320px;
|
|
819
|
-
}
|
|
837
|
+
#treemap { flex: 1; }
|
|
820
838
|
#legend {
|
|
821
839
|
position: absolute;
|
|
822
840
|
bottom: 16px;
|
|
@@ -873,27 +891,7 @@ function folderColor(riskLevel) {
|
|
|
873
891
|
}
|
|
874
892
|
}
|
|
875
893
|
|
|
876
|
-
|
|
877
|
-
if (node.path === path) return node;
|
|
878
|
-
if (node.children) {
|
|
879
|
-
for (const child of node.children) {
|
|
880
|
-
const found = findNode(child, path);
|
|
881
|
-
if (found) return found;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
return null;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function buildHierarchy(node) {
|
|
888
|
-
if (node.type === 'file') {
|
|
889
|
-
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
890
|
-
}
|
|
891
|
-
return {
|
|
892
|
-
name: node.path.split('/').pop() || node.path,
|
|
893
|
-
data: node,
|
|
894
|
-
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
895
|
-
};
|
|
896
|
-
}
|
|
894
|
+
${getTreemapUtilsScript()}
|
|
897
895
|
|
|
898
896
|
function render() {
|
|
899
897
|
const container = document.getElementById('treemap');
|
|
@@ -975,15 +973,7 @@ function render() {
|
|
|
975
973
|
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
976
974
|
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
977
975
|
.style('pointer-events', 'none')
|
|
978
|
-
.text(d =>
|
|
979
|
-
const w = d.x1 - d.x0;
|
|
980
|
-
const h = d.y1 - d.y0;
|
|
981
|
-
const name = d.data.name || '';
|
|
982
|
-
if (w < 36 || h < 18) return '';
|
|
983
|
-
const maxChars = Math.floor((w - 8) / 6.5);
|
|
984
|
-
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
985
|
-
return name;
|
|
986
|
-
});
|
|
976
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
987
977
|
}
|
|
988
978
|
|
|
989
979
|
function showTooltip(data, event) {
|
|
@@ -1008,22 +998,6 @@ function showTooltip(data, event) {
|
|
|
1008
998
|
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1009
999
|
}
|
|
1010
1000
|
|
|
1011
|
-
function zoomTo(path) {
|
|
1012
|
-
currentPath = path;
|
|
1013
|
-
const el = document.getElementById('breadcrumb');
|
|
1014
|
-
const parts = path ? path.split('/') : [];
|
|
1015
|
-
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
1016
|
-
let accumulated = '';
|
|
1017
|
-
for (const part of parts) {
|
|
1018
|
-
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
1019
|
-
const p = accumulated;
|
|
1020
|
-
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
1021
|
-
}
|
|
1022
|
-
el.innerHTML = html;
|
|
1023
|
-
render();
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Render risk sidebar
|
|
1027
1001
|
function renderRiskSidebar() {
|
|
1028
1002
|
const container = document.getElementById('risk-list');
|
|
1029
1003
|
if (riskFiles.length === 0) {
|
|
@@ -1087,7 +1061,7 @@ async function computeMultiUser(options) {
|
|
|
1087
1061
|
...options,
|
|
1088
1062
|
user: userName,
|
|
1089
1063
|
team: false,
|
|
1090
|
-
|
|
1064
|
+
contributorsPerFile: false
|
|
1091
1065
|
};
|
|
1092
1066
|
const result = await computeFamiliarity(userOptions);
|
|
1093
1067
|
results.push({ userName, result });
|
|
@@ -1191,34 +1165,8 @@ function findFolderInTree(node, targetPath) {
|
|
|
1191
1165
|
}
|
|
1192
1166
|
|
|
1193
1167
|
// src/cli/output/multi-user-terminal.ts
|
|
1194
|
-
import
|
|
1195
|
-
var
|
|
1196
|
-
var FILLED_CHAR2 = "\u2588";
|
|
1197
|
-
var EMPTY_CHAR2 = "\u2591";
|
|
1198
|
-
function makeBar2(score, width = BAR_WIDTH2) {
|
|
1199
|
-
const filled = Math.round(score * width);
|
|
1200
|
-
const empty = width - filled;
|
|
1201
|
-
const bar = FILLED_CHAR2.repeat(filled) + EMPTY_CHAR2.repeat(empty);
|
|
1202
|
-
if (score >= 0.8) return chalk3.green(bar);
|
|
1203
|
-
if (score >= 0.5) return chalk3.yellow(bar);
|
|
1204
|
-
if (score > 0) return chalk3.red(bar);
|
|
1205
|
-
return chalk3.gray(bar);
|
|
1206
|
-
}
|
|
1207
|
-
function formatPercent2(score) {
|
|
1208
|
-
return `${Math.round(score * 100)}%`;
|
|
1209
|
-
}
|
|
1210
|
-
function getModeLabel2(mode) {
|
|
1211
|
-
switch (mode) {
|
|
1212
|
-
case "binary":
|
|
1213
|
-
return "Binary mode";
|
|
1214
|
-
case "authorship":
|
|
1215
|
-
return "Authorship mode";
|
|
1216
|
-
case "weighted":
|
|
1217
|
-
return "Weighted mode";
|
|
1218
|
-
default:
|
|
1219
|
-
return mode;
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1168
|
+
import chalk4 from "chalk";
|
|
1169
|
+
var BAR_WIDTH = 20;
|
|
1222
1170
|
function truncateName(name, maxLen) {
|
|
1223
1171
|
if (name.length <= maxLen) return name;
|
|
1224
1172
|
return name.slice(0, maxLen - 1) + "\u2026";
|
|
@@ -1234,8 +1182,8 @@ function renderFolder3(node, indent, maxDepth, nameWidth) {
|
|
|
1234
1182
|
const prefix = " ".repeat(indent);
|
|
1235
1183
|
const name = (child.path.split("/").pop() || child.path) + "/";
|
|
1236
1184
|
const displayName = truncateName(name, nameWidth).padEnd(nameWidth);
|
|
1237
|
-
const scores = child.userScores.map((s) =>
|
|
1238
|
-
lines.push(`${prefix}${
|
|
1185
|
+
const scores = child.userScores.map((s) => formatPercent(s.score).padStart(5)).join(" ");
|
|
1186
|
+
lines.push(`${prefix}${chalk4.bold(displayName)} ${scores}`);
|
|
1239
1187
|
if (indent < maxDepth) {
|
|
1240
1188
|
lines.push(...renderFolder3(child, indent + 1, maxDepth, nameWidth));
|
|
1241
1189
|
}
|
|
@@ -1247,17 +1195,17 @@ function renderMultiUserTerminal(result) {
|
|
|
1247
1195
|
const { tree, repoName, mode, userSummaries, totalFiles } = result;
|
|
1248
1196
|
console.log("");
|
|
1249
1197
|
console.log(
|
|
1250
|
-
|
|
1251
|
-
`GitFamiliar \u2014 ${repoName} (${
|
|
1198
|
+
chalk4.bold(
|
|
1199
|
+
`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)}, ${userSummaries.length} users)`
|
|
1252
1200
|
)
|
|
1253
1201
|
);
|
|
1254
1202
|
console.log("");
|
|
1255
|
-
console.log(
|
|
1203
|
+
console.log(chalk4.bold("Overall:"));
|
|
1256
1204
|
for (const summary of userSummaries) {
|
|
1257
1205
|
const name = truncateName(summary.user.name, 14).padEnd(14);
|
|
1258
|
-
const bar =
|
|
1259
|
-
const pct =
|
|
1260
|
-
if (mode === "
|
|
1206
|
+
const bar = makeBar(summary.overallScore, BAR_WIDTH);
|
|
1207
|
+
const pct = formatPercent(summary.overallScore);
|
|
1208
|
+
if (mode === "committed") {
|
|
1261
1209
|
console.log(
|
|
1262
1210
|
` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount}/${totalFiles} files)`
|
|
1263
1211
|
);
|
|
@@ -1268,7 +1216,7 @@ function renderMultiUserTerminal(result) {
|
|
|
1268
1216
|
console.log("");
|
|
1269
1217
|
const nameWidth = 20;
|
|
1270
1218
|
const headerNames = userSummaries.map((s) => truncateName(s.user.name, 7).padStart(7)).join(" ");
|
|
1271
|
-
console.log(
|
|
1219
|
+
console.log(chalk4.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames);
|
|
1272
1220
|
const folderLines = renderFolder3(tree, 1, 2, nameWidth);
|
|
1273
1221
|
for (const line of folderLines) {
|
|
1274
1222
|
console.log(line);
|
|
@@ -1290,71 +1238,19 @@ function generateMultiUserHTML(result) {
|
|
|
1290
1238
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1291
1239
|
<title>GitFamiliar \u2014 ${result.repoName} \u2014 Multi-User</title>
|
|
1292
1240
|
<style>
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
overflow: hidden;
|
|
1299
|
-
}
|
|
1300
|
-
#header {
|
|
1301
|
-
padding: 16px 24px;
|
|
1302
|
-
background: #16213e;
|
|
1303
|
-
border-bottom: 1px solid #0f3460;
|
|
1304
|
-
display: flex;
|
|
1305
|
-
align-items: center;
|
|
1306
|
-
justify-content: space-between;
|
|
1307
|
-
}
|
|
1308
|
-
#header h1 { font-size: 18px; color: #e94560; }
|
|
1309
|
-
#header .controls { display: flex; align-items: center; gap: 12px; }
|
|
1310
|
-
#header select {
|
|
1311
|
-
padding: 4px 12px;
|
|
1312
|
-
border: 1px solid #0f3460;
|
|
1313
|
-
background: #1a1a2e;
|
|
1314
|
-
color: #e0e0e0;
|
|
1315
|
-
border-radius: 4px;
|
|
1316
|
-
font-size: 13px;
|
|
1317
|
-
}
|
|
1318
|
-
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
1319
|
-
#breadcrumb {
|
|
1320
|
-
padding: 8px 24px;
|
|
1321
|
-
background: #16213e;
|
|
1322
|
-
font-size: 13px;
|
|
1323
|
-
border-bottom: 1px solid #0f3460;
|
|
1324
|
-
}
|
|
1325
|
-
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
1326
|
-
#breadcrumb span:hover { text-decoration: underline; }
|
|
1327
|
-
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
1328
|
-
#treemap { width: 100%; }
|
|
1329
|
-
#tooltip {
|
|
1330
|
-
position: absolute;
|
|
1331
|
-
pointer-events: none;
|
|
1332
|
-
background: rgba(22, 33, 62, 0.95);
|
|
1241
|
+
${getBaseStyles()}
|
|
1242
|
+
${getBreadcrumbStyles()}
|
|
1243
|
+
#header .controls { display: flex; align-items: center; gap: 12px; }
|
|
1244
|
+
#header select {
|
|
1245
|
+
padding: 4px 12px;
|
|
1333
1246
|
border: 1px solid #0f3460;
|
|
1334
|
-
|
|
1335
|
-
|
|
1247
|
+
background: #1a1a2e;
|
|
1248
|
+
color: #e0e0e0;
|
|
1249
|
+
border-radius: 4px;
|
|
1336
1250
|
font-size: 13px;
|
|
1337
|
-
line-height: 1.6;
|
|
1338
|
-
display: none;
|
|
1339
|
-
z-index: 100;
|
|
1340
|
-
max-width: 350px;
|
|
1341
|
-
}
|
|
1342
|
-
#legend {
|
|
1343
|
-
position: absolute;
|
|
1344
|
-
bottom: 16px;
|
|
1345
|
-
right: 16px;
|
|
1346
|
-
background: rgba(22, 33, 62, 0.9);
|
|
1347
|
-
border: 1px solid #0f3460;
|
|
1348
|
-
border-radius: 6px;
|
|
1349
|
-
padding: 10px;
|
|
1350
|
-
font-size: 12px;
|
|
1351
|
-
}
|
|
1352
|
-
#legend .gradient-bar {
|
|
1353
|
-
width: 120px; height: 12px;
|
|
1354
|
-
background: linear-gradient(to right, #e94560, #f5a623, #27ae60);
|
|
1355
|
-
border-radius: 3px; margin: 4px 0;
|
|
1356
1251
|
}
|
|
1357
|
-
#
|
|
1252
|
+
#treemap { width: 100%; }
|
|
1253
|
+
${getGradientLegendStyles()}
|
|
1358
1254
|
</style>
|
|
1359
1255
|
</head>
|
|
1360
1256
|
<body>
|
|
@@ -1398,16 +1294,7 @@ function changeUser() {
|
|
|
1398
1294
|
render();
|
|
1399
1295
|
}
|
|
1400
1296
|
|
|
1401
|
-
|
|
1402
|
-
if (score <= 0) return '#e94560';
|
|
1403
|
-
if (score >= 1) return '#27ae60';
|
|
1404
|
-
if (score < 0.5) {
|
|
1405
|
-
const t = score / 0.5;
|
|
1406
|
-
return d3.interpolateRgb('#e94560', '#f5a623')(t);
|
|
1407
|
-
}
|
|
1408
|
-
const t = (score - 0.5) / 0.5;
|
|
1409
|
-
return d3.interpolateRgb('#f5a623', '#27ae60')(t);
|
|
1410
|
-
}
|
|
1297
|
+
${getScoreColorScript()}
|
|
1411
1298
|
|
|
1412
1299
|
function getUserScore(node) {
|
|
1413
1300
|
if (!node.userScores || node.userScores.length === 0) return node.score;
|
|
@@ -1415,27 +1302,7 @@ function getUserScore(node) {
|
|
|
1415
1302
|
return s ? s.score : 0;
|
|
1416
1303
|
}
|
|
1417
1304
|
|
|
1418
|
-
|
|
1419
|
-
if (node.path === path) return node;
|
|
1420
|
-
if (node.children) {
|
|
1421
|
-
for (const child of node.children) {
|
|
1422
|
-
const found = findNode(child, path);
|
|
1423
|
-
if (found) return found;
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
return null;
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
function buildHierarchy(node) {
|
|
1430
|
-
if (node.type === 'file') {
|
|
1431
|
-
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
1432
|
-
}
|
|
1433
|
-
return {
|
|
1434
|
-
name: node.path.split('/').pop() || node.path,
|
|
1435
|
-
data: node,
|
|
1436
|
-
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1305
|
+
${getTreemapUtilsScript()}
|
|
1439
1306
|
|
|
1440
1307
|
function render() {
|
|
1441
1308
|
const container = document.getElementById('treemap');
|
|
@@ -1516,15 +1383,7 @@ function render() {
|
|
|
1516
1383
|
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
1517
1384
|
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
1518
1385
|
.style('pointer-events', 'none')
|
|
1519
|
-
.text(d =>
|
|
1520
|
-
const w = d.x1 - d.x0;
|
|
1521
|
-
const h = d.y1 - d.y0;
|
|
1522
|
-
const name = d.data.name || '';
|
|
1523
|
-
if (w < 36 || h < 18) return '';
|
|
1524
|
-
const maxChars = Math.floor((w - 8) / 6.5);
|
|
1525
|
-
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
1526
|
-
return name;
|
|
1527
|
-
});
|
|
1386
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
1528
1387
|
}
|
|
1529
1388
|
|
|
1530
1389
|
function showTooltip(data, event) {
|
|
@@ -1554,21 +1413,6 @@ function showTooltip(data, event) {
|
|
|
1554
1413
|
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
1555
1414
|
}
|
|
1556
1415
|
|
|
1557
|
-
function zoomTo(path) {
|
|
1558
|
-
currentPath = path;
|
|
1559
|
-
const el = document.getElementById('breadcrumb');
|
|
1560
|
-
const parts = path ? path.split('/') : [];
|
|
1561
|
-
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
1562
|
-
let accumulated = '';
|
|
1563
|
-
for (const part of parts) {
|
|
1564
|
-
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
1565
|
-
const p = accumulated;
|
|
1566
|
-
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
1567
|
-
}
|
|
1568
|
-
el.innerHTML = html;
|
|
1569
|
-
render();
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
1416
|
window.addEventListener('resize', render);
|
|
1573
1417
|
render();
|
|
1574
1418
|
</script>
|
|
@@ -1617,7 +1461,6 @@ async function bulkGetChangeFrequency(gitClient, days, trackedFiles) {
|
|
|
1617
1461
|
}
|
|
1618
1462
|
|
|
1619
1463
|
// src/core/hotspot.ts
|
|
1620
|
-
var DEFAULT_WINDOW = 90;
|
|
1621
1464
|
async function computeHotspots(options) {
|
|
1622
1465
|
const gitClient = new GitClient(options.repoPath);
|
|
1623
1466
|
if (!await gitClient.isRepo()) {
|
|
@@ -1627,7 +1470,7 @@ async function computeHotspots(options) {
|
|
|
1627
1470
|
const repoRoot = await gitClient.getRepoRoot();
|
|
1628
1471
|
const filter = createFilter(repoRoot);
|
|
1629
1472
|
const tree = await buildFileTree(gitClient, filter);
|
|
1630
|
-
const timeWindow = options.
|
|
1473
|
+
const timeWindow = options.since || DEFAULT_HOTSPOT_WINDOW;
|
|
1631
1474
|
const isTeamMode = options.hotspot === "team";
|
|
1632
1475
|
const trackedFiles = /* @__PURE__ */ new Set();
|
|
1633
1476
|
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
@@ -1649,7 +1492,7 @@ async function computeHotspots(options) {
|
|
|
1649
1492
|
const result = await computeFamiliarity({
|
|
1650
1493
|
...options,
|
|
1651
1494
|
team: false,
|
|
1652
|
-
|
|
1495
|
+
contributorsPerFile: false
|
|
1653
1496
|
});
|
|
1654
1497
|
userName = result.userName;
|
|
1655
1498
|
familiarityMap = /* @__PURE__ */ new Map();
|
|
@@ -1697,12 +1540,6 @@ async function computeHotspots(options) {
|
|
|
1697
1540
|
summary
|
|
1698
1541
|
};
|
|
1699
1542
|
}
|
|
1700
|
-
function classifyHotspotRisk(risk) {
|
|
1701
|
-
if (risk >= 0.6) return "critical";
|
|
1702
|
-
if (risk >= 0.4) return "high";
|
|
1703
|
-
if (risk >= 0.2) return "medium";
|
|
1704
|
-
return "low";
|
|
1705
|
-
}
|
|
1706
1543
|
async function computeTeamAvgFamiliarity(gitClient, trackedFiles, options) {
|
|
1707
1544
|
const contributors = await getAllContributors(gitClient, 1);
|
|
1708
1545
|
const totalContributors = Math.max(1, contributors.length);
|
|
@@ -1723,29 +1560,29 @@ async function computeTeamAvgFamiliarity(gitClient, trackedFiles, options) {
|
|
|
1723
1560
|
}
|
|
1724
1561
|
|
|
1725
1562
|
// src/cli/output/hotspot-terminal.ts
|
|
1726
|
-
import
|
|
1563
|
+
import chalk5 from "chalk";
|
|
1727
1564
|
function riskBadge2(level) {
|
|
1728
1565
|
switch (level) {
|
|
1729
1566
|
case "critical":
|
|
1730
|
-
return
|
|
1567
|
+
return chalk5.bgRed.white.bold(" CRIT ");
|
|
1731
1568
|
case "high":
|
|
1732
|
-
return
|
|
1569
|
+
return chalk5.bgRedBright.white(" HIGH ");
|
|
1733
1570
|
case "medium":
|
|
1734
|
-
return
|
|
1571
|
+
return chalk5.bgYellow.black(" MED ");
|
|
1735
1572
|
case "low":
|
|
1736
|
-
return
|
|
1573
|
+
return chalk5.bgGreen.black(" LOW ");
|
|
1737
1574
|
}
|
|
1738
1575
|
}
|
|
1739
1576
|
function riskColor2(level) {
|
|
1740
1577
|
switch (level) {
|
|
1741
1578
|
case "critical":
|
|
1742
|
-
return
|
|
1579
|
+
return chalk5.red;
|
|
1743
1580
|
case "high":
|
|
1744
|
-
return
|
|
1581
|
+
return chalk5.redBright;
|
|
1745
1582
|
case "medium":
|
|
1746
|
-
return
|
|
1583
|
+
return chalk5.yellow;
|
|
1747
1584
|
case "low":
|
|
1748
|
-
return
|
|
1585
|
+
return chalk5.green;
|
|
1749
1586
|
}
|
|
1750
1587
|
}
|
|
1751
1588
|
function renderHotspotTerminal(result) {
|
|
@@ -1754,24 +1591,24 @@ function renderHotspotTerminal(result) {
|
|
|
1754
1591
|
const modeLabel = hotspotMode === "team" ? "Team Hotspots" : "Personal Hotspots";
|
|
1755
1592
|
const userLabel = userName ? ` (${userName})` : "";
|
|
1756
1593
|
console.log(
|
|
1757
|
-
|
|
1594
|
+
chalk5.bold(`GitFamiliar \u2014 ${modeLabel}${userLabel} \u2014 ${repoName}`)
|
|
1758
1595
|
);
|
|
1759
|
-
console.log(
|
|
1596
|
+
console.log(chalk5.gray(` Time window: last ${timeWindow} days`));
|
|
1760
1597
|
console.log("");
|
|
1761
1598
|
const activeFiles = files.filter((f) => f.changeFrequency > 0);
|
|
1762
1599
|
if (activeFiles.length === 0) {
|
|
1763
|
-
console.log(
|
|
1600
|
+
console.log(chalk5.gray(" No files changed in the time window."));
|
|
1764
1601
|
console.log("");
|
|
1765
1602
|
return;
|
|
1766
1603
|
}
|
|
1767
1604
|
const displayCount = Math.min(30, activeFiles.length);
|
|
1768
1605
|
const topFiles = activeFiles.slice(0, displayCount);
|
|
1769
1606
|
console.log(
|
|
1770
|
-
|
|
1607
|
+
chalk5.gray(
|
|
1771
1608
|
` ${"Rank".padEnd(5)} ${"File".padEnd(42)} ${"Familiarity".padStart(11)} ${"Changes".padStart(8)} ${"Risk".padStart(6)} Level`
|
|
1772
1609
|
)
|
|
1773
1610
|
);
|
|
1774
|
-
console.log(
|
|
1611
|
+
console.log(chalk5.gray(" " + "\u2500".repeat(90)));
|
|
1775
1612
|
for (let i = 0; i < topFiles.length; i++) {
|
|
1776
1613
|
const f = topFiles[i];
|
|
1777
1614
|
const rank = String(i + 1).padEnd(5);
|
|
@@ -1787,33 +1624,33 @@ function renderHotspotTerminal(result) {
|
|
|
1787
1624
|
}
|
|
1788
1625
|
if (activeFiles.length > displayCount) {
|
|
1789
1626
|
console.log(
|
|
1790
|
-
|
|
1627
|
+
chalk5.gray(` ... and ${activeFiles.length - displayCount} more files`)
|
|
1791
1628
|
);
|
|
1792
1629
|
}
|
|
1793
1630
|
console.log("");
|
|
1794
|
-
console.log(
|
|
1631
|
+
console.log(chalk5.bold("Summary:"));
|
|
1795
1632
|
if (summary.critical > 0) {
|
|
1796
1633
|
console.log(
|
|
1797
|
-
` ${
|
|
1634
|
+
` ${chalk5.red.bold(`\u{1F534} Critical Risk: ${summary.critical} files`)}`
|
|
1798
1635
|
);
|
|
1799
1636
|
}
|
|
1800
1637
|
if (summary.high > 0) {
|
|
1801
1638
|
console.log(
|
|
1802
|
-
` ${
|
|
1639
|
+
` ${chalk5.redBright(`\u{1F7E0} High Risk: ${summary.high} files`)}`
|
|
1803
1640
|
);
|
|
1804
1641
|
}
|
|
1805
1642
|
if (summary.medium > 0) {
|
|
1806
1643
|
console.log(
|
|
1807
|
-
` ${
|
|
1644
|
+
` ${chalk5.yellow(`\u{1F7E1} Medium Risk: ${summary.medium} files`)}`
|
|
1808
1645
|
);
|
|
1809
1646
|
}
|
|
1810
1647
|
console.log(
|
|
1811
|
-
` ${
|
|
1648
|
+
` ${chalk5.green(`\u{1F7E2} Low Risk: ${summary.low} files`)}`
|
|
1812
1649
|
);
|
|
1813
1650
|
console.log("");
|
|
1814
1651
|
if (summary.critical > 0 || summary.high > 0) {
|
|
1815
1652
|
console.log(
|
|
1816
|
-
|
|
1653
|
+
chalk5.gray(
|
|
1817
1654
|
" Recommendation: Focus code review and knowledge transfer on critical/high risk files."
|
|
1818
1655
|
)
|
|
1819
1656
|
);
|
|
@@ -1849,33 +1686,11 @@ function generateHotspotHTML(result) {
|
|
|
1849
1686
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1850
1687
|
<title>GitFamiliar \u2014 ${modeLabel} \u2014 ${result.repoName}</title>
|
|
1851
1688
|
<style>
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
color: #e0e0e0;
|
|
1857
|
-
overflow: hidden;
|
|
1858
|
-
}
|
|
1859
|
-
#header {
|
|
1860
|
-
padding: 16px 24px;
|
|
1861
|
-
background: #16213e;
|
|
1862
|
-
border-bottom: 1px solid #0f3460;
|
|
1863
|
-
display: flex;
|
|
1864
|
-
align-items: center;
|
|
1865
|
-
justify-content: space-between;
|
|
1866
|
-
}
|
|
1867
|
-
#header h1 { font-size: 18px; color: #e94560; }
|
|
1868
|
-
#header .info { font-size: 14px; color: #a0a0a0; }
|
|
1869
|
-
#main { display: flex; height: calc(100vh - 60px); }
|
|
1689
|
+
${getBaseStyles()}
|
|
1690
|
+
${getSidebarStyles()}
|
|
1691
|
+
#main { height: calc(100vh - 60px); }
|
|
1692
|
+
#sidebar { width: 320px; }
|
|
1870
1693
|
#chart { flex: 1; position: relative; }
|
|
1871
|
-
#sidebar {
|
|
1872
|
-
width: 320px;
|
|
1873
|
-
background: #16213e;
|
|
1874
|
-
border-left: 1px solid #0f3460;
|
|
1875
|
-
overflow-y: auto;
|
|
1876
|
-
padding: 16px;
|
|
1877
|
-
}
|
|
1878
|
-
#sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
|
|
1879
1694
|
.hotspot-item {
|
|
1880
1695
|
padding: 8px 0;
|
|
1881
1696
|
border-bottom: 1px solid #0f3460;
|
|
@@ -1895,19 +1710,6 @@ function generateHotspotHTML(result) {
|
|
|
1895
1710
|
.risk-high { background: #f07040; color: white; }
|
|
1896
1711
|
.risk-medium { background: #f5a623; color: black; }
|
|
1897
1712
|
.risk-low { background: #27ae60; color: white; }
|
|
1898
|
-
#tooltip {
|
|
1899
|
-
position: absolute;
|
|
1900
|
-
pointer-events: none;
|
|
1901
|
-
background: rgba(22, 33, 62, 0.95);
|
|
1902
|
-
border: 1px solid #0f3460;
|
|
1903
|
-
border-radius: 6px;
|
|
1904
|
-
padding: 10px 14px;
|
|
1905
|
-
font-size: 13px;
|
|
1906
|
-
line-height: 1.6;
|
|
1907
|
-
display: none;
|
|
1908
|
-
z-index: 100;
|
|
1909
|
-
max-width: 350px;
|
|
1910
|
-
}
|
|
1911
1713
|
#zone-labels { position: absolute; pointer-events: none; }
|
|
1912
1714
|
.zone-label {
|
|
1913
1715
|
position: absolute;
|
|
@@ -2104,10 +1906,10 @@ async function generateAndOpenHotspotHTML(result, repoPath) {
|
|
|
2104
1906
|
// src/core/unified.ts
|
|
2105
1907
|
async function computeUnified(options) {
|
|
2106
1908
|
console.log("Computing unified dashboard data...");
|
|
2107
|
-
console.log(" [1/4] Scoring (
|
|
2108
|
-
const [
|
|
2109
|
-
computeFamiliarity({ ...options, mode: "
|
|
2110
|
-
computeFamiliarity({ ...options, mode: "
|
|
1909
|
+
console.log(" [1/4] Scoring (committed, code-coverage, weighted)...");
|
|
1910
|
+
const [committed, codeCoverage, weighted] = await Promise.all([
|
|
1911
|
+
computeFamiliarity({ ...options, mode: "committed" }),
|
|
1912
|
+
computeFamiliarity({ ...options, mode: "code-coverage" }),
|
|
2111
1913
|
computeFamiliarity({ ...options, mode: "weighted" })
|
|
2112
1914
|
]);
|
|
2113
1915
|
console.log(" [2/4] Team coverage...");
|
|
@@ -2138,9 +1940,9 @@ async function computeUnified(options) {
|
|
|
2138
1940
|
});
|
|
2139
1941
|
console.log("Done.");
|
|
2140
1942
|
return {
|
|
2141
|
-
repoName:
|
|
2142
|
-
userName:
|
|
2143
|
-
scoring: {
|
|
1943
|
+
repoName: committed.repoName,
|
|
1944
|
+
userName: committed.userName,
|
|
1945
|
+
scoring: { committed, codeCoverage, weighted },
|
|
2144
1946
|
coverage,
|
|
2145
1947
|
hotspot,
|
|
2146
1948
|
hotspotTeamFamiliarity,
|
|
@@ -2151,35 +1953,10 @@ async function computeUnified(options) {
|
|
|
2151
1953
|
// src/cli/output/unified-html.ts
|
|
2152
1954
|
import { writeFileSync as writeFileSync5 } from "fs";
|
|
2153
1955
|
import { join as join5 } from "path";
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
const coverageTreeJson = JSON.stringify(data.coverage.tree);
|
|
2159
|
-
const coverageRiskJson = JSON.stringify(data.coverage.riskFiles);
|
|
2160
|
-
const hotspotJson = JSON.stringify(
|
|
2161
|
-
data.hotspot.files.filter((f) => f.changeFrequency > 0).map((f) => ({
|
|
2162
|
-
path: f.path,
|
|
2163
|
-
lines: f.lines,
|
|
2164
|
-
familiarity: f.familiarity,
|
|
2165
|
-
changeFrequency: f.changeFrequency,
|
|
2166
|
-
risk: f.risk,
|
|
2167
|
-
riskLevel: f.riskLevel
|
|
2168
|
-
}))
|
|
2169
|
-
);
|
|
2170
|
-
const hotspotTeamFamJson = JSON.stringify(data.hotspotTeamFamiliarity);
|
|
2171
|
-
const multiUserTreeJson = JSON.stringify(data.multiUser.tree);
|
|
2172
|
-
const multiUserSummariesJson = JSON.stringify(data.multiUser.userSummaries);
|
|
2173
|
-
const multiUserNamesJson = JSON.stringify(
|
|
2174
|
-
data.multiUser.users.map((u) => u.name)
|
|
2175
|
-
);
|
|
2176
|
-
return `<!DOCTYPE html>
|
|
2177
|
-
<html lang="en">
|
|
2178
|
-
<head>
|
|
2179
|
-
<meta charset="UTF-8">
|
|
2180
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2181
|
-
<title>GitFamiliar \u2014 ${data.repoName}</title>
|
|
2182
|
-
<style>
|
|
1956
|
+
|
|
1957
|
+
// src/cli/output/unified-html-styles.ts
|
|
1958
|
+
function getUnifiedStyles() {
|
|
1959
|
+
return `
|
|
2183
1960
|
:root {
|
|
2184
1961
|
--bg-base: #1a1a2e;
|
|
2185
1962
|
--bg-panel: #16213e;
|
|
@@ -2500,160 +2277,46 @@ function generateUnifiedHTML(data) {
|
|
|
2500
2277
|
border: 1px solid var(--glass-border);
|
|
2501
2278
|
border-radius: 10px;
|
|
2502
2279
|
padding: 12px 14px;
|
|
2503
|
-
font-size: 12px;
|
|
2504
|
-
display: none;
|
|
2505
|
-
z-index: 50;
|
|
2506
|
-
box-shadow: var(--shadow-md);
|
|
2507
|
-
}
|
|
2508
|
-
.legend.active { display: block; }
|
|
2509
|
-
.legend .gradient-bar {
|
|
2510
|
-
width: 120px;
|
|
2511
|
-
height: 12px;
|
|
2512
|
-
background: linear-gradient(to right, var(--color-critical), var(--color-medium), var(--color-safe));
|
|
2513
|
-
border-radius: 6px;
|
|
2514
|
-
margin: 4px 0;
|
|
2515
|
-
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
|
2516
|
-
}
|
|
2517
|
-
.legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-dim); }
|
|
2518
|
-
.legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
|
2519
|
-
.legend .swatch { width: 14px; height: 14px; border-radius: 4px; box-shadow: var(--shadow-sm); }
|
|
2520
|
-
|
|
2521
|
-
/* Zone labels for hotspot */
|
|
2522
|
-
#zone-labels { position: absolute; pointer-events: none; }
|
|
2523
|
-
.zone-label {
|
|
2524
|
-
position: absolute;
|
|
2525
|
-
font-size: 16px;
|
|
2526
|
-
font-weight: bold;
|
|
2527
|
-
}
|
|
2528
|
-
</style>
|
|
2529
|
-
</head>
|
|
2530
|
-
<body>
|
|
2531
|
-
<div id="header">
|
|
2532
|
-
<h1>GitFamiliar \u2014 ${data.repoName}</h1>
|
|
2533
|
-
<div class="info">${data.userName} | ${data.scoring.binary.totalFiles} files</div>
|
|
2534
|
-
</div>
|
|
2535
|
-
|
|
2536
|
-
<div id="tabs">
|
|
2537
|
-
<div class="tab active" onclick="switchTab('scoring')">Scoring</div>
|
|
2538
|
-
<div class="tab" onclick="switchTab('coverage')">Coverage</div>
|
|
2539
|
-
<div class="tab" onclick="switchTab('multiuser')">Multi-User</div>
|
|
2540
|
-
<div class="tab" onclick="switchTab('hotspots')">Hotspots</div>
|
|
2541
|
-
</div>
|
|
2542
|
-
|
|
2543
|
-
<div id="tab-desc-scoring" class="tab-desc visible">
|
|
2544
|
-
Your personal familiarity with each file, based on Git history. Larger blocks = more lines of code. Color shows how well you know each file.
|
|
2545
|
-
</div>
|
|
2546
|
-
<div id="tab-desc-coverage" class="tab-desc">
|
|
2547
|
-
Team knowledge distribution: how many people have contributed to each file. Low contributor count = high bus factor risk.
|
|
2548
|
-
</div>
|
|
2549
|
-
<div id="tab-desc-multiuser" class="tab-desc">
|
|
2550
|
-
Compare familiarity scores across team members. Select a user to see the codebase colored by their knowledge.
|
|
2551
|
-
</div>
|
|
2552
|
-
<div id="tab-desc-hotspots" class="tab-desc">
|
|
2553
|
-
Files that change frequently but are poorly understood. Top-left = danger zone (high change, low familiarity).
|
|
2554
|
-
</div>
|
|
2555
|
-
|
|
2556
|
-
<div id="scoring-controls" class="visible">
|
|
2557
|
-
<button class="subtab active" onclick="switchScoringMode('binary')">Binary</button>
|
|
2558
|
-
<button class="subtab" onclick="switchScoringMode('authorship')">Authorship</button>
|
|
2559
|
-
<button class="subtab" onclick="switchScoringMode('weighted')">Weighted</button>
|
|
2560
|
-
<div id="weight-controls">
|
|
2561
|
-
<span>Blame:</span>
|
|
2562
|
-
<span class="weight-label" id="blame-label">50%</span>
|
|
2563
|
-
<input type="range" id="blame-slider" min="0" max="100" value="50" oninput="onWeightChange()">
|
|
2564
|
-
<span>Commit:</span>
|
|
2565
|
-
<span class="weight-label" id="commit-label">50%</span>
|
|
2566
|
-
</div>
|
|
2567
|
-
</div>
|
|
2568
|
-
<div id="scoring-mode-desc" class="tab-desc visible" style="padding-top:0">
|
|
2569
|
-
<span id="mode-desc-text">Binary: Have you ever committed to this file? Yes (green) or No (red).</span>
|
|
2570
|
-
</div>
|
|
2571
|
-
|
|
2572
|
-
<div id="multiuser-controls">
|
|
2573
|
-
<label>View as:</label>
|
|
2574
|
-
<select id="userSelect" onchange="onUserChange()"></select>
|
|
2575
|
-
</div>
|
|
2576
|
-
|
|
2577
|
-
<div id="hotspot-controls">
|
|
2578
|
-
<label>Mode:</label>
|
|
2579
|
-
<button class="subtab active" onclick="switchHotspotMode('personal')">Personal</button>
|
|
2580
|
-
<button class="subtab" onclick="switchHotspotMode('team')">Team</button>
|
|
2581
|
-
<span class="sep-v"></span>
|
|
2582
|
-
<label>Scoring:</label>
|
|
2583
|
-
<button class="subtab hs-scoring active" onclick="switchHotspotScoring('binary')">Binary</button>
|
|
2584
|
-
<button class="subtab hs-scoring" onclick="switchHotspotScoring('authorship')">Authorship</button>
|
|
2585
|
-
<button class="subtab hs-scoring" onclick="switchHotspotScoring('weighted')">Weighted</button>
|
|
2586
|
-
</div>
|
|
2587
|
-
|
|
2588
|
-
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
2589
|
-
|
|
2590
|
-
<div id="content-area">
|
|
2591
|
-
<div id="tab-scoring" class="tab-content active"></div>
|
|
2592
|
-
<div id="tab-coverage" class="tab-content with-sidebar">
|
|
2593
|
-
<div class="viz-area" id="coverage-viz"></div>
|
|
2594
|
-
<div class="sidebar" id="coverage-sidebar">
|
|
2595
|
-
<h3>Risk Files (0-1 contributors)</h3>
|
|
2596
|
-
<div id="risk-list"></div>
|
|
2597
|
-
</div>
|
|
2598
|
-
</div>
|
|
2599
|
-
<div id="tab-multiuser" class="tab-content"></div>
|
|
2600
|
-
<div id="tab-hotspots" class="tab-content with-sidebar">
|
|
2601
|
-
<div class="viz-area" id="hotspot-viz">
|
|
2602
|
-
<div id="zone-labels"></div>
|
|
2603
|
-
</div>
|
|
2604
|
-
<div class="sidebar" id="hotspot-sidebar">
|
|
2605
|
-
<h3>Top Hotspots</h3>
|
|
2606
|
-
<div id="hotspot-list"></div>
|
|
2607
|
-
</div>
|
|
2608
|
-
</div>
|
|
2609
|
-
</div>
|
|
2610
|
-
|
|
2611
|
-
<div id="tooltip"></div>
|
|
2612
|
-
|
|
2613
|
-
<!-- Legends -->
|
|
2614
|
-
<div class="legend active" id="legend-scoring">
|
|
2615
|
-
<div>Familiarity</div>
|
|
2616
|
-
<div class="gradient-bar"></div>
|
|
2617
|
-
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2618
|
-
</div>
|
|
2619
|
-
<div class="legend" id="legend-coverage">
|
|
2620
|
-
<div>Contributors</div>
|
|
2621
|
-
<div class="row"><div class="swatch" style="background:var(--color-critical)"></div> 0\u20131 (Risk)</div>
|
|
2622
|
-
<div class="row"><div class="swatch" style="background:var(--color-medium)"></div> 2\u20133 (Moderate)</div>
|
|
2623
|
-
<div class="row"><div class="swatch" style="background:var(--color-safe)"></div> 4+ (Safe)</div>
|
|
2624
|
-
</div>
|
|
2625
|
-
<div class="legend" id="legend-multiuser">
|
|
2626
|
-
<div>Familiarity</div>
|
|
2627
|
-
<div class="gradient-bar"></div>
|
|
2628
|
-
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2629
|
-
</div>
|
|
2280
|
+
font-size: 12px;
|
|
2281
|
+
display: none;
|
|
2282
|
+
z-index: 50;
|
|
2283
|
+
box-shadow: var(--shadow-md);
|
|
2284
|
+
}
|
|
2285
|
+
.legend.active { display: block; }
|
|
2286
|
+
.legend .gradient-bar {
|
|
2287
|
+
width: 120px;
|
|
2288
|
+
height: 12px;
|
|
2289
|
+
background: linear-gradient(to right, var(--color-critical), var(--color-medium), var(--color-safe));
|
|
2290
|
+
border-radius: 6px;
|
|
2291
|
+
margin: 4px 0;
|
|
2292
|
+
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
|
2293
|
+
}
|
|
2294
|
+
.legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-dim); }
|
|
2295
|
+
.legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
|
2296
|
+
.legend .swatch { width: 14px; height: 14px; border-radius: 4px; box-shadow: var(--shadow-sm); }
|
|
2630
2297
|
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
}
|
|
2639
|
-
const coverageData = ${coverageTreeJson};
|
|
2640
|
-
const coverageRiskFiles = ${coverageRiskJson};
|
|
2641
|
-
const hotspotData = ${hotspotJson};
|
|
2642
|
-
const hotspotTeamFamiliarity = ${hotspotTeamFamJson};
|
|
2643
|
-
const multiUserData = ${multiUserTreeJson};
|
|
2644
|
-
const multiUserNames = ${multiUserNamesJson};
|
|
2645
|
-
const multiUserSummaries = ${multiUserSummariesJson};
|
|
2298
|
+
/* Zone labels for hotspot */
|
|
2299
|
+
#zone-labels { position: absolute; pointer-events: none; }
|
|
2300
|
+
.zone-label {
|
|
2301
|
+
position: absolute;
|
|
2302
|
+
font-size: 16px;
|
|
2303
|
+
font-weight: bold;
|
|
2304
|
+
}`;
|
|
2305
|
+
}
|
|
2646
2306
|
|
|
2307
|
+
// src/cli/output/unified-html-scripts.ts
|
|
2308
|
+
function getUnifiedScripts() {
|
|
2309
|
+
return `
|
|
2647
2310
|
// \u2500\u2500 State \u2500\u2500
|
|
2648
2311
|
let activeTab = 'scoring';
|
|
2649
|
-
let scoringMode = '
|
|
2312
|
+
let scoringMode = 'committed';
|
|
2650
2313
|
let blameWeight = 0.5;
|
|
2651
2314
|
let scoringPath = '';
|
|
2652
2315
|
let coveragePath = '';
|
|
2653
2316
|
let multiuserPath = '';
|
|
2654
2317
|
let currentUser = 0;
|
|
2655
2318
|
let hotspotMode = 'personal';
|
|
2656
|
-
let hotspotScoring = '
|
|
2319
|
+
let hotspotScoring = 'committed';
|
|
2657
2320
|
const rendered = { scoring: false, coverage: false, hotspots: false, multiuser: false };
|
|
2658
2321
|
|
|
2659
2322
|
// \u2500\u2500 Hotspot recalculation utilities \u2500\u2500
|
|
@@ -2668,8 +2331,8 @@ function extractFlatScores(node) {
|
|
|
2668
2331
|
}
|
|
2669
2332
|
|
|
2670
2333
|
const personalScores = {
|
|
2671
|
-
|
|
2672
|
-
|
|
2334
|
+
committed: extractFlatScores(scoringData.committed),
|
|
2335
|
+
'code-coverage': extractFlatScores(scoringData['code-coverage']),
|
|
2673
2336
|
weighted: extractFlatScores(scoringData.weighted),
|
|
2674
2337
|
};
|
|
2675
2338
|
|
|
@@ -2691,7 +2354,7 @@ function recalculateHotspotData() {
|
|
|
2691
2354
|
function switchHotspotMode(mode) {
|
|
2692
2355
|
hotspotMode = mode;
|
|
2693
2356
|
document.querySelectorAll('#hotspot-controls .subtab:not(.hs-scoring)').forEach(el => {
|
|
2694
|
-
el.classList.toggle('active', el.
|
|
2357
|
+
el.classList.toggle('active', el.dataset.mode === mode);
|
|
2695
2358
|
});
|
|
2696
2359
|
// Disable scoring buttons in team mode
|
|
2697
2360
|
const isTeam = mode === 'team';
|
|
@@ -2706,7 +2369,7 @@ function switchHotspotScoring(mode) {
|
|
|
2706
2369
|
if (hotspotMode === 'team') return;
|
|
2707
2370
|
hotspotScoring = mode;
|
|
2708
2371
|
document.querySelectorAll('#hotspot-controls .hs-scoring').forEach(el => {
|
|
2709
|
-
el.classList.toggle('active', el.
|
|
2372
|
+
el.classList.toggle('active', el.dataset.mode === mode);
|
|
2710
2373
|
});
|
|
2711
2374
|
renderHotspot();
|
|
2712
2375
|
renderHotspotSidebar();
|
|
@@ -2806,8 +2469,8 @@ function truncateLabel(name, w, h) {
|
|
|
2806
2469
|
|
|
2807
2470
|
// \u2500\u2500 Tab switching \u2500\u2500
|
|
2808
2471
|
const modeDescriptions = {
|
|
2809
|
-
|
|
2810
|
-
|
|
2472
|
+
committed: 'Committed: Have you ever committed to this file? Yes (green) or No (red).',
|
|
2473
|
+
'code-coverage': 'Code Coverage: How much of the current code did you write? Based on git blame line ownership.',
|
|
2811
2474
|
weighted: 'Weighted: Combines blame ownership and commit history with adjustable weights. Use the sliders to tune.',
|
|
2812
2475
|
};
|
|
2813
2476
|
|
|
@@ -2883,7 +2546,7 @@ function switchScoringMode(mode) {
|
|
|
2883
2546
|
scoringPath = '';
|
|
2884
2547
|
updateBreadcrumb('');
|
|
2885
2548
|
document.querySelectorAll('#scoring-controls .subtab').forEach(el => {
|
|
2886
|
-
el.classList.toggle('active', el.
|
|
2549
|
+
el.classList.toggle('active', el.dataset.mode === mode);
|
|
2887
2550
|
});
|
|
2888
2551
|
document.getElementById('weight-controls').classList.toggle('visible', mode === 'weighted');
|
|
2889
2552
|
document.getElementById('mode-desc-text').textContent = modeDescriptions[mode];
|
|
@@ -3314,7 +2977,159 @@ window.addEventListener('resize', () => {
|
|
|
3314
2977
|
else if (activeTab === 'coverage') renderCoverage();
|
|
3315
2978
|
else if (activeTab === 'hotspots') renderHotspot();
|
|
3316
2979
|
else if (activeTab === 'multiuser') renderMultiUser();
|
|
3317
|
-
})
|
|
2980
|
+
});`;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// src/cli/output/unified-html.ts
|
|
2984
|
+
function generateUnifiedHTML(data) {
|
|
2985
|
+
const scoringCommittedJson = JSON.stringify(data.scoring.committed.tree);
|
|
2986
|
+
const scoringCodeCoverageJson = JSON.stringify(
|
|
2987
|
+
data.scoring.codeCoverage.tree
|
|
2988
|
+
);
|
|
2989
|
+
const scoringWeightedJson = JSON.stringify(data.scoring.weighted.tree);
|
|
2990
|
+
const coverageTreeJson = JSON.stringify(data.coverage.tree);
|
|
2991
|
+
const coverageRiskJson = JSON.stringify(data.coverage.riskFiles);
|
|
2992
|
+
const hotspotJson = JSON.stringify(
|
|
2993
|
+
data.hotspot.files.filter((f) => f.changeFrequency > 0).map((f) => ({
|
|
2994
|
+
path: f.path,
|
|
2995
|
+
lines: f.lines,
|
|
2996
|
+
familiarity: f.familiarity,
|
|
2997
|
+
changeFrequency: f.changeFrequency,
|
|
2998
|
+
risk: f.risk,
|
|
2999
|
+
riskLevel: f.riskLevel
|
|
3000
|
+
}))
|
|
3001
|
+
);
|
|
3002
|
+
const hotspotTeamFamJson = JSON.stringify(data.hotspotTeamFamiliarity);
|
|
3003
|
+
const multiUserTreeJson = JSON.stringify(data.multiUser.tree);
|
|
3004
|
+
const multiUserSummariesJson = JSON.stringify(data.multiUser.userSummaries);
|
|
3005
|
+
const multiUserNamesJson = JSON.stringify(
|
|
3006
|
+
data.multiUser.users.map((u) => u.name)
|
|
3007
|
+
);
|
|
3008
|
+
return `<!DOCTYPE html>
|
|
3009
|
+
<html lang="en">
|
|
3010
|
+
<head>
|
|
3011
|
+
<meta charset="UTF-8">
|
|
3012
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3013
|
+
<title>GitFamiliar \u2014 ${data.repoName}</title>
|
|
3014
|
+
<style>${getUnifiedStyles()}</style>
|
|
3015
|
+
</head>
|
|
3016
|
+
<body>
|
|
3017
|
+
<div id="header">
|
|
3018
|
+
<h1>GitFamiliar \u2014 ${data.repoName}</h1>
|
|
3019
|
+
<div class="info">${data.userName} | ${data.scoring.committed.totalFiles} files</div>
|
|
3020
|
+
</div>
|
|
3021
|
+
|
|
3022
|
+
<div id="tabs">
|
|
3023
|
+
<div class="tab active" onclick="switchTab('scoring')">Scoring</div>
|
|
3024
|
+
<div class="tab" onclick="switchTab('coverage')">Contributors</div>
|
|
3025
|
+
<div class="tab" onclick="switchTab('multiuser')">Team</div>
|
|
3026
|
+
<div class="tab" onclick="switchTab('hotspots')">Hotspots</div>
|
|
3027
|
+
</div>
|
|
3028
|
+
|
|
3029
|
+
<div id="tab-desc-scoring" class="tab-desc visible">
|
|
3030
|
+
Your personal familiarity with each file, based on Git history. Larger blocks = more lines of code. Color shows how well you know each file.
|
|
3031
|
+
</div>
|
|
3032
|
+
<div id="tab-desc-coverage" class="tab-desc">
|
|
3033
|
+
Contributors per file: how many people have committed to each file. Low contributor count = high bus factor risk.
|
|
3034
|
+
</div>
|
|
3035
|
+
<div id="tab-desc-multiuser" class="tab-desc">
|
|
3036
|
+
Compare familiarity scores across the team. Select a user to see the codebase colored by their knowledge.
|
|
3037
|
+
</div>
|
|
3038
|
+
<div id="tab-desc-hotspots" class="tab-desc">
|
|
3039
|
+
Files that change frequently but are poorly understood. Top-left = danger zone (high change, low familiarity).
|
|
3040
|
+
</div>
|
|
3041
|
+
|
|
3042
|
+
<div id="scoring-controls" class="visible">
|
|
3043
|
+
<button class="subtab active" data-mode="committed" onclick="switchScoringMode('committed')">Committed</button>
|
|
3044
|
+
<button class="subtab" data-mode="code-coverage" onclick="switchScoringMode('code-coverage')">Code Coverage</button>
|
|
3045
|
+
<button class="subtab" data-mode="weighted" onclick="switchScoringMode('weighted')">Weighted</button>
|
|
3046
|
+
<div id="weight-controls">
|
|
3047
|
+
<span>Blame:</span>
|
|
3048
|
+
<span class="weight-label" id="blame-label">50%</span>
|
|
3049
|
+
<input type="range" id="blame-slider" min="0" max="100" value="50" oninput="onWeightChange()">
|
|
3050
|
+
<span>Commit:</span>
|
|
3051
|
+
<span class="weight-label" id="commit-label">50%</span>
|
|
3052
|
+
</div>
|
|
3053
|
+
</div>
|
|
3054
|
+
<div id="scoring-mode-desc" class="tab-desc visible" style="padding-top:0">
|
|
3055
|
+
<span id="mode-desc-text">Binary: Have you ever committed to this file? Yes (green) or No (red).</span>
|
|
3056
|
+
</div>
|
|
3057
|
+
|
|
3058
|
+
<div id="multiuser-controls">
|
|
3059
|
+
<label>View as:</label>
|
|
3060
|
+
<select id="userSelect" onchange="onUserChange()"></select>
|
|
3061
|
+
</div>
|
|
3062
|
+
|
|
3063
|
+
<div id="hotspot-controls">
|
|
3064
|
+
<label>Mode:</label>
|
|
3065
|
+
<button class="subtab active" data-mode="personal" onclick="switchHotspotMode('personal')">Personal</button>
|
|
3066
|
+
<button class="subtab" data-mode="team" onclick="switchHotspotMode('team')">Team</button>
|
|
3067
|
+
<span class="sep-v"></span>
|
|
3068
|
+
<label>Scoring:</label>
|
|
3069
|
+
<button class="subtab hs-scoring active" data-mode="committed" onclick="switchHotspotScoring('committed')">Committed</button>
|
|
3070
|
+
<button class="subtab hs-scoring" data-mode="code-coverage" onclick="switchHotspotScoring('code-coverage')">Code Coverage</button>
|
|
3071
|
+
<button class="subtab hs-scoring" data-mode="weighted" onclick="switchHotspotScoring('weighted')">Weighted</button>
|
|
3072
|
+
</div>
|
|
3073
|
+
|
|
3074
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
3075
|
+
|
|
3076
|
+
<div id="content-area">
|
|
3077
|
+
<div id="tab-scoring" class="tab-content active"></div>
|
|
3078
|
+
<div id="tab-coverage" class="tab-content with-sidebar">
|
|
3079
|
+
<div class="viz-area" id="coverage-viz"></div>
|
|
3080
|
+
<div class="sidebar" id="coverage-sidebar">
|
|
3081
|
+
<h3>Risk Files (0-1 contributors)</h3>
|
|
3082
|
+
<div id="risk-list"></div>
|
|
3083
|
+
</div>
|
|
3084
|
+
</div>
|
|
3085
|
+
<div id="tab-multiuser" class="tab-content"></div>
|
|
3086
|
+
<div id="tab-hotspots" class="tab-content with-sidebar">
|
|
3087
|
+
<div class="viz-area" id="hotspot-viz">
|
|
3088
|
+
<div id="zone-labels"></div>
|
|
3089
|
+
</div>
|
|
3090
|
+
<div class="sidebar" id="hotspot-sidebar">
|
|
3091
|
+
<h3>Top Hotspots</h3>
|
|
3092
|
+
<div id="hotspot-list"></div>
|
|
3093
|
+
</div>
|
|
3094
|
+
</div>
|
|
3095
|
+
</div>
|
|
3096
|
+
|
|
3097
|
+
<div id="tooltip"></div>
|
|
3098
|
+
|
|
3099
|
+
<!-- Legends -->
|
|
3100
|
+
<div class="legend active" id="legend-scoring">
|
|
3101
|
+
<div>Familiarity</div>
|
|
3102
|
+
<div class="gradient-bar"></div>
|
|
3103
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
3104
|
+
</div>
|
|
3105
|
+
<div class="legend" id="legend-coverage">
|
|
3106
|
+
<div>Contributors</div>
|
|
3107
|
+
<div class="row"><div class="swatch" style="background:var(--color-critical)"></div> 0\u20131 (Risk)</div>
|
|
3108
|
+
<div class="row"><div class="swatch" style="background:var(--color-medium)"></div> 2\u20133 (Moderate)</div>
|
|
3109
|
+
<div class="row"><div class="swatch" style="background:var(--color-safe)"></div> 4+ (Safe)</div>
|
|
3110
|
+
</div>
|
|
3111
|
+
<div class="legend" id="legend-multiuser">
|
|
3112
|
+
<div>Familiarity</div>
|
|
3113
|
+
<div class="gradient-bar"></div>
|
|
3114
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
3115
|
+
</div>
|
|
3116
|
+
|
|
3117
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
3118
|
+
<script>
|
|
3119
|
+
// \u2500\u2500 Data \u2500\u2500
|
|
3120
|
+
const scoringData = {
|
|
3121
|
+
committed: ${scoringCommittedJson},
|
|
3122
|
+
'code-coverage': ${scoringCodeCoverageJson},
|
|
3123
|
+
weighted: ${scoringWeightedJson},
|
|
3124
|
+
};
|
|
3125
|
+
const coverageData = ${coverageTreeJson};
|
|
3126
|
+
const coverageRiskFiles = ${coverageRiskJson};
|
|
3127
|
+
const hotspotData = ${hotspotJson};
|
|
3128
|
+
const hotspotTeamFamiliarity = ${hotspotTeamFamJson};
|
|
3129
|
+
const multiUserData = ${multiUserTreeJson};
|
|
3130
|
+
const multiUserNames = ${multiUserNamesJson};
|
|
3131
|
+
const multiUserSummaries = ${multiUserSummariesJson};
|
|
3132
|
+
${getUnifiedScripts()}
|
|
3318
3133
|
</script>
|
|
3319
3134
|
</body>
|
|
3320
3135
|
</html>`;
|
|
@@ -3327,6 +3142,600 @@ async function generateAndOpenUnifiedHTML(data, repoPath) {
|
|
|
3327
3142
|
await openBrowser(outputPath);
|
|
3328
3143
|
}
|
|
3329
3144
|
|
|
3145
|
+
// src/core/demo.ts
|
|
3146
|
+
var REPO_NAME = "acme-web-app";
|
|
3147
|
+
var ALICE = { name: "Alice Chen", email: "alice@acme.dev" };
|
|
3148
|
+
var BOB = { name: "Bob Kim", email: "bob@acme.dev" };
|
|
3149
|
+
var CHARLIE = {
|
|
3150
|
+
name: "Charlie Rivera",
|
|
3151
|
+
email: "charlie@acme.dev"
|
|
3152
|
+
};
|
|
3153
|
+
var DIANA = { name: "Diana Patel", email: "diana@acme.dev" };
|
|
3154
|
+
var USERS = [ALICE, BOB, CHARLIE, DIANA];
|
|
3155
|
+
var DEMO_FILES = [
|
|
3156
|
+
// Root config files
|
|
3157
|
+
{
|
|
3158
|
+
path: "package.json",
|
|
3159
|
+
lines: 25,
|
|
3160
|
+
familiarity: { alice: 0.6, bob: 0.2, charlie: 0.3, diana: 0.8 },
|
|
3161
|
+
written: { alice: true, bob: false, charlie: false, diana: true },
|
|
3162
|
+
contributors: ["Alice Chen", "Diana Patel"],
|
|
3163
|
+
changeFrequency: 1,
|
|
3164
|
+
lastChanged: "2026-02-01"
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
path: "tsconfig.json",
|
|
3168
|
+
lines: 15,
|
|
3169
|
+
familiarity: { alice: 0.55, bob: 0.1, charlie: 0.25, diana: 0.75 },
|
|
3170
|
+
written: { alice: true, bob: false, charlie: false, diana: true },
|
|
3171
|
+
contributors: ["Alice Chen", "Diana Patel"],
|
|
3172
|
+
changeFrequency: 0,
|
|
3173
|
+
lastChanged: null
|
|
3174
|
+
},
|
|
3175
|
+
{
|
|
3176
|
+
path: "README.md",
|
|
3177
|
+
lines: 80,
|
|
3178
|
+
familiarity: { alice: 0.4, bob: 0.3, charlie: 0.25, diana: 0.2 },
|
|
3179
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3180
|
+
contributors: ["Alice Chen", "Bob Kim", "Charlie Rivera"],
|
|
3181
|
+
changeFrequency: 1,
|
|
3182
|
+
lastChanged: "2026-02-10"
|
|
3183
|
+
},
|
|
3184
|
+
{
|
|
3185
|
+
path: "Dockerfile",
|
|
3186
|
+
lines: 35,
|
|
3187
|
+
familiarity: { alice: 0.3, bob: 0, charlie: 0, diana: 0.9 },
|
|
3188
|
+
written: { alice: false, bob: false, charlie: false, diana: true },
|
|
3189
|
+
contributors: ["Diana Patel"],
|
|
3190
|
+
changeFrequency: 2,
|
|
3191
|
+
lastChanged: "2026-02-20"
|
|
3192
|
+
},
|
|
3193
|
+
// CI/CD
|
|
3194
|
+
{
|
|
3195
|
+
path: ".github/workflows/ci.yml",
|
|
3196
|
+
lines: 65,
|
|
3197
|
+
familiarity: { alice: 0.15, bob: 0, charlie: 0, diana: 0.95 },
|
|
3198
|
+
written: { alice: false, bob: false, charlie: false, diana: true },
|
|
3199
|
+
contributors: ["Diana Patel"],
|
|
3200
|
+
changeFrequency: 3,
|
|
3201
|
+
lastChanged: "2026-02-18"
|
|
3202
|
+
},
|
|
3203
|
+
{
|
|
3204
|
+
path: ".github/workflows/deploy.yml",
|
|
3205
|
+
lines: 45,
|
|
3206
|
+
familiarity: { alice: 0.1, bob: 0, charlie: 0, diana: 0.9 },
|
|
3207
|
+
written: { alice: false, bob: false, charlie: false, diana: true },
|
|
3208
|
+
contributors: ["Diana Patel"],
|
|
3209
|
+
changeFrequency: 2,
|
|
3210
|
+
lastChanged: "2026-02-15"
|
|
3211
|
+
},
|
|
3212
|
+
// src/ core
|
|
3213
|
+
{
|
|
3214
|
+
path: "src/index.ts",
|
|
3215
|
+
lines: 30,
|
|
3216
|
+
familiarity: { alice: 0.85, bob: 0.25, charlie: 0.1, diana: 0.4 },
|
|
3217
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3218
|
+
contributors: ["Alice Chen", "Bob Kim"],
|
|
3219
|
+
changeFrequency: 1,
|
|
3220
|
+
lastChanged: "2026-01-20"
|
|
3221
|
+
},
|
|
3222
|
+
{
|
|
3223
|
+
path: "src/config.ts",
|
|
3224
|
+
lines: 55,
|
|
3225
|
+
familiarity: { alice: 0.75, bob: 0.15, charlie: 0.2, diana: 0.6 },
|
|
3226
|
+
written: { alice: true, bob: true, charlie: true, diana: true },
|
|
3227
|
+
contributors: ["Alice Chen", "Bob Kim", "Charlie Rivera", "Diana Patel"],
|
|
3228
|
+
changeFrequency: 3,
|
|
3229
|
+
lastChanged: "2026-02-22"
|
|
3230
|
+
},
|
|
3231
|
+
{
|
|
3232
|
+
path: "src/app.ts",
|
|
3233
|
+
lines: 120,
|
|
3234
|
+
familiarity: { alice: 0.9, bob: 0.1, charlie: 0.1, diana: 0.45 },
|
|
3235
|
+
written: { alice: true, bob: false, charlie: false, diana: true },
|
|
3236
|
+
contributors: ["Alice Chen", "Diana Patel"],
|
|
3237
|
+
changeFrequency: 2,
|
|
3238
|
+
lastChanged: "2026-02-05"
|
|
3239
|
+
},
|
|
3240
|
+
// src/routes
|
|
3241
|
+
{
|
|
3242
|
+
path: "src/routes/auth.ts",
|
|
3243
|
+
lines: 95,
|
|
3244
|
+
familiarity: { alice: 0.9, bob: 0, charlie: 0, diana: 0.1 },
|
|
3245
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3246
|
+
contributors: ["Alice Chen"],
|
|
3247
|
+
changeFrequency: 3,
|
|
3248
|
+
lastChanged: "2026-02-12"
|
|
3249
|
+
},
|
|
3250
|
+
{
|
|
3251
|
+
path: "src/routes/users.ts",
|
|
3252
|
+
lines: 130,
|
|
3253
|
+
familiarity: { alice: 0.85, bob: 0.2, charlie: 0, diana: 0.1 },
|
|
3254
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3255
|
+
contributors: ["Alice Chen", "Bob Kim"],
|
|
3256
|
+
changeFrequency: 3,
|
|
3257
|
+
lastChanged: "2026-02-14"
|
|
3258
|
+
},
|
|
3259
|
+
{
|
|
3260
|
+
path: "src/routes/products.ts",
|
|
3261
|
+
lines: 180,
|
|
3262
|
+
familiarity: { alice: 0.8, bob: 0.15, charlie: 0, diana: 0.05 },
|
|
3263
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3264
|
+
contributors: ["Alice Chen", "Bob Kim"],
|
|
3265
|
+
changeFrequency: 4,
|
|
3266
|
+
lastChanged: "2026-02-19"
|
|
3267
|
+
},
|
|
3268
|
+
{
|
|
3269
|
+
path: "src/routes/orders.ts",
|
|
3270
|
+
lines: 210,
|
|
3271
|
+
familiarity: { alice: 0.25, bob: 0, charlie: 0, diana: 0.05 },
|
|
3272
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3273
|
+
contributors: ["Alice Chen"],
|
|
3274
|
+
changeFrequency: 12,
|
|
3275
|
+
lastChanged: "2026-02-25"
|
|
3276
|
+
},
|
|
3277
|
+
// src/middleware
|
|
3278
|
+
{
|
|
3279
|
+
path: "src/middleware/auth.ts",
|
|
3280
|
+
lines: 75,
|
|
3281
|
+
familiarity: { alice: 0.85, bob: 0, charlie: 0, diana: 0.15 },
|
|
3282
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3283
|
+
contributors: ["Alice Chen"],
|
|
3284
|
+
changeFrequency: 1,
|
|
3285
|
+
lastChanged: "2026-01-15"
|
|
3286
|
+
},
|
|
3287
|
+
{
|
|
3288
|
+
path: "src/middleware/logging.ts",
|
|
3289
|
+
lines: 50,
|
|
3290
|
+
familiarity: { alice: 0.8, bob: 0, charlie: 0, diana: 0.5 },
|
|
3291
|
+
written: { alice: true, bob: false, charlie: false, diana: true },
|
|
3292
|
+
contributors: ["Alice Chen", "Diana Patel"],
|
|
3293
|
+
changeFrequency: 1,
|
|
3294
|
+
lastChanged: "2026-01-25"
|
|
3295
|
+
},
|
|
3296
|
+
{
|
|
3297
|
+
path: "src/middleware/error-handler.ts",
|
|
3298
|
+
lines: 60,
|
|
3299
|
+
familiarity: { alice: 0.4, bob: 0, charlie: 0, diana: 0.2 },
|
|
3300
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3301
|
+
contributors: ["Alice Chen"],
|
|
3302
|
+
changeFrequency: 6,
|
|
3303
|
+
lastChanged: "2026-02-23"
|
|
3304
|
+
},
|
|
3305
|
+
// src/models
|
|
3306
|
+
{
|
|
3307
|
+
path: "src/models/user.ts",
|
|
3308
|
+
lines: 85,
|
|
3309
|
+
familiarity: { alice: 0.88, bob: 0.2, charlie: 0, diana: 0.1 },
|
|
3310
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3311
|
+
contributors: ["Alice Chen", "Bob Kim"],
|
|
3312
|
+
changeFrequency: 2,
|
|
3313
|
+
lastChanged: "2026-02-08"
|
|
3314
|
+
},
|
|
3315
|
+
{
|
|
3316
|
+
path: "src/models/product.ts",
|
|
3317
|
+
lines: 90,
|
|
3318
|
+
familiarity: { alice: 0.85, bob: 0, charlie: 0, diana: 0.05 },
|
|
3319
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3320
|
+
contributors: ["Alice Chen"],
|
|
3321
|
+
changeFrequency: 1,
|
|
3322
|
+
lastChanged: "2026-01-10"
|
|
3323
|
+
},
|
|
3324
|
+
{
|
|
3325
|
+
path: "src/models/order.ts",
|
|
3326
|
+
lines: 110,
|
|
3327
|
+
familiarity: { alice: 0.7, bob: 0, charlie: 0, diana: 0.05 },
|
|
3328
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3329
|
+
contributors: ["Alice Chen"],
|
|
3330
|
+
changeFrequency: 5,
|
|
3331
|
+
lastChanged: "2026-02-21"
|
|
3332
|
+
},
|
|
3333
|
+
// src/services
|
|
3334
|
+
{
|
|
3335
|
+
path: "src/services/email.ts",
|
|
3336
|
+
lines: 70,
|
|
3337
|
+
familiarity: { alice: 0.8, bob: 0, charlie: 0, diana: 0.15 },
|
|
3338
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3339
|
+
contributors: ["Alice Chen"],
|
|
3340
|
+
changeFrequency: 1,
|
|
3341
|
+
lastChanged: "2026-01-05"
|
|
3342
|
+
},
|
|
3343
|
+
{
|
|
3344
|
+
path: "src/services/payment.ts",
|
|
3345
|
+
lines: 150,
|
|
3346
|
+
familiarity: { alice: 0.5, bob: 0, charlie: 0, diana: 0.35 },
|
|
3347
|
+
written: { alice: true, bob: false, charlie: false, diana: true },
|
|
3348
|
+
contributors: ["Alice Chen", "Diana Patel"],
|
|
3349
|
+
changeFrequency: 9,
|
|
3350
|
+
lastChanged: "2026-02-24"
|
|
3351
|
+
},
|
|
3352
|
+
{
|
|
3353
|
+
path: "src/services/inventory.ts",
|
|
3354
|
+
lines: 95,
|
|
3355
|
+
familiarity: { alice: 0.55, bob: 0, charlie: 0, diana: 0.1 },
|
|
3356
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3357
|
+
contributors: ["Alice Chen"],
|
|
3358
|
+
changeFrequency: 5,
|
|
3359
|
+
lastChanged: "2026-02-17"
|
|
3360
|
+
},
|
|
3361
|
+
// src/utils
|
|
3362
|
+
{
|
|
3363
|
+
path: "src/utils/validators.ts",
|
|
3364
|
+
lines: 65,
|
|
3365
|
+
familiarity: { alice: 0.85, bob: 0.15, charlie: 0.1, diana: 0.2 },
|
|
3366
|
+
written: { alice: true, bob: true, charlie: false, diana: false },
|
|
3367
|
+
contributors: ["Alice Chen", "Bob Kim"],
|
|
3368
|
+
changeFrequency: 1,
|
|
3369
|
+
lastChanged: "2026-01-30"
|
|
3370
|
+
},
|
|
3371
|
+
{
|
|
3372
|
+
path: "src/utils/helpers.ts",
|
|
3373
|
+
lines: 45,
|
|
3374
|
+
familiarity: { alice: 0.8, bob: 0, charlie: 0.05, diana: 0.1 },
|
|
3375
|
+
written: { alice: true, bob: false, charlie: false, diana: false },
|
|
3376
|
+
contributors: ["Alice Chen"],
|
|
3377
|
+
changeFrequency: 0,
|
|
3378
|
+
lastChanged: null
|
|
3379
|
+
},
|
|
3380
|
+
// frontend
|
|
3381
|
+
{
|
|
3382
|
+
path: "frontend/App.tsx",
|
|
3383
|
+
lines: 140,
|
|
3384
|
+
familiarity: { alice: 0.15, bob: 0, charlie: 0.85, diana: 0.05 },
|
|
3385
|
+
written: { alice: true, bob: false, charlie: true, diana: false },
|
|
3386
|
+
contributors: ["Charlie Rivera", "Alice Chen"],
|
|
3387
|
+
changeFrequency: 7,
|
|
3388
|
+
lastChanged: "2026-02-24"
|
|
3389
|
+
},
|
|
3390
|
+
{
|
|
3391
|
+
path: "frontend/index.tsx",
|
|
3392
|
+
lines: 20,
|
|
3393
|
+
familiarity: { alice: 0.1, bob: 0, charlie: 0.8, diana: 0 },
|
|
3394
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3395
|
+
contributors: ["Charlie Rivera"],
|
|
3396
|
+
changeFrequency: 1,
|
|
3397
|
+
lastChanged: "2026-01-12"
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
path: "frontend/components/Header.tsx",
|
|
3401
|
+
lines: 85,
|
|
3402
|
+
familiarity: { alice: 0.1, bob: 0, charlie: 0.9, diana: 0 },
|
|
3403
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3404
|
+
contributors: ["Charlie Rivera"],
|
|
3405
|
+
changeFrequency: 2,
|
|
3406
|
+
lastChanged: "2026-02-03"
|
|
3407
|
+
},
|
|
3408
|
+
{
|
|
3409
|
+
path: "frontend/components/ProductList.tsx",
|
|
3410
|
+
lines: 160,
|
|
3411
|
+
familiarity: { alice: 0.1, bob: 0, charlie: 0.85, diana: 0 },
|
|
3412
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3413
|
+
contributors: ["Charlie Rivera"],
|
|
3414
|
+
changeFrequency: 4,
|
|
3415
|
+
lastChanged: "2026-02-16"
|
|
3416
|
+
},
|
|
3417
|
+
{
|
|
3418
|
+
path: "frontend/components/Cart.tsx",
|
|
3419
|
+
lines: 190,
|
|
3420
|
+
familiarity: { alice: 0.08, bob: 0, charlie: 0.92, diana: 0 },
|
|
3421
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3422
|
+
contributors: ["Charlie Rivera"],
|
|
3423
|
+
changeFrequency: 8,
|
|
3424
|
+
lastChanged: "2026-02-22"
|
|
3425
|
+
},
|
|
3426
|
+
{
|
|
3427
|
+
path: "frontend/components/Checkout.tsx",
|
|
3428
|
+
lines: 220,
|
|
3429
|
+
familiarity: { alice: 0.05, bob: 0, charlie: 0.95, diana: 0 },
|
|
3430
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3431
|
+
contributors: ["Charlie Rivera"],
|
|
3432
|
+
changeFrequency: 10,
|
|
3433
|
+
lastChanged: "2026-02-25"
|
|
3434
|
+
},
|
|
3435
|
+
{
|
|
3436
|
+
path: "frontend/styles/global.css",
|
|
3437
|
+
lines: 120,
|
|
3438
|
+
familiarity: { alice: 0.05, bob: 0, charlie: 0.9, diana: 0 },
|
|
3439
|
+
written: { alice: false, bob: false, charlie: true, diana: false },
|
|
3440
|
+
contributors: ["Charlie Rivera"],
|
|
3441
|
+
changeFrequency: 2,
|
|
3442
|
+
lastChanged: "2026-02-11"
|
|
3443
|
+
}
|
|
3444
|
+
];
|
|
3445
|
+
function buildDemoTree(files, createFolder) {
|
|
3446
|
+
const allDirs = /* @__PURE__ */ new Set();
|
|
3447
|
+
for (const file of files) {
|
|
3448
|
+
const parts = file.path.split("/");
|
|
3449
|
+
for (let i = 1; i < parts.length; i++) {
|
|
3450
|
+
allDirs.add(parts.slice(0, i).join("/"));
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
const dirChildren = /* @__PURE__ */ new Map();
|
|
3454
|
+
for (const file of files) {
|
|
3455
|
+
const lastSlash = file.path.lastIndexOf("/");
|
|
3456
|
+
const parentDir = lastSlash >= 0 ? file.path.substring(0, lastSlash) : "";
|
|
3457
|
+
if (!dirChildren.has(parentDir)) dirChildren.set(parentDir, []);
|
|
3458
|
+
dirChildren.get(parentDir).push(file);
|
|
3459
|
+
}
|
|
3460
|
+
const sortedDirs = [...allDirs].sort(
|
|
3461
|
+
(a, b) => b.split("/").length - a.split("/").length
|
|
3462
|
+
);
|
|
3463
|
+
for (const dir of sortedDirs) {
|
|
3464
|
+
const children = dirChildren.get(dir) || [];
|
|
3465
|
+
const folder = createFolder(dir, children);
|
|
3466
|
+
const lastSlash = dir.lastIndexOf("/");
|
|
3467
|
+
const parentDir = lastSlash >= 0 ? dir.substring(0, lastSlash) : "";
|
|
3468
|
+
if (!dirChildren.has(parentDir)) dirChildren.set(parentDir, []);
|
|
3469
|
+
dirChildren.get(parentDir).push(folder);
|
|
3470
|
+
}
|
|
3471
|
+
const rootChildren = dirChildren.get("") || [];
|
|
3472
|
+
return createFolder("", rootChildren);
|
|
3473
|
+
}
|
|
3474
|
+
function sortChildren(children) {
|
|
3475
|
+
return [...children].sort((a, b) => {
|
|
3476
|
+
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
3477
|
+
return a.path.localeCompare(b.path);
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
function buildScoringTree(files, mode) {
|
|
3481
|
+
return buildDemoTree(files, (path, children) => {
|
|
3482
|
+
let totalLines = 0;
|
|
3483
|
+
let weightedScore = 0;
|
|
3484
|
+
let fileCount = 0;
|
|
3485
|
+
let readCount = 0;
|
|
3486
|
+
for (const child of children) {
|
|
3487
|
+
totalLines += child.lines;
|
|
3488
|
+
weightedScore += child.lines * child.score;
|
|
3489
|
+
if (child.type === "file") {
|
|
3490
|
+
fileCount++;
|
|
3491
|
+
if (child.score > 0) readCount++;
|
|
3492
|
+
} else {
|
|
3493
|
+
fileCount += child.fileCount;
|
|
3494
|
+
readCount += child.readCount || 0;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
const folder = {
|
|
3498
|
+
type: "folder",
|
|
3499
|
+
path,
|
|
3500
|
+
lines: totalLines,
|
|
3501
|
+
score: totalLines > 0 ? weightedScore / totalLines : 0,
|
|
3502
|
+
fileCount,
|
|
3503
|
+
children: sortChildren(children)
|
|
3504
|
+
};
|
|
3505
|
+
if (mode === "committed") folder.readCount = readCount;
|
|
3506
|
+
return folder;
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
function getDemoFamiliarityResult(mode) {
|
|
3510
|
+
const m = mode || "committed";
|
|
3511
|
+
const files = DEMO_FILES.map((file) => {
|
|
3512
|
+
const base = {
|
|
3513
|
+
type: "file",
|
|
3514
|
+
path: file.path,
|
|
3515
|
+
lines: file.lines,
|
|
3516
|
+
score: 0
|
|
3517
|
+
};
|
|
3518
|
+
switch (m) {
|
|
3519
|
+
case "committed":
|
|
3520
|
+
base.score = file.written.alice ? 1 : 0;
|
|
3521
|
+
base.isWritten = file.written.alice;
|
|
3522
|
+
break;
|
|
3523
|
+
case "code-coverage":
|
|
3524
|
+
base.score = file.familiarity.alice;
|
|
3525
|
+
break;
|
|
3526
|
+
case "weighted":
|
|
3527
|
+
base.blameScore = file.familiarity.alice;
|
|
3528
|
+
base.commitScore = Math.min(
|
|
3529
|
+
1,
|
|
3530
|
+
file.written.alice ? file.familiarity.alice * 0.9 + 0.1 : 0
|
|
3531
|
+
);
|
|
3532
|
+
base.score = 0.5 * base.blameScore + 0.5 * base.commitScore;
|
|
3533
|
+
break;
|
|
3534
|
+
}
|
|
3535
|
+
return base;
|
|
3536
|
+
});
|
|
3537
|
+
const tree = buildScoringTree(files, m);
|
|
3538
|
+
const writtenCount = DEMO_FILES.filter((file) => file.written.alice).length;
|
|
3539
|
+
return {
|
|
3540
|
+
tree,
|
|
3541
|
+
repoName: REPO_NAME,
|
|
3542
|
+
userName: ALICE.name,
|
|
3543
|
+
mode: m,
|
|
3544
|
+
writtenCount,
|
|
3545
|
+
totalFiles: DEMO_FILES.length
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
function getDemoHotspotResult() {
|
|
3549
|
+
const maxFreq = Math.max(...DEMO_FILES.map((file) => file.changeFrequency));
|
|
3550
|
+
const files = DEMO_FILES.map((file) => {
|
|
3551
|
+
const normalizedFreq = maxFreq > 0 ? file.changeFrequency / maxFreq : 0;
|
|
3552
|
+
const familiarity = file.familiarity.alice;
|
|
3553
|
+
const risk = normalizedFreq * (1 - familiarity);
|
|
3554
|
+
return {
|
|
3555
|
+
path: file.path,
|
|
3556
|
+
lines: file.lines,
|
|
3557
|
+
familiarity,
|
|
3558
|
+
changeFrequency: file.changeFrequency,
|
|
3559
|
+
lastChanged: file.lastChanged ? new Date(file.lastChanged) : null,
|
|
3560
|
+
risk,
|
|
3561
|
+
riskLevel: classifyHotspotRisk(risk)
|
|
3562
|
+
};
|
|
3563
|
+
}).sort((a, b) => b.risk - a.risk);
|
|
3564
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
3565
|
+
for (const hotspot of files) summary[hotspot.riskLevel]++;
|
|
3566
|
+
return {
|
|
3567
|
+
files,
|
|
3568
|
+
repoName: REPO_NAME,
|
|
3569
|
+
userName: ALICE.name,
|
|
3570
|
+
hotspotMode: "personal",
|
|
3571
|
+
timeWindow: DEFAULT_HOTSPOT_WINDOW,
|
|
3572
|
+
summary
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
function buildCoverageTree2(files) {
|
|
3576
|
+
return buildDemoTree(
|
|
3577
|
+
files,
|
|
3578
|
+
(path, children) => {
|
|
3579
|
+
let totalLines = 0;
|
|
3580
|
+
let fileCount = 0;
|
|
3581
|
+
let totalContributors = 0;
|
|
3582
|
+
for (const child of children) {
|
|
3583
|
+
totalLines += child.lines;
|
|
3584
|
+
if (child.type === "file") {
|
|
3585
|
+
fileCount++;
|
|
3586
|
+
totalContributors += child.contributorCount;
|
|
3587
|
+
} else {
|
|
3588
|
+
const folderChild = child;
|
|
3589
|
+
fileCount += folderChild.fileCount;
|
|
3590
|
+
totalContributors += folderChild.avgContributors * folderChild.fileCount;
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
const avgContributors = fileCount > 0 ? Math.round(totalContributors / fileCount * 10) / 10 : 0;
|
|
3594
|
+
const busFactor = avgContributors >= 4 ? 3 : avgContributors >= 2 ? 2 : 1;
|
|
3595
|
+
return {
|
|
3596
|
+
type: "folder",
|
|
3597
|
+
path,
|
|
3598
|
+
lines: totalLines,
|
|
3599
|
+
fileCount,
|
|
3600
|
+
avgContributors,
|
|
3601
|
+
busFactor,
|
|
3602
|
+
riskLevel: busFactor <= 1 ? "risk" : busFactor <= 2 ? "moderate" : "safe",
|
|
3603
|
+
children: sortChildren(children)
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
);
|
|
3607
|
+
}
|
|
3608
|
+
function getDemoCoverageResult() {
|
|
3609
|
+
const coverageFiles = DEMO_FILES.map((file) => ({
|
|
3610
|
+
type: "file",
|
|
3611
|
+
path: file.path,
|
|
3612
|
+
lines: file.lines,
|
|
3613
|
+
contributorCount: file.contributors.length,
|
|
3614
|
+
contributors: file.contributors,
|
|
3615
|
+
riskLevel: classifyCoverageRisk(file.contributors.length)
|
|
3616
|
+
}));
|
|
3617
|
+
const tree = buildCoverageTree2(coverageFiles);
|
|
3618
|
+
const riskFiles = coverageFiles.filter((file) => file.contributorCount <= 1).sort((a, b) => a.contributorCount - b.contributorCount);
|
|
3619
|
+
return {
|
|
3620
|
+
tree,
|
|
3621
|
+
repoName: REPO_NAME,
|
|
3622
|
+
totalContributors: USERS.length,
|
|
3623
|
+
totalFiles: DEMO_FILES.length,
|
|
3624
|
+
riskFiles,
|
|
3625
|
+
overallBusFactor: 1
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
function buildMultiUserTree(files) {
|
|
3629
|
+
return buildDemoTree(
|
|
3630
|
+
files,
|
|
3631
|
+
(path, children) => {
|
|
3632
|
+
let totalLines = 0;
|
|
3633
|
+
let fileCount = 0;
|
|
3634
|
+
const userTotals = USERS.map(() => 0);
|
|
3635
|
+
for (const child of children) {
|
|
3636
|
+
totalLines += child.lines;
|
|
3637
|
+
if (child.type === "file") {
|
|
3638
|
+
fileCount++;
|
|
3639
|
+
const fileNode = child;
|
|
3640
|
+
fileNode.userScores.forEach((userScore, i) => {
|
|
3641
|
+
userTotals[i] += userScore.score * fileNode.lines;
|
|
3642
|
+
});
|
|
3643
|
+
} else {
|
|
3644
|
+
const folderNode = child;
|
|
3645
|
+
fileCount += folderNode.fileCount;
|
|
3646
|
+
folderNode.userScores.forEach((userScore, i) => {
|
|
3647
|
+
userTotals[i] += userScore.score * folderNode.lines;
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
const userScores = USERS.map((user, i) => ({
|
|
3652
|
+
user,
|
|
3653
|
+
score: totalLines > 0 ? userTotals[i] / totalLines : 0
|
|
3654
|
+
}));
|
|
3655
|
+
const avgScore = userScores.reduce((sum, entry) => sum + entry.score, 0) / userScores.length;
|
|
3656
|
+
return {
|
|
3657
|
+
type: "folder",
|
|
3658
|
+
path,
|
|
3659
|
+
lines: totalLines,
|
|
3660
|
+
score: avgScore,
|
|
3661
|
+
fileCount,
|
|
3662
|
+
userScores,
|
|
3663
|
+
children: sortChildren(children)
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3668
|
+
function getDemoMultiUserResult() {
|
|
3669
|
+
const files = DEMO_FILES.map((file) => {
|
|
3670
|
+
const scores = [
|
|
3671
|
+
file.familiarity.alice,
|
|
3672
|
+
file.familiarity.bob,
|
|
3673
|
+
file.familiarity.charlie,
|
|
3674
|
+
file.familiarity.diana
|
|
3675
|
+
];
|
|
3676
|
+
const userScores = USERS.map((user, i) => ({
|
|
3677
|
+
user,
|
|
3678
|
+
score: scores[i],
|
|
3679
|
+
isWritten: [
|
|
3680
|
+
file.written.alice,
|
|
3681
|
+
file.written.bob,
|
|
3682
|
+
file.written.charlie,
|
|
3683
|
+
file.written.diana
|
|
3684
|
+
][i]
|
|
3685
|
+
}));
|
|
3686
|
+
return {
|
|
3687
|
+
type: "file",
|
|
3688
|
+
path: file.path,
|
|
3689
|
+
lines: file.lines,
|
|
3690
|
+
score: scores.reduce((a, b) => a + b, 0) / scores.length,
|
|
3691
|
+
userScores
|
|
3692
|
+
};
|
|
3693
|
+
});
|
|
3694
|
+
const tree = buildMultiUserTree(files);
|
|
3695
|
+
const userSummaries = USERS.map((user, i) => {
|
|
3696
|
+
const key = ["alice", "bob", "charlie", "diana"][i];
|
|
3697
|
+
const writtenCount = DEMO_FILES.filter((file) => file.written[key]).length;
|
|
3698
|
+
const totalLines = DEMO_FILES.reduce((sum, file) => sum + file.lines, 0);
|
|
3699
|
+
const weightedScore = DEMO_FILES.reduce(
|
|
3700
|
+
(sum, file) => sum + file.familiarity[key] * file.lines,
|
|
3701
|
+
0
|
|
3702
|
+
);
|
|
3703
|
+
return {
|
|
3704
|
+
user,
|
|
3705
|
+
writtenCount,
|
|
3706
|
+
overallScore: totalLines > 0 ? weightedScore / totalLines : 0
|
|
3707
|
+
};
|
|
3708
|
+
});
|
|
3709
|
+
return {
|
|
3710
|
+
tree,
|
|
3711
|
+
repoName: REPO_NAME,
|
|
3712
|
+
users: USERS,
|
|
3713
|
+
mode: "committed",
|
|
3714
|
+
totalFiles: DEMO_FILES.length,
|
|
3715
|
+
userSummaries
|
|
3716
|
+
};
|
|
3717
|
+
}
|
|
3718
|
+
function getDemoUnifiedData() {
|
|
3719
|
+
const hotspotTeamFamiliarity = {};
|
|
3720
|
+
for (const file of DEMO_FILES) {
|
|
3721
|
+
const avg = (file.familiarity.alice + file.familiarity.bob + file.familiarity.charlie + file.familiarity.diana) / 4;
|
|
3722
|
+
hotspotTeamFamiliarity[file.path] = avg;
|
|
3723
|
+
}
|
|
3724
|
+
return {
|
|
3725
|
+
repoName: REPO_NAME,
|
|
3726
|
+
userName: ALICE.name,
|
|
3727
|
+
scoring: {
|
|
3728
|
+
committed: getDemoFamiliarityResult("committed"),
|
|
3729
|
+
codeCoverage: getDemoFamiliarityResult("code-coverage"),
|
|
3730
|
+
weighted: getDemoFamiliarityResult("weighted")
|
|
3731
|
+
},
|
|
3732
|
+
coverage: getDemoCoverageResult(),
|
|
3733
|
+
hotspot: getDemoHotspotResult(),
|
|
3734
|
+
hotspotTeamFamiliarity,
|
|
3735
|
+
multiUser: getDemoMultiUserResult()
|
|
3736
|
+
};
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3330
3739
|
// src/cli/index.ts
|
|
3331
3740
|
var require2 = createRequire(import.meta.url);
|
|
3332
3741
|
var pkg = require2("../../package.json");
|
|
@@ -3337,8 +3746,8 @@ function createProgram() {
|
|
|
3337
3746
|
const program2 = new Command();
|
|
3338
3747
|
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version(pkg.version).option(
|
|
3339
3748
|
"-m, --mode <mode>",
|
|
3340
|
-
"Scoring mode:
|
|
3341
|
-
"
|
|
3749
|
+
"Scoring mode: committed, code-coverage, weighted",
|
|
3750
|
+
"committed"
|
|
3342
3751
|
).option(
|
|
3343
3752
|
"-u, --user <user>",
|
|
3344
3753
|
"Git user name or email (repeatable for comparison)",
|
|
@@ -3352,55 +3761,46 @@ function createProgram() {
|
|
|
3352
3761
|
"-w, --weights <weights>",
|
|
3353
3762
|
'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
|
|
3354
3763
|
).option("--team", "Compare all contributors", false).option(
|
|
3355
|
-
"--
|
|
3356
|
-
"
|
|
3764
|
+
"--contributors-per-file",
|
|
3765
|
+
"Analyze number of contributors per file (bus factor)",
|
|
3357
3766
|
false
|
|
3358
|
-
).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
|
|
3359
|
-
"--window <days>",
|
|
3360
|
-
"Time window for hotspot analysis in days (default: 90)"
|
|
3361
|
-
).action(async (rawOptions) => {
|
|
3767
|
+
).option("--contributors", "Alias for --contributors-per-file").option("--team-coverage", "Deprecated alias for --contributors-per-file").option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option("--since <days>", "Hotspot analysis period in days (default: 90)").option("--window <days>", "Deprecated alias for --since").option("--demo", "Show demo with sample data (no git repo needed)", false).action(async (rawOptions) => {
|
|
3362
3768
|
try {
|
|
3363
3769
|
const repoPath = process.cwd();
|
|
3364
3770
|
const options = parseOptions(rawOptions, repoPath);
|
|
3365
|
-
const
|
|
3366
|
-
|
|
3367
|
-
|
|
3771
|
+
const isDemo = options.demo;
|
|
3772
|
+
const isMultiUser = options.team || Array.isArray(options.user) && options.user.length > 1;
|
|
3773
|
+
if (options.html && !options.hotspot && !options.contributorsPerFile && !isMultiUser) {
|
|
3774
|
+
const data = isDemo ? getDemoUnifiedData() : await computeUnified(options);
|
|
3368
3775
|
await generateAndOpenUnifiedHTML(data, repoPath);
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
if (options.hotspot) {
|
|
3372
|
-
const result2 = await computeHotspots(options);
|
|
3776
|
+
} else if (options.hotspot) {
|
|
3777
|
+
const result = isDemo ? getDemoHotspotResult() : await computeHotspots(options);
|
|
3373
3778
|
if (options.html) {
|
|
3374
|
-
await generateAndOpenHotspotHTML(
|
|
3779
|
+
await generateAndOpenHotspotHTML(result, repoPath);
|
|
3375
3780
|
} else {
|
|
3376
|
-
renderHotspotTerminal(
|
|
3781
|
+
renderHotspotTerminal(result);
|
|
3377
3782
|
}
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
if (options.teamCoverage) {
|
|
3381
|
-
const result2 = await computeTeamCoverage(options);
|
|
3783
|
+
} else if (options.contributorsPerFile) {
|
|
3784
|
+
const result = isDemo ? getDemoCoverageResult() : await computeTeamCoverage(options);
|
|
3382
3785
|
if (options.html) {
|
|
3383
|
-
await generateAndOpenCoverageHTML(
|
|
3786
|
+
await generateAndOpenCoverageHTML(result, repoPath);
|
|
3384
3787
|
} else {
|
|
3385
|
-
renderCoverageTerminal(
|
|
3788
|
+
renderCoverageTerminal(result);
|
|
3386
3789
|
}
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
const isMultiUser = options.team || Array.isArray(options.user) && options.user.length > 1;
|
|
3390
|
-
if (isMultiUser) {
|
|
3391
|
-
const result2 = await computeMultiUser(options);
|
|
3790
|
+
} else if (isMultiUser) {
|
|
3791
|
+
const result = isDemo ? getDemoMultiUserResult() : await computeMultiUser(options);
|
|
3392
3792
|
if (options.html) {
|
|
3393
|
-
await generateAndOpenMultiUserHTML(
|
|
3793
|
+
await generateAndOpenMultiUserHTML(result, repoPath);
|
|
3394
3794
|
} else {
|
|
3395
|
-
renderMultiUserTerminal(
|
|
3795
|
+
renderMultiUserTerminal(result);
|
|
3396
3796
|
}
|
|
3397
|
-
return;
|
|
3398
|
-
}
|
|
3399
|
-
const result = await computeFamiliarity(options);
|
|
3400
|
-
if (options.html) {
|
|
3401
|
-
await generateAndOpenHTML(result, repoPath);
|
|
3402
3797
|
} else {
|
|
3403
|
-
|
|
3798
|
+
const result = isDemo ? getDemoFamiliarityResult(options.mode) : await computeFamiliarity(options);
|
|
3799
|
+
if (options.html) {
|
|
3800
|
+
await generateAndOpenHTML(result, repoPath);
|
|
3801
|
+
} else {
|
|
3802
|
+
renderTerminal(result);
|
|
3803
|
+
}
|
|
3404
3804
|
}
|
|
3405
3805
|
} catch (error) {
|
|
3406
3806
|
console.error(`Error: ${error.message}`);
|