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.
@@ -8,7 +8,7 @@ import {
8
8
  processBatch,
9
9
  resolveUser,
10
10
  walkFiles
11
- } from "../chunk-NGCQUB2H.js";
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 || "binary");
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 windowDays = raw.window ? parseInt(raw.window, 10) : void 0;
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
- teamCoverage: raw.teamCoverage || false,
71
+ contributorsPerFile: raw.contributorsPerFile || raw.contributors || raw.teamCoverage || false,
58
72
  hotspot,
59
- window: windowDays
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
- const valid = ["binary", "authorship", "weighted"];
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 * BAR_WIDTH);
90
- const empty = BAR_WIDTH - filled;
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 "binary":
103
- return "Binary mode";
104
- case "authorship":
105
- return "Authorship mode";
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 === "binary") {
158
+ if (mode === "committed") {
133
159
  const readCount = folder.readCount || 0;
134
160
  lines.push(
135
- `${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
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}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
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
- chalk.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`)
179
+ chalk2.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`)
154
180
  );
155
181
  console.log("");
156
- if (mode === "binary") {
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 === "binary") {
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 generateTreemapHTML(result) {
194
- const dataJson = JSON.stringify(result.tree);
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: 300px;
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
- function getNodeScore(node) {
298
- return node.score;
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(getNodeScore(d.data.data));
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 = getNodeScore(data);
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(gitClient, trackedFiles);
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: classifyRisk(names.length)
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({ type: "folder", path: "", lines: 0, fileCount: 0, avgContributors: 0, busFactor: 0, riskLevel: "safe", children }, (f) => {
593
- fileScores.push(f);
594
- });
595
- const totalContributors = fileScores.reduce((sum, f) => sum + f.contributorCount, 0);
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: classifyRisk(busFactor),
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 * 0.5);
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 chalk2 from "chalk";
727
+ import chalk3 from "chalk";
665
728
  function riskBadge(level) {
666
729
  switch (level) {
667
730
  case "risk":
668
- return chalk2.bgRed.white(" RISK ");
731
+ return chalk3.bgRed.white(" RISK ");
669
732
  case "moderate":
670
- return chalk2.bgYellow.black(" MOD ");
733
+ return chalk3.bgYellow.black(" MOD ");
671
734
  case "safe":
672
- return chalk2.bgGreen.black(" SAFE ");
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 chalk2.red;
743
+ return chalk3.red;
681
744
  case "moderate":
682
- return chalk2.yellow;
745
+ return chalk3.yellow;
683
746
  default:
684
- return chalk2.green;
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}${chalk2.bold(name.padEnd(24))} ${String(child.avgContributors).padStart(4)} avg ${String(child.busFactor).padStart(2)} ${riskBadge(child.riskLevel)}`
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
- chalk2.bold(
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 ? chalk2.red : result.overallBusFactor <= 2 ? chalk2.yellow : chalk2.green;
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(chalk2.red.bold(`Risk Files (0-1 contributors):`));
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 ? chalk2.red("0 people") : chalk2.yellow(`1 person (${names})`);
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
- chalk2.gray(` ... and ${result.riskFiles.length - 20} more`)
793
+ chalk3.gray(` ... and ${result.riskFiles.length - 20} more`)
731
794
  );
732
795
  }
733
796
  console.log("");
734
797
  } else {
735
- console.log(chalk2.green("No high-risk files found."));
798
+ console.log(chalk3.green("No high-risk files found."));
736
799
  console.log("");
737
800
  }
738
- console.log(chalk2.bold("Folder Coverage:"));
801
+ console.log(chalk3.bold("Folder Coverage:"));
739
802
  console.log(
740
- chalk2.gray(
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
- * { margin: 0; padding: 0; box-sizing: border-box; }
765
- body {
766
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
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
- #tooltip {
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
- function findNode(node, path) {
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
- teamCoverage: false
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 chalk3 from "chalk";
1195
- var BAR_WIDTH2 = 20;
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) => formatPercent2(s.score).padStart(5)).join(" ");
1238
- lines.push(`${prefix}${chalk3.bold(displayName)} ${scores}`);
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
- chalk3.bold(
1251
- `GitFamiliar \u2014 ${repoName} (${getModeLabel2(mode)}, ${userSummaries.length} users)`
1198
+ chalk4.bold(
1199
+ `GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)}, ${userSummaries.length} users)`
1252
1200
  )
1253
1201
  );
1254
1202
  console.log("");
1255
- console.log(chalk3.bold("Overall:"));
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 = makeBar2(summary.overallScore);
1259
- const pct = formatPercent2(summary.overallScore);
1260
- if (mode === "binary") {
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(chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames);
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
- * { margin: 0; padding: 0; box-sizing: border-box; }
1294
- body {
1295
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1296
- background: #1a1a2e;
1297
- color: #e0e0e0;
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
- border-radius: 6px;
1335
- padding: 10px 14px;
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
- #legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
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
- function scoreColor(score) {
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
- function findNode(node, path) {
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.window || DEFAULT_WINDOW;
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
- teamCoverage: false
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 chalk4 from "chalk";
1563
+ import chalk5 from "chalk";
1727
1564
  function riskBadge2(level) {
1728
1565
  switch (level) {
1729
1566
  case "critical":
1730
- return chalk4.bgRed.white.bold(" CRIT ");
1567
+ return chalk5.bgRed.white.bold(" CRIT ");
1731
1568
  case "high":
1732
- return chalk4.bgRedBright.white(" HIGH ");
1569
+ return chalk5.bgRedBright.white(" HIGH ");
1733
1570
  case "medium":
1734
- return chalk4.bgYellow.black(" MED ");
1571
+ return chalk5.bgYellow.black(" MED ");
1735
1572
  case "low":
1736
- return chalk4.bgGreen.black(" LOW ");
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 chalk4.red;
1579
+ return chalk5.red;
1743
1580
  case "high":
1744
- return chalk4.redBright;
1581
+ return chalk5.redBright;
1745
1582
  case "medium":
1746
- return chalk4.yellow;
1583
+ return chalk5.yellow;
1747
1584
  case "low":
1748
- return chalk4.green;
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
- chalk4.bold(`GitFamiliar \u2014 ${modeLabel}${userLabel} \u2014 ${repoName}`)
1594
+ chalk5.bold(`GitFamiliar \u2014 ${modeLabel}${userLabel} \u2014 ${repoName}`)
1758
1595
  );
1759
- console.log(chalk4.gray(` Time window: last ${timeWindow} days`));
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(chalk4.gray(" No files changed in the time window."));
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
- chalk4.gray(
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(chalk4.gray(" " + "\u2500".repeat(90)));
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
- chalk4.gray(` ... and ${activeFiles.length - displayCount} more files`)
1627
+ chalk5.gray(` ... and ${activeFiles.length - displayCount} more files`)
1791
1628
  );
1792
1629
  }
1793
1630
  console.log("");
1794
- console.log(chalk4.bold("Summary:"));
1631
+ console.log(chalk5.bold("Summary:"));
1795
1632
  if (summary.critical > 0) {
1796
1633
  console.log(
1797
- ` ${chalk4.red.bold(`\u{1F534} Critical Risk: ${summary.critical} files`)}`
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
- ` ${chalk4.redBright(`\u{1F7E0} High Risk: ${summary.high} files`)}`
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
- ` ${chalk4.yellow(`\u{1F7E1} Medium Risk: ${summary.medium} files`)}`
1644
+ ` ${chalk5.yellow(`\u{1F7E1} Medium Risk: ${summary.medium} files`)}`
1808
1645
  );
1809
1646
  }
1810
1647
  console.log(
1811
- ` ${chalk4.green(`\u{1F7E2} Low Risk: ${summary.low} files`)}`
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
- chalk4.gray(
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
- * { margin: 0; padding: 0; box-sizing: border-box; }
1853
- body {
1854
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1855
- background: #1a1a2e;
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 (binary, authorship, weighted)...");
2108
- const [binary, authorship, weighted] = await Promise.all([
2109
- computeFamiliarity({ ...options, mode: "binary" }),
2110
- computeFamiliarity({ ...options, mode: "authorship" }),
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: binary.repoName,
2142
- userName: binary.userName,
2143
- scoring: { binary, authorship, weighted },
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
- function generateUnifiedHTML(data) {
2155
- const scoringBinaryJson = JSON.stringify(data.scoring.binary.tree);
2156
- const scoringAuthorshipJson = JSON.stringify(data.scoring.authorship.tree);
2157
- const scoringWeightedJson = JSON.stringify(data.scoring.weighted.tree);
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
- <script src="https://d3js.org/d3.v7.min.js"></script>
2632
- <script>
2633
- // \u2500\u2500 Data \u2500\u2500
2634
- const scoringData = {
2635
- binary: ${scoringBinaryJson},
2636
- authorship: ${scoringAuthorshipJson},
2637
- weighted: ${scoringWeightedJson},
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 = 'binary';
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 = 'binary';
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
- binary: extractFlatScores(scoringData.binary),
2672
- authorship: extractFlatScores(scoringData.authorship),
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.textContent.toLowerCase() === mode);
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.textContent.toLowerCase() === mode);
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
- binary: 'Binary: Have you ever committed to this file? Yes (green) or No (red).',
2810
- authorship: 'Authorship: How much of the current code did you write? Based on git blame line ownership.',
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.textContent.toLowerCase() === mode);
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: binary, authorship, weighted",
3341
- "binary"
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
- "--team-coverage",
3356
- "Show team coverage map (bus factor analysis)",
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 isMultiUserCheck = options.team || Array.isArray(options.user) && options.user.length > 1;
3366
- if (options.html && !options.hotspot && !options.teamCoverage && !isMultiUserCheck) {
3367
- const data = await computeUnified(options);
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
- return;
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(result2, repoPath);
3779
+ await generateAndOpenHotspotHTML(result, repoPath);
3375
3780
  } else {
3376
- renderHotspotTerminal(result2);
3781
+ renderHotspotTerminal(result);
3377
3782
  }
3378
- return;
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(result2, repoPath);
3786
+ await generateAndOpenCoverageHTML(result, repoPath);
3384
3787
  } else {
3385
- renderCoverageTerminal(result2);
3788
+ renderCoverageTerminal(result);
3386
3789
  }
3387
- return;
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(result2, repoPath);
3793
+ await generateAndOpenMultiUserHTML(result, repoPath);
3394
3794
  } else {
3395
- renderMultiUserTerminal(result2);
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
- renderTerminal(result);
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}`);