gitfamiliar 0.5.0 → 0.8.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.
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  GitClient,
4
- GitHubClient,
5
4
  buildFileTree,
6
5
  computeFamiliarity,
7
6
  createFilter,
8
7
  parseExpirationConfig,
9
8
  processBatch,
10
- resolveGitHubToken,
11
9
  resolveUser,
12
10
  walkFiles
13
- } from "../chunk-LZ67KNHF.js";
11
+ } from "../chunk-R5MGQGFI.js";
14
12
 
15
13
  // src/cli/index.ts
16
14
  import { Command } from "commander";
@@ -18,8 +16,7 @@ import { Command } from "commander";
18
16
  // src/core/types.ts
19
17
  var DEFAULT_WEIGHTS = {
20
18
  blame: 0.5,
21
- commit: 0.35,
22
- review: 0.15
19
+ commit: 0.5
23
20
  };
24
21
  var DEFAULT_EXPIRATION = {
25
22
  policy: "never"
@@ -28,7 +25,6 @@ var DEFAULT_EXPIRATION = {
28
25
  // src/cli/options.ts
29
26
  function parseOptions(raw, repoPath) {
30
27
  const mode = validateMode(raw.mode || "binary");
31
- const filter = validateFilter(raw.filter || "all");
32
28
  let weights = DEFAULT_WEIGHTS;
33
29
  if (raw.weights) {
34
30
  weights = parseWeights(raw.weights);
@@ -52,7 +48,6 @@ function parseOptions(raw, repoPath) {
52
48
  return {
53
49
  mode,
54
50
  user,
55
- filter,
56
51
  expiration,
57
52
  html: raw.html || false,
58
53
  weights,
@@ -60,18 +55,11 @@ function parseOptions(raw, repoPath) {
60
55
  team: raw.team || false,
61
56
  teamCoverage: raw.teamCoverage || false,
62
57
  hotspot,
63
- window: windowDays,
64
- githubUrl: raw.githubUrl,
65
- checkGithub: raw.checkGithub || false
58
+ window: windowDays
66
59
  };
67
60
  }
68
61
  function validateMode(mode) {
69
- const valid = [
70
- "binary",
71
- "authorship",
72
- "review-coverage",
73
- "weighted"
74
- ];
62
+ const valid = ["binary", "authorship", "weighted"];
75
63
  if (!valid.includes(mode)) {
76
64
  throw new Error(
77
65
  `Invalid mode: "${mode}". Valid modes: ${valid.join(", ")}`
@@ -79,27 +67,16 @@ function validateMode(mode) {
79
67
  }
80
68
  return mode;
81
69
  }
82
- function validateFilter(filter) {
83
- const valid = ["all", "written", "reviewed"];
84
- if (!valid.includes(filter)) {
85
- throw new Error(
86
- `Invalid filter: "${filter}". Valid filters: ${valid.join(", ")}`
87
- );
88
- }
89
- return filter;
90
- }
91
70
  function parseWeights(s) {
92
71
  const parts = s.split(",").map(Number);
93
- if (parts.length !== 3 || parts.some(isNaN)) {
94
- throw new Error(
95
- `Invalid weights: "${s}". Expected format: "0.5,0.35,0.15"`
96
- );
72
+ if (parts.length !== 2 || parts.some(isNaN)) {
73
+ throw new Error(`Invalid weights: "${s}". Expected format: "0.5,0.5"`);
97
74
  }
98
- const sum = parts[0] + parts[1] + parts[2];
75
+ const sum = parts[0] + parts[1];
99
76
  if (Math.abs(sum - 1) > 0.01) {
100
77
  throw new Error(`Weights must sum to 1.0, got ${sum}`);
101
78
  }
102
- return { blame: parts[0], commit: parts[1], review: parts[2] };
79
+ return { blame: parts[0], commit: parts[1] };
103
80
  }
104
81
 
105
82
  // src/cli/output/terminal.ts
@@ -125,17 +102,17 @@ function getModeLabel(mode) {
125
102
  return "Binary mode";
126
103
  case "authorship":
127
104
  return "Authorship mode";
128
- case "review-coverage":
129
- return "Review Coverage mode";
130
105
  case "weighted":
131
106
  return "Weighted mode";
132
107
  default:
133
108
  return mode;
134
109
  }
135
110
  }
111
+ var NAME_COLUMN_WIDTH = 24;
136
112
  function renderFolder(node, indent, mode, maxDepth) {
137
113
  const lines = [];
138
114
  const prefix = " ".repeat(indent);
115
+ const prefixWidth = indent * 2;
139
116
  const sorted = [...node.children].sort((a, b) => {
140
117
  if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
141
118
  return a.path.localeCompare(b.path);
@@ -146,14 +123,19 @@ function renderFolder(node, indent, mode, maxDepth) {
146
123
  const name = folder.path.split("/").pop() + "/";
147
124
  const bar = makeBar(folder.score);
148
125
  const pct = formatPercent(folder.score);
126
+ const padWidth = Math.max(
127
+ 1,
128
+ NAME_COLUMN_WIDTH - prefixWidth - name.length
129
+ );
130
+ const padding = " ".repeat(padWidth);
149
131
  if (mode === "binary") {
150
132
  const readCount = folder.readCount || 0;
151
133
  lines.push(
152
- `${prefix}${chalk.bold(name.padEnd(16))} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
134
+ `${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
153
135
  );
154
136
  } else {
155
137
  lines.push(
156
- `${prefix}${chalk.bold(name.padEnd(16))} ${bar} ${pct.padStart(4)}`
138
+ `${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
157
139
  );
158
140
  }
159
141
  if (indent < maxDepth) {
@@ -166,7 +148,9 @@ function renderFolder(node, indent, mode, maxDepth) {
166
148
  function renderTerminal(result) {
167
149
  const { tree, repoName, mode } = result;
168
150
  console.log("");
169
- console.log(chalk.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`));
151
+ console.log(
152
+ chalk.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`)
153
+ );
170
154
  console.log("");
171
155
  if (mode === "binary") {
172
156
  const readCount = tree.readCount || 0;
@@ -183,10 +167,8 @@ function renderTerminal(result) {
183
167
  }
184
168
  console.log("");
185
169
  if (mode === "binary") {
186
- const { writtenCount, reviewedCount, bothCount } = result;
187
- console.log(
188
- `Written: ${writtenCount} files | Reviewed: ${reviewedCount} files | Both: ${bothCount} files`
189
- );
170
+ const { writtenCount } = result;
171
+ console.log(`Written: ${writtenCount} files`);
190
172
  console.log("");
191
173
  }
192
174
  }
@@ -244,28 +226,7 @@ function generateTreemapHTML(result) {
244
226
  #breadcrumb span { cursor: pointer; color: #5eadf7; }
245
227
  #breadcrumb span:hover { text-decoration: underline; }
246
228
  #breadcrumb .sep { color: #666; margin: 0 4px; }
247
- #controls {
248
- padding: 8px 24px;
249
- background: #16213e;
250
- border-bottom: 1px solid #0f3460;
251
- display: flex;
252
- gap: 12px;
253
- align-items: center;
254
- }
255
- #controls button {
256
- padding: 4px 12px;
257
- border: 1px solid #0f3460;
258
- background: #1a1a2e;
259
- color: #e0e0e0;
260
- border-radius: 4px;
261
- cursor: pointer;
262
- font-size: 12px;
263
- }
264
- #controls button.active {
265
- background: #e94560;
266
- border-color: #e94560;
267
- color: white;
268
- }
229
+
269
230
  #treemap { width: 100%; }
270
231
  #tooltip {
271
232
  position: absolute;
@@ -306,13 +267,7 @@ function generateTreemapHTML(result) {
306
267
  <div class="info">${mode.charAt(0).toUpperCase() + mode.slice(1)} mode | ${result.totalFiles} files</div>
307
268
  </div>
308
269
  <div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
309
- ${mode === "binary" ? `
310
- <div id="controls">
311
- <span style="font-size:12px;color:#888;">Filter:</span>
312
- <button class="active" onclick="setFilter('all')">All</button>
313
- <button onclick="setFilter('written')">Written only</button>
314
- <button onclick="setFilter('reviewed')">Reviewed only</button>
315
- </div>` : ""}
270
+
316
271
  <div id="treemap"></div>
317
272
  <div id="tooltip"></div>
318
273
  <div id="legend">
@@ -325,7 +280,6 @@ ${mode === "binary" ? `
325
280
  <script>
326
281
  const rawData = ${dataJson};
327
282
  const mode = "${mode}";
328
- let currentFilter = 'all';
329
283
  let currentPath = '';
330
284
 
331
285
  function scoreColor(score) {
@@ -340,9 +294,6 @@ function scoreColor(score) {
340
294
  }
341
295
 
342
296
  function getNodeScore(node) {
343
- if (mode !== 'binary') return node.score;
344
- if (currentFilter === 'written') return node.isWritten ? 1 : 0;
345
- if (currentFilter === 'reviewed') return node.isReviewed ? 1 : 0;
346
297
  return node.score;
347
298
  }
348
299
 
@@ -382,10 +333,8 @@ function render() {
382
333
 
383
334
  const headerH = document.getElementById('header').offsetHeight;
384
335
  const breadcrumbH = document.getElementById('breadcrumb').offsetHeight;
385
- const controlsEl = document.getElementById('controls');
386
- const controlsH = controlsEl ? controlsEl.offsetHeight : 0;
387
336
  const width = window.innerWidth;
388
- const height = window.innerHeight - headerH - breadcrumbH - controlsH;
337
+ const height = window.innerHeight - headerH - breadcrumbH;
389
338
 
390
339
  const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
391
340
  if (!targetNode) return;
@@ -492,9 +441,7 @@ function showTooltip(data, event) {
492
441
  if (data.commitScore !== undefined) {
493
442
  html += '<br>Commit: ' + Math.round(data.commitScore * 100) + '%';
494
443
  }
495
- if (data.reviewScore !== undefined) {
496
- html += '<br>Review: ' + Math.round(data.reviewScore * 100) + '%';
497
- }
444
+
498
445
  if (data.isExpired) {
499
446
  html += '<br><span style="color:#e94560">Expired</span>';
500
447
  }
@@ -523,14 +470,6 @@ function updateBreadcrumb() {
523
470
  el.innerHTML = html;
524
471
  }
525
472
 
526
- function setFilter(f) {
527
- currentFilter = f;
528
- document.querySelectorAll('#controls button').forEach(btn => {
529
- btn.classList.toggle('active', btn.textContent.toLowerCase().includes(f));
530
- });
531
- render();
532
- }
533
-
534
473
  window.addEventListener('resize', render);
535
474
  render();
536
475
  </script>
@@ -1162,7 +1101,6 @@ async function computeMultiUser(options) {
1162
1101
  const userSummaries = results.map((r) => ({
1163
1102
  user: { name: r.result.userName, email: "" },
1164
1103
  writtenCount: r.result.writtenCount,
1165
- reviewedCount: r.result.reviewedCount,
1166
1104
  overallScore: r.result.tree.score
1167
1105
  }));
1168
1106
  return {
@@ -1199,8 +1137,7 @@ function mergeResults(results) {
1199
1137
  scores.push({
1200
1138
  user: { name: userName, email: "" },
1201
1139
  score: file.score,
1202
- isWritten: file.isWritten,
1203
- isReviewed: file.isReviewed
1140
+ isWritten: file.isWritten
1204
1141
  });
1205
1142
  });
1206
1143
  }
@@ -1275,8 +1212,6 @@ function getModeLabel2(mode) {
1275
1212
  return "Binary mode";
1276
1213
  case "authorship":
1277
1214
  return "Authorship mode";
1278
- case "review-coverage":
1279
- return "Review Coverage mode";
1280
1215
  case "weighted":
1281
1216
  return "Weighted mode";
1282
1217
  default:
@@ -1323,7 +1258,7 @@ function renderMultiUserTerminal(result) {
1323
1258
  const pct = formatPercent2(summary.overallScore);
1324
1259
  if (mode === "binary") {
1325
1260
  console.log(
1326
- ` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount + summary.reviewedCount}/${totalFiles} files)`
1261
+ ` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount}/${totalFiles} files)`
1327
1262
  );
1328
1263
  } else {
1329
1264
  console.log(` ${name} ${bar} ${pct.padStart(4)}`);
@@ -1332,9 +1267,7 @@ function renderMultiUserTerminal(result) {
1332
1267
  console.log("");
1333
1268
  const nameWidth = 20;
1334
1269
  const headerNames = userSummaries.map((s) => truncateName(s.user.name, 7).padStart(7)).join(" ");
1335
- console.log(
1336
- chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames
1337
- );
1270
+ console.log(chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames);
1338
1271
  const folderLines = renderFolder3(tree, 1, 2, nameWidth);
1339
1272
  for (const line of folderLines) {
1340
1273
  console.log(line);
@@ -2149,84 +2082,1020 @@ async function generateAndOpenHotspotHTML(result, repoPath) {
2149
2082
  await openBrowser(outputPath);
2150
2083
  }
2151
2084
 
2152
- // src/github/check.ts
2153
- async function checkGitHubConnection(repoPath, githubUrl) {
2154
- const gitClient = new GitClient(repoPath);
2155
- if (!await gitClient.isRepo()) {
2156
- console.error("Error: Not a git repository.");
2157
- process.exit(1);
2158
- }
2159
- const remoteUrl = await gitClient.getRemoteUrl();
2160
- if (!remoteUrl) {
2161
- console.error("Error: No git remote found.");
2162
- process.exit(1);
2163
- }
2164
- console.log(`Remote URL: ${remoteUrl}`);
2165
- const parsed = GitHubClient.parseRemoteUrl(remoteUrl, githubUrl);
2166
- if (!parsed) {
2167
- console.error("Error: Could not parse remote URL as a GitHub repository.");
2168
- process.exit(1);
2169
- }
2170
- console.log(`Hostname: ${parsed.hostname}`);
2171
- console.log(`Repository: ${parsed.owner}/${parsed.repo}`);
2172
- console.log(`API Base URL: ${parsed.apiBaseUrl}`);
2173
- console.log(`
2174
- Resolving token for hostname: ${parsed.hostname}`);
2175
- const token = resolveGitHubToken(parsed.hostname);
2176
- if (!token) {
2177
- console.error(
2178
- `No GitHub token found.
2179
- Tried:
2180
- 1. Environment variables: GITHUB_TOKEN, GH_TOKEN
2181
- 2. gh auth token --hostname ${parsed.hostname}
2182
- ` + (parsed.hostname !== "github.com" ? ` 3. gh auth token (default host fallback)
2183
- ` : "") + `
2184
- Please run: gh auth login` + (parsed.hostname !== "github.com" ? ` --hostname ${parsed.hostname}` : "")
2185
- );
2186
- process.exit(1);
2085
+ // src/core/unified.ts
2086
+ async function computeUnified(options) {
2087
+ console.log("Computing unified dashboard data...");
2088
+ console.log(" [1/4] Scoring (binary, authorship, weighted)...");
2089
+ const [binary, authorship, weighted] = await Promise.all([
2090
+ computeFamiliarity({ ...options, mode: "binary" }),
2091
+ computeFamiliarity({ ...options, mode: "authorship" }),
2092
+ computeFamiliarity({ ...options, mode: "weighted" })
2093
+ ]);
2094
+ console.log(" [2/4] Team coverage...");
2095
+ const coverage = await computeTeamCoverage(options);
2096
+ console.log(" [3/4] Hotspot analysis...");
2097
+ const hotspot = await computeHotspots({
2098
+ ...options,
2099
+ hotspot: "personal"
2100
+ });
2101
+ console.log(" [4/4] Multi-user comparison...");
2102
+ const multiUser = await computeMultiUser({
2103
+ ...options,
2104
+ team: true
2105
+ });
2106
+ console.log("Done.");
2107
+ return {
2108
+ repoName: binary.repoName,
2109
+ userName: binary.userName,
2110
+ scoring: { binary, authorship, weighted },
2111
+ coverage,
2112
+ hotspot,
2113
+ multiUser
2114
+ };
2115
+ }
2116
+
2117
+ // src/cli/output/unified-html.ts
2118
+ import { writeFileSync as writeFileSync5 } from "fs";
2119
+ import { join as join5 } from "path";
2120
+ function generateUnifiedHTML(data) {
2121
+ const scoringBinaryJson = JSON.stringify(data.scoring.binary.tree);
2122
+ const scoringAuthorshipJson = JSON.stringify(data.scoring.authorship.tree);
2123
+ const scoringWeightedJson = JSON.stringify(data.scoring.weighted.tree);
2124
+ const coverageTreeJson = JSON.stringify(data.coverage.tree);
2125
+ const coverageRiskJson = JSON.stringify(data.coverage.riskFiles);
2126
+ const hotspotJson = JSON.stringify(
2127
+ data.hotspot.files.filter((f) => f.changeFrequency > 0).map((f) => ({
2128
+ path: f.path,
2129
+ lines: f.lines,
2130
+ familiarity: f.familiarity,
2131
+ changeFrequency: f.changeFrequency,
2132
+ risk: f.risk,
2133
+ riskLevel: f.riskLevel
2134
+ }))
2135
+ );
2136
+ const multiUserTreeJson = JSON.stringify(data.multiUser.tree);
2137
+ const multiUserSummariesJson = JSON.stringify(data.multiUser.userSummaries);
2138
+ const multiUserNamesJson = JSON.stringify(
2139
+ data.multiUser.users.map((u) => u.name)
2140
+ );
2141
+ return `<!DOCTYPE html>
2142
+ <html lang="en">
2143
+ <head>
2144
+ <meta charset="UTF-8">
2145
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2146
+ <title>GitFamiliar \u2014 ${data.repoName}</title>
2147
+ <style>
2148
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2149
+ body {
2150
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2151
+ background: #1a1a2e;
2152
+ color: #e0e0e0;
2153
+ overflow: hidden;
2154
+ display: flex;
2155
+ flex-direction: column;
2156
+ height: 100vh;
2187
2157
  }
2188
- console.log(`Token: ****${token.slice(-4)}`);
2189
- console.log("\nVerifying API connectivity...");
2190
- try {
2191
- const client = new GitHubClient(token, parsed.apiBaseUrl);
2192
- const user = await client.verifyConnection();
2193
- console.log(
2194
- `Authenticated as: ${user.login}${user.name ? ` (${user.name})` : ""}`
2195
- );
2196
- console.log("\nGitHub connection OK.");
2197
- } catch (error) {
2198
- console.error(`
2199
- API connection failed: ${error.message}`);
2200
- process.exit(1);
2158
+ #header {
2159
+ padding: 12px 24px;
2160
+ background: #16213e;
2161
+ border-bottom: 1px solid #0f3460;
2162
+ display: flex;
2163
+ align-items: center;
2164
+ justify-content: space-between;
2165
+ }
2166
+ #header h1 { font-size: 18px; color: #e94560; }
2167
+ #header .info { font-size: 13px; color: #a0a0a0; }
2168
+
2169
+ /* Tabs */
2170
+ #tabs {
2171
+ display: flex;
2172
+ background: #16213e;
2173
+ border-bottom: 1px solid #0f3460;
2174
+ padding: 0 24px;
2175
+ }
2176
+ #tabs .tab {
2177
+ padding: 10px 20px;
2178
+ cursor: pointer;
2179
+ color: #888;
2180
+ border-bottom: 2px solid transparent;
2181
+ font-size: 14px;
2182
+ transition: color 0.15s;
2183
+ }
2184
+ #tabs .tab:hover { color: #ccc; }
2185
+ #tabs .tab.active { color: #e94560; border-bottom-color: #e94560; }
2186
+
2187
+ /* Sub-tabs (scoring modes) */
2188
+ #scoring-controls {
2189
+ display: none;
2190
+ padding: 8px 24px;
2191
+ background: #16213e;
2192
+ border-bottom: 1px solid #0f3460;
2193
+ align-items: center;
2194
+ gap: 16px;
2195
+ }
2196
+ #scoring-controls.visible { display: flex; }
2197
+ .subtab {
2198
+ padding: 5px 14px;
2199
+ cursor: pointer;
2200
+ color: #888;
2201
+ border: 1px solid #0f3460;
2202
+ border-radius: 4px;
2203
+ font-size: 12px;
2204
+ background: transparent;
2205
+ transition: all 0.15s;
2206
+ }
2207
+ .subtab:hover { color: #ccc; border-color: #555; }
2208
+ .subtab.active { color: #e94560; border-color: #e94560; background: rgba(233,69,96,0.1); }
2209
+ #weight-controls {
2210
+ display: none;
2211
+ align-items: center;
2212
+ gap: 8px;
2213
+ margin-left: 24px;
2214
+ font-size: 12px;
2215
+ color: #a0a0a0;
2216
+ }
2217
+ #weight-controls.visible { display: flex; }
2218
+ #weight-controls input[type="range"] {
2219
+ width: 120px;
2220
+ accent-color: #e94560;
2221
+ }
2222
+ #weight-controls .weight-label { min-width: 36px; text-align: right; color: #e0e0e0; }
2223
+
2224
+ /* Breadcrumb */
2225
+ #breadcrumb {
2226
+ padding: 8px 24px;
2227
+ background: #16213e;
2228
+ font-size: 13px;
2229
+ border-bottom: 1px solid #0f3460;
2230
+ display: none;
2231
+ }
2232
+ #breadcrumb.visible { display: block; }
2233
+ #breadcrumb span { cursor: pointer; color: #5eadf7; }
2234
+ #breadcrumb span:hover { text-decoration: underline; }
2235
+ #breadcrumb .sep { color: #666; margin: 0 4px; }
2236
+
2237
+ /* Tab descriptions */
2238
+ .tab-desc {
2239
+ padding: 8px 24px;
2240
+ background: #16213e;
2241
+ border-bottom: 1px solid #0f3460;
2242
+ font-size: 12px;
2243
+ color: #888;
2244
+ display: none;
2245
+ }
2246
+ .tab-desc.visible { display: block; }
2247
+
2248
+ /* Tab content */
2249
+ #content-area { flex: 1; position: relative; overflow: hidden; }
2250
+ .tab-content { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
2251
+ .tab-content.active { display: block; }
2252
+ .tab-content.with-sidebar.active { display: flex; }
2253
+
2254
+ /* Layout with sidebar */
2255
+ .with-sidebar .viz-area { flex: 1; position: relative; height: 100%; }
2256
+ .with-sidebar .sidebar {
2257
+ width: 300px;
2258
+ height: 100%;
2259
+ background: #16213e;
2260
+ border-left: 1px solid #0f3460;
2261
+ overflow-y: auto;
2262
+ padding: 16px;
2263
+ }
2264
+ .sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
2265
+ .sidebar .risk-file, .sidebar .hotspot-item {
2266
+ padding: 6px 0;
2267
+ border-bottom: 1px solid #0f3460;
2268
+ font-size: 12px;
2269
+ }
2270
+ .sidebar .path { color: #e0e0e0; word-break: break-all; }
2271
+ .sidebar .meta { color: #888; margin-top: 2px; }
2272
+ .risk-badge {
2273
+ display: inline-block;
2274
+ padding: 1px 6px;
2275
+ border-radius: 3px;
2276
+ font-size: 10px;
2277
+ font-weight: bold;
2278
+ margin-left: 4px;
2279
+ }
2280
+ .risk-critical { background: #e94560; color: white; }
2281
+ .risk-high { background: #f07040; color: white; }
2282
+ .risk-medium { background: #f5a623; color: black; }
2283
+ .risk-low { background: #27ae60; color: white; }
2284
+
2285
+ /* Multi-user controls */
2286
+ #multiuser-controls {
2287
+ display: none;
2288
+ padding: 8px 24px;
2289
+ background: #16213e;
2290
+ border-bottom: 1px solid #0f3460;
2291
+ align-items: center;
2292
+ gap: 12px;
2293
+ }
2294
+ #multiuser-controls.visible { display: flex; }
2295
+ #multiuser-controls select {
2296
+ padding: 4px 12px;
2297
+ border: 1px solid #0f3460;
2298
+ background: #1a1a2e;
2299
+ color: #e0e0e0;
2300
+ border-radius: 4px;
2301
+ font-size: 13px;
2302
+ }
2303
+ #multiuser-controls label { font-size: 13px; color: #888; }
2304
+
2305
+ /* Tooltip */
2306
+ #tooltip {
2307
+ position: absolute;
2308
+ pointer-events: none;
2309
+ background: rgba(22, 33, 62, 0.95);
2310
+ border: 1px solid #0f3460;
2311
+ border-radius: 6px;
2312
+ padding: 10px 14px;
2313
+ font-size: 13px;
2314
+ line-height: 1.6;
2315
+ display: none;
2316
+ z-index: 100;
2317
+ max-width: 350px;
2318
+ }
2319
+
2320
+ /* Legends */
2321
+ .legend {
2322
+ position: absolute;
2323
+ bottom: 16px;
2324
+ right: 16px;
2325
+ background: rgba(22, 33, 62, 0.9);
2326
+ border: 1px solid #0f3460;
2327
+ border-radius: 6px;
2328
+ padding: 10px;
2329
+ font-size: 12px;
2330
+ display: none;
2331
+ z-index: 50;
2332
+ }
2333
+ .legend.active { display: block; }
2334
+ .legend .gradient-bar {
2335
+ width: 120px;
2336
+ height: 12px;
2337
+ background: linear-gradient(to right, #e94560, #f5a623, #27ae60);
2338
+ border-radius: 3px;
2339
+ margin: 4px 0;
2201
2340
  }
2341
+ .legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
2342
+ .legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
2343
+ .legend .swatch { width: 14px; height: 14px; border-radius: 3px; }
2344
+
2345
+ /* Zone labels for hotspot */
2346
+ #zone-labels { position: absolute; pointer-events: none; }
2347
+ .zone-label {
2348
+ position: absolute;
2349
+ font-size: 16px;
2350
+ font-weight: bold;
2351
+ }
2352
+ </style>
2353
+ </head>
2354
+ <body>
2355
+ <div id="header">
2356
+ <h1>GitFamiliar \u2014 ${data.repoName}</h1>
2357
+ <div class="info">${data.userName} | ${data.scoring.binary.totalFiles} files</div>
2358
+ </div>
2359
+
2360
+ <div id="tabs">
2361
+ <div class="tab active" onclick="switchTab('scoring')">Scoring</div>
2362
+ <div class="tab" onclick="switchTab('coverage')">Coverage</div>
2363
+ <div class="tab" onclick="switchTab('multiuser')">Multi-User</div>
2364
+ <div class="tab" onclick="switchTab('hotspots')">Hotspots</div>
2365
+ </div>
2366
+
2367
+ <div id="tab-desc-scoring" class="tab-desc visible">
2368
+ Your personal familiarity with each file, based on Git history. Larger blocks = more lines of code. Color shows how well you know each file.
2369
+ </div>
2370
+ <div id="tab-desc-coverage" class="tab-desc">
2371
+ Team knowledge distribution: how many people have contributed to each file. Low contributor count = high bus factor risk.
2372
+ </div>
2373
+ <div id="tab-desc-multiuser" class="tab-desc">
2374
+ Compare familiarity scores across team members. Select a user to see the codebase colored by their knowledge.
2375
+ </div>
2376
+ <div id="tab-desc-hotspots" class="tab-desc">
2377
+ Files that change frequently but are poorly understood. Top-left = danger zone (high change, low familiarity).
2378
+ </div>
2379
+
2380
+ <div id="scoring-controls" class="visible">
2381
+ <button class="subtab active" onclick="switchScoringMode('binary')">Binary</button>
2382
+ <button class="subtab" onclick="switchScoringMode('authorship')">Authorship</button>
2383
+ <button class="subtab" onclick="switchScoringMode('weighted')">Weighted</button>
2384
+ <div id="weight-controls">
2385
+ <span>Blame:</span>
2386
+ <span class="weight-label" id="blame-label">50%</span>
2387
+ <input type="range" id="blame-slider" min="0" max="100" value="50" oninput="onWeightChange()">
2388
+ <span>Commit:</span>
2389
+ <span class="weight-label" id="commit-label">50%</span>
2390
+ </div>
2391
+ </div>
2392
+ <div id="scoring-mode-desc" class="tab-desc visible" style="padding-top:0">
2393
+ <span id="mode-desc-text">Binary: Have you ever committed to this file? Yes (green) or No (red).</span>
2394
+ </div>
2395
+
2396
+ <div id="multiuser-controls">
2397
+ <label>View as:</label>
2398
+ <select id="userSelect" onchange="onUserChange()"></select>
2399
+ </div>
2400
+
2401
+ <div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
2402
+
2403
+ <div id="content-area">
2404
+ <div id="tab-scoring" class="tab-content active"></div>
2405
+ <div id="tab-coverage" class="tab-content with-sidebar">
2406
+ <div class="viz-area" id="coverage-viz"></div>
2407
+ <div class="sidebar" id="coverage-sidebar">
2408
+ <h3>Risk Files (0-1 contributors)</h3>
2409
+ <div id="risk-list"></div>
2410
+ </div>
2411
+ </div>
2412
+ <div id="tab-multiuser" class="tab-content"></div>
2413
+ <div id="tab-hotspots" class="tab-content with-sidebar">
2414
+ <div class="viz-area" id="hotspot-viz">
2415
+ <div id="zone-labels"></div>
2416
+ </div>
2417
+ <div class="sidebar" id="hotspot-sidebar">
2418
+ <h3>Top Hotspots</h3>
2419
+ <div id="hotspot-list"></div>
2420
+ </div>
2421
+ </div>
2422
+ </div>
2423
+
2424
+ <div id="tooltip"></div>
2425
+
2426
+ <!-- Legends -->
2427
+ <div class="legend active" id="legend-scoring">
2428
+ <div>Familiarity</div>
2429
+ <div class="gradient-bar"></div>
2430
+ <div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
2431
+ </div>
2432
+ <div class="legend" id="legend-coverage">
2433
+ <div>Contributors</div>
2434
+ <div class="row"><div class="swatch" style="background:#e94560"></div> 0\u20131 (Risk)</div>
2435
+ <div class="row"><div class="swatch" style="background:#f5a623"></div> 2\u20133 (Moderate)</div>
2436
+ <div class="row"><div class="swatch" style="background:#27ae60"></div> 4+ (Safe)</div>
2437
+ </div>
2438
+ <div class="legend" id="legend-multiuser">
2439
+ <div>Familiarity</div>
2440
+ <div class="gradient-bar"></div>
2441
+ <div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
2442
+ </div>
2443
+
2444
+ <script src="https://d3js.org/d3.v7.min.js"></script>
2445
+ <script>
2446
+ // \u2500\u2500 Data \u2500\u2500
2447
+ const scoringData = {
2448
+ binary: ${scoringBinaryJson},
2449
+ authorship: ${scoringAuthorshipJson},
2450
+ weighted: ${scoringWeightedJson},
2451
+ };
2452
+ const coverageData = ${coverageTreeJson};
2453
+ const coverageRiskFiles = ${coverageRiskJson};
2454
+ const hotspotData = ${hotspotJson};
2455
+ const multiUserData = ${multiUserTreeJson};
2456
+ const multiUserNames = ${multiUserNamesJson};
2457
+ const multiUserSummaries = ${multiUserSummariesJson};
2458
+
2459
+ // \u2500\u2500 State \u2500\u2500
2460
+ let activeTab = 'scoring';
2461
+ let scoringMode = 'binary';
2462
+ let blameWeight = 0.5;
2463
+ let scoringPath = '';
2464
+ let coveragePath = '';
2465
+ let multiuserPath = '';
2466
+ let currentUser = 0;
2467
+ const rendered = { scoring: false, coverage: false, hotspots: false, multiuser: false };
2468
+
2469
+ // \u2500\u2500 Common utilities \u2500\u2500
2470
+ function scoreColor(score) {
2471
+ if (score <= 0) return '#e94560';
2472
+ if (score >= 1) return '#27ae60';
2473
+ if (score < 0.5) return d3.interpolateRgb('#e94560', '#f5a623')(score / 0.5);
2474
+ return d3.interpolateRgb('#f5a623', '#27ae60')((score - 0.5) / 0.5);
2202
2475
  }
2203
2476
 
2204
- // src/cli/index.ts
2205
- function collect(value, previous) {
2206
- return previous.concat([value]);
2477
+ function coverageColor(count) {
2478
+ if (count <= 0) return '#e94560';
2479
+ if (count === 1) return '#d63c57';
2480
+ if (count <= 3) return '#f5a623';
2481
+ return '#27ae60';
2207
2482
  }
2208
- function createProgram() {
2483
+
2484
+ function folderRiskColor(riskLevel) {
2485
+ switch (riskLevel) {
2486
+ case 'risk': return '#e94560';
2487
+ case 'moderate': return '#f5a623';
2488
+ default: return '#27ae60';
2489
+ }
2490
+ }
2491
+
2492
+ function riskLevelColor(level) {
2493
+ switch(level) {
2494
+ case 'critical': return '#e94560';
2495
+ case 'high': return '#f07040';
2496
+ case 'medium': return '#f5a623';
2497
+ default: return '#27ae60';
2498
+ }
2499
+ }
2500
+
2501
+ function findNode(node, path) {
2502
+ if (node.path === path) return node;
2503
+ if (node.children) {
2504
+ for (const child of node.children) {
2505
+ const found = findNode(child, path);
2506
+ if (found) return found;
2507
+ }
2508
+ }
2509
+ return null;
2510
+ }
2511
+
2512
+ function buildHierarchy(node) {
2513
+ if (node.type === 'file') {
2514
+ return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
2515
+ }
2516
+ return {
2517
+ name: node.path.split('/').pop() || node.path,
2518
+ data: node,
2519
+ children: (node.children || []).map(c => buildHierarchy(c)),
2520
+ };
2521
+ }
2522
+
2523
+ function updateBreadcrumb(path) {
2524
+ const el = document.getElementById('breadcrumb');
2525
+ const parts = path ? path.split('/') : [];
2526
+ let html = '<span onclick="zoomTo(\\'\\')">root</span>';
2527
+ let accumulated = '';
2528
+ for (const part of parts) {
2529
+ accumulated = accumulated ? accumulated + '/' + part : part;
2530
+ const p = accumulated;
2531
+ html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
2532
+ }
2533
+ el.innerHTML = html;
2534
+ }
2535
+
2536
+ function showTooltipAt(html, event) {
2537
+ const tooltip = document.getElementById('tooltip');
2538
+ tooltip.innerHTML = html;
2539
+ tooltip.style.display = 'block';
2540
+ tooltip.style.left = (event.pageX + 14) + 'px';
2541
+ tooltip.style.top = (event.pageY - 14) + 'px';
2542
+ }
2543
+
2544
+ function moveTooltip(event) {
2545
+ const tooltip = document.getElementById('tooltip');
2546
+ tooltip.style.left = (event.pageX + 14) + 'px';
2547
+ tooltip.style.top = (event.pageY - 14) + 'px';
2548
+ }
2549
+
2550
+ function hideTooltip() {
2551
+ document.getElementById('tooltip').style.display = 'none';
2552
+ }
2553
+
2554
+ function truncateLabel(name, w, h) {
2555
+ if (w < 36 || h < 18) return '';
2556
+ const maxChars = Math.floor((w - 8) / 6.5);
2557
+ if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
2558
+ return name;
2559
+ }
2560
+
2561
+ // \u2500\u2500 Tab switching \u2500\u2500
2562
+ const modeDescriptions = {
2563
+ binary: 'Binary: Have you ever committed to this file? Yes (green) or No (red).',
2564
+ authorship: 'Authorship: How much of the current code did you write? Based on git blame line ownership.',
2565
+ weighted: 'Weighted: Combines blame ownership and commit history with adjustable weights. Use the sliders to tune.',
2566
+ };
2567
+
2568
+ function switchTab(tab) {
2569
+ activeTab = tab;
2570
+ document.querySelectorAll('#tabs .tab').forEach((el, i) => {
2571
+ const tabs = ['scoring', 'coverage', 'multiuser', 'hotspots'];
2572
+ el.classList.toggle('active', tabs[i] === tab);
2573
+ });
2574
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
2575
+ document.getElementById('tab-' + tab).classList.add('active');
2576
+
2577
+ // Show/hide tab descriptions
2578
+ ['scoring', 'coverage', 'multiuser', 'hotspots'].forEach(t => {
2579
+ document.getElementById('tab-desc-' + t).classList.toggle('visible', t === tab);
2580
+ });
2581
+
2582
+ // Show/hide controls
2583
+ document.getElementById('scoring-controls').classList.toggle('visible', tab === 'scoring');
2584
+ document.getElementById('scoring-mode-desc').classList.toggle('visible', tab === 'scoring');
2585
+ document.getElementById('multiuser-controls').classList.toggle('visible', tab === 'multiuser');
2586
+
2587
+ // Show/hide breadcrumb
2588
+ const showBreadcrumb = tab === 'scoring' || tab === 'coverage' || tab === 'multiuser';
2589
+ document.getElementById('breadcrumb').classList.toggle('visible', showBreadcrumb);
2590
+
2591
+ // Show/hide legends
2592
+ document.getElementById('legend-scoring').classList.toggle('active', tab === 'scoring');
2593
+ document.getElementById('legend-coverage').classList.toggle('active', tab === 'coverage');
2594
+ document.getElementById('legend-multiuser').classList.toggle('active', tab === 'multiuser');
2595
+
2596
+ // Update breadcrumb for current tab
2597
+ if (tab === 'scoring') updateBreadcrumb(scoringPath);
2598
+ else if (tab === 'coverage') updateBreadcrumb(coveragePath);
2599
+ else if (tab === 'multiuser') updateBreadcrumb(multiuserPath);
2600
+
2601
+ // Render after a short delay so layout is computed after display change
2602
+ setTimeout(() => {
2603
+ if (!rendered[tab]) {
2604
+ rendered[tab] = true;
2605
+ if (tab === 'coverage') { renderCoverageSidebar(); renderCoverage(); }
2606
+ else if (tab === 'hotspots') { renderHotspotSidebar(); renderHotspot(); }
2607
+ else if (tab === 'multiuser') { initMultiUserSelect(); renderMultiUser(); }
2608
+ } else {
2609
+ if (tab === 'scoring') renderScoring();
2610
+ else if (tab === 'coverage') renderCoverage();
2611
+ else if (tab === 'hotspots') renderHotspot();
2612
+ else if (tab === 'multiuser') renderMultiUser();
2613
+ }
2614
+ }, 0);
2615
+ }
2616
+
2617
+ // \u2500\u2500 Zoom (shared across treemap tabs) \u2500\u2500
2618
+ function zoomTo(path) {
2619
+ if (activeTab === 'scoring') { scoringPath = path; renderScoring(); }
2620
+ else if (activeTab === 'coverage') { coveragePath = path; renderCoverage(); }
2621
+ else if (activeTab === 'multiuser') { multiuserPath = path; renderMultiUser(); }
2622
+ updateBreadcrumb(path);
2623
+ }
2624
+
2625
+ // \u2500\u2500 Layout dimensions \u2500\u2500
2626
+ function getContentHeight() {
2627
+ return document.getElementById('content-area').offsetHeight;
2628
+ }
2629
+
2630
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2631
+ // \u2500\u2500 SCORING TAB \u2500\u2500
2632
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2633
+
2634
+ function switchScoringMode(mode) {
2635
+ scoringMode = mode;
2636
+ scoringPath = '';
2637
+ updateBreadcrumb('');
2638
+ document.querySelectorAll('#scoring-controls .subtab').forEach(el => {
2639
+ el.classList.toggle('active', el.textContent.toLowerCase() === mode);
2640
+ });
2641
+ document.getElementById('weight-controls').classList.toggle('visible', mode === 'weighted');
2642
+ document.getElementById('mode-desc-text').textContent = modeDescriptions[mode];
2643
+ renderScoring();
2644
+ }
2645
+
2646
+ function onWeightChange() {
2647
+ const slider = document.getElementById('blame-slider');
2648
+ const bv = parseInt(slider.value);
2649
+ blameWeight = bv / 100;
2650
+ document.getElementById('blame-label').textContent = bv + '%';
2651
+ document.getElementById('commit-label').textContent = (100 - bv) + '%';
2652
+ recalcWeightedScores(scoringData.weighted, blameWeight, 1 - blameWeight);
2653
+ renderScoring();
2654
+ }
2655
+
2656
+ function recalcWeightedScores(node, bw, cw) {
2657
+ if (node.type === 'file') {
2658
+ const bs = node.blameScore || 0;
2659
+ const cs = node.commitScore || 0;
2660
+ node.score = bw * bs + cw * cs;
2661
+ } else if (node.children) {
2662
+ let totalLines = 0;
2663
+ let weightedSum = 0;
2664
+ for (const child of node.children) {
2665
+ recalcWeightedScores(child, bw, cw);
2666
+ const lines = child.lines || 1;
2667
+ totalLines += lines;
2668
+ weightedSum += child.score * lines;
2669
+ }
2670
+ node.score = totalLines > 0 ? weightedSum / totalLines : 0;
2671
+ }
2672
+ }
2673
+
2674
+ function renderScoring() {
2675
+ const container = document.getElementById('tab-scoring');
2676
+ container.innerHTML = '';
2677
+ const height = getContentHeight();
2678
+ const width = window.innerWidth;
2679
+
2680
+ const treeData = scoringData[scoringMode];
2681
+ const targetNode = scoringPath ? findNode(treeData, scoringPath) : treeData;
2682
+ if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
2683
+
2684
+ const hierarchyData = {
2685
+ name: targetNode.path || 'root',
2686
+ children: targetNode.children.map(c => buildHierarchy(c)),
2687
+ };
2688
+
2689
+ const root = d3.hierarchy(hierarchyData)
2690
+ .sum(d => d.value || 0)
2691
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
2692
+
2693
+ d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
2694
+
2695
+ const svg = d3.select('#tab-scoring').append('svg').attr('width', width).attr('height', height);
2696
+ const nodes = root.descendants().filter(d => d.depth > 0);
2697
+
2698
+ const groups = svg.selectAll('g').data(nodes).join('g')
2699
+ .attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
2700
+
2701
+ groups.append('rect')
2702
+ .attr('width', d => Math.max(0, d.x1 - d.x0))
2703
+ .attr('height', d => Math.max(0, d.y1 - d.y0))
2704
+ .attr('fill', d => d.data.data ? scoreColor(d.data.data.score) : '#333')
2705
+ .attr('opacity', d => d.children ? 0.35 : 0.88)
2706
+ .attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
2707
+ .style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
2708
+ .on('click', (event, d) => {
2709
+ if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
2710
+ })
2711
+ .on('mouseover', function(event, d) {
2712
+ if (!d.data.data) return;
2713
+ d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
2714
+ let html = '<strong>' + d.data.data.path + '</strong>';
2715
+ html += '<br>Score: ' + Math.round(d.data.data.score * 100) + '%';
2716
+ html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
2717
+ if (d.data.data.type === 'folder') {
2718
+ html += '<br>Files: ' + d.data.data.fileCount;
2719
+ html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
2720
+ }
2721
+ if (d.data.data.blameScore !== undefined) html += '<br>Blame: ' + Math.round(d.data.data.blameScore * 100) + '%';
2722
+ if (d.data.data.commitScore !== undefined) html += '<br>Commit: ' + Math.round(d.data.data.commitScore * 100) + '%';
2723
+ if (d.data.data.isExpired) html += '<br><span style="color:#e94560">Expired</span>';
2724
+ showTooltipAt(html, event);
2725
+ })
2726
+ .on('mousemove', moveTooltip)
2727
+ .on('mouseout', function(event, d) {
2728
+ d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
2729
+ hideTooltip();
2730
+ });
2731
+
2732
+ groups.append('text')
2733
+ .attr('x', 4).attr('y', 14).attr('fill', '#fff')
2734
+ .attr('font-size', d => d.children ? '11px' : '10px')
2735
+ .attr('font-weight', d => d.children ? 'bold' : 'normal')
2736
+ .style('pointer-events', 'none')
2737
+ .text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
2738
+ }
2739
+
2740
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2741
+ // \u2500\u2500 COVERAGE TAB \u2500\u2500
2742
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2743
+
2744
+ function renderCoverage() {
2745
+ const vizArea = document.getElementById('coverage-viz');
2746
+ vizArea.innerHTML = '';
2747
+ let height = vizArea.offsetHeight;
2748
+ let width = vizArea.offsetWidth;
2749
+
2750
+ if (!width || !height) {
2751
+ const contentH = document.getElementById('content-area').offsetHeight;
2752
+ width = window.innerWidth - 300;
2753
+ height = contentH;
2754
+ }
2755
+
2756
+ const targetNode = coveragePath ? findNode(coverageData, coveragePath) : coverageData;
2757
+ if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
2758
+
2759
+ const hierarchyData = {
2760
+ name: targetNode.path || 'root',
2761
+ children: targetNode.children.map(c => buildHierarchy(c)),
2762
+ };
2763
+
2764
+ const root = d3.hierarchy(hierarchyData)
2765
+ .sum(d => d.value || 0)
2766
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
2767
+
2768
+ d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
2769
+
2770
+ const svg = d3.select('#coverage-viz').append('svg').attr('width', width).attr('height', height);
2771
+ const nodes = root.descendants().filter(d => d.depth > 0);
2772
+
2773
+ const groups = svg.selectAll('g').data(nodes).join('g')
2774
+ .attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
2775
+
2776
+ groups.append('rect')
2777
+ .attr('width', d => Math.max(0, d.x1 - d.x0))
2778
+ .attr('height', d => Math.max(0, d.y1 - d.y0))
2779
+ .attr('fill', d => {
2780
+ if (!d.data.data) return '#333';
2781
+ if (d.data.data.type === 'file') return coverageColor(d.data.data.contributorCount);
2782
+ return folderRiskColor(d.data.data.riskLevel);
2783
+ })
2784
+ .attr('opacity', d => d.children ? 0.35 : 0.88)
2785
+ .attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
2786
+ .style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
2787
+ .on('click', (event, d) => {
2788
+ if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
2789
+ })
2790
+ .on('mouseover', function(event, d) {
2791
+ if (!d.data.data) return;
2792
+ d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
2793
+ let html = '<strong>' + d.data.data.path + '</strong>';
2794
+ if (d.data.data.type === 'file') {
2795
+ html += '<br>Contributors: ' + d.data.data.contributorCount;
2796
+ if (d.data.data.contributors && d.data.data.contributors.length > 0) {
2797
+ html += '<br>' + d.data.data.contributors.slice(0, 8).join(', ');
2798
+ if (d.data.data.contributors.length > 8) html += ', ...';
2799
+ }
2800
+ html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
2801
+ } else {
2802
+ html += '<br>Files: ' + d.data.data.fileCount;
2803
+ html += '<br>Avg Contributors: ' + d.data.data.avgContributors;
2804
+ html += '<br>Bus Factor: ' + d.data.data.busFactor;
2805
+ html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
2806
+ }
2807
+ showTooltipAt(html, event);
2808
+ })
2809
+ .on('mousemove', moveTooltip)
2810
+ .on('mouseout', function(event, d) {
2811
+ d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
2812
+ hideTooltip();
2813
+ });
2814
+
2815
+ groups.append('text')
2816
+ .attr('x', 4).attr('y', 14).attr('fill', '#fff')
2817
+ .attr('font-size', d => d.children ? '11px' : '10px')
2818
+ .attr('font-weight', d => d.children ? 'bold' : 'normal')
2819
+ .style('pointer-events', 'none')
2820
+ .text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
2821
+ }
2822
+
2823
+ function renderCoverageSidebar() {
2824
+ const container = document.getElementById('risk-list');
2825
+ if (coverageRiskFiles.length === 0) {
2826
+ container.innerHTML = '<div style="color:#888">No high-risk files found.</div>';
2827
+ return;
2828
+ }
2829
+ let html = '';
2830
+ for (const f of coverageRiskFiles.slice(0, 50)) {
2831
+ const countLabel = f.contributorCount === 0 ? '0 people' : '1 person (' + f.contributors[0] + ')';
2832
+ html += '<div class="risk-file"><div class="path">' + f.path + '</div><div class="meta">' + countLabel + '</div></div>';
2833
+ }
2834
+ if (coverageRiskFiles.length > 50) {
2835
+ html += '<div style="color:#888;padding:8px 0">... and ' + (coverageRiskFiles.length - 50) + ' more</div>';
2836
+ }
2837
+ container.innerHTML = html;
2838
+ }
2839
+
2840
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2841
+ // \u2500\u2500 HOTSPOT TAB \u2500\u2500
2842
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2843
+
2844
+ function renderHotspot() {
2845
+ const vizArea = document.getElementById('hotspot-viz');
2846
+ const existingSvg = vizArea.querySelector('svg');
2847
+ if (existingSvg) existingSvg.remove();
2848
+
2849
+ let height = vizArea.offsetHeight;
2850
+ let width = vizArea.offsetWidth;
2851
+
2852
+ // Fallback: if the element hasn't been laid out yet, calculate manually
2853
+ if (!width || !height) {
2854
+ const contentH = document.getElementById('content-area').offsetHeight;
2855
+ const totalW = window.innerWidth;
2856
+ width = totalW - 300; // subtract sidebar width
2857
+ height = contentH;
2858
+ }
2859
+
2860
+ const margin = { top: 30, right: 30, bottom: 60, left: 70 };
2861
+ const innerW = width - margin.left - margin.right;
2862
+ const innerH = height - margin.top - margin.bottom;
2863
+
2864
+ const maxFreq = d3.max(hotspotData, d => d.changeFrequency) || 1;
2865
+
2866
+ const x = d3.scaleLinear().domain([0, 1]).range([0, innerW]);
2867
+ const y = d3.scaleLinear().domain([0, maxFreq * 1.1]).range([innerH, 0]);
2868
+ const r = d3.scaleSqrt().domain([0, d3.max(hotspotData, d => d.lines) || 1]).range([3, 20]);
2869
+
2870
+ const svg = d3.select('#hotspot-viz').append('svg').attr('width', width).attr('height', height);
2871
+ const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
2872
+
2873
+ // Danger zone
2874
+ g.append('rect').attr('x', 0).attr('y', 0)
2875
+ .attr('width', x(0.3)).attr('height', y(maxFreq * 0.3))
2876
+ .attr('fill', 'rgba(233, 69, 96, 0.06)');
2877
+
2878
+ // Axes
2879
+ g.append('g').attr('transform', 'translate(0,' + innerH + ')')
2880
+ .call(d3.axisBottom(x).ticks(5).tickFormat(d => Math.round(d * 100) + '%'))
2881
+ .selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
2882
+
2883
+ svg.append('text').attr('x', margin.left + innerW / 2).attr('y', height - 10)
2884
+ .attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
2885
+ .text('Familiarity \\u2192');
2886
+
2887
+ g.append('g').call(d3.axisLeft(y).ticks(6))
2888
+ .selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
2889
+
2890
+ svg.append('text').attr('transform', 'rotate(-90)')
2891
+ .attr('x', -(margin.top + innerH / 2)).attr('y', 16)
2892
+ .attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
2893
+ .text('Change Frequency (commits) \\u2192');
2894
+
2895
+ // Zone labels
2896
+ const labels = document.getElementById('zone-labels');
2897
+ labels.innerHTML = '';
2898
+ const dangerLabel = document.createElement('div');
2899
+ dangerLabel.className = 'zone-label';
2900
+ dangerLabel.style.left = (margin.left + 8) + 'px';
2901
+ dangerLabel.style.top = (margin.top + 8) + 'px';
2902
+ dangerLabel.textContent = 'DANGER ZONE';
2903
+ dangerLabel.style.color = 'rgba(233,69,96,0.25)';
2904
+ labels.appendChild(dangerLabel);
2905
+
2906
+ const safeLabel = document.createElement('div');
2907
+ safeLabel.className = 'zone-label';
2908
+ safeLabel.style.right = (320 + 40) + 'px';
2909
+ safeLabel.style.bottom = (margin.bottom + 16) + 'px';
2910
+ safeLabel.textContent = 'SAFE ZONE';
2911
+ safeLabel.style.color = 'rgba(39,174,96,0.2)';
2912
+ labels.appendChild(safeLabel);
2913
+
2914
+ // Data points
2915
+ g.selectAll('circle').data(hotspotData).join('circle')
2916
+ .attr('cx', d => x(d.familiarity))
2917
+ .attr('cy', d => y(d.changeFrequency))
2918
+ .attr('r', d => r(d.lines))
2919
+ .attr('fill', d => riskLevelColor(d.riskLevel))
2920
+ .attr('opacity', 0.7)
2921
+ .attr('stroke', 'none')
2922
+ .style('cursor', 'pointer')
2923
+ .on('mouseover', function(event, d) {
2924
+ d3.select(this).attr('opacity', 1).attr('stroke', '#fff').attr('stroke-width', 2);
2925
+ showTooltipAt(
2926
+ '<strong>' + d.path + '</strong>' +
2927
+ '<br>Familiarity: ' + Math.round(d.familiarity * 100) + '%' +
2928
+ '<br>Changes: ' + d.changeFrequency + ' commits' +
2929
+ '<br>Risk: ' + d.risk.toFixed(2) + ' (' + d.riskLevel + ')' +
2930
+ '<br>Lines: ' + d.lines.toLocaleString(),
2931
+ event
2932
+ );
2933
+ })
2934
+ .on('mousemove', moveTooltip)
2935
+ .on('mouseout', function() {
2936
+ d3.select(this).attr('opacity', 0.7).attr('stroke', 'none');
2937
+ hideTooltip();
2938
+ });
2939
+ }
2940
+
2941
+ function renderHotspotSidebar() {
2942
+ const container = document.getElementById('hotspot-list');
2943
+ const top = hotspotData.slice(0, 30);
2944
+ if (top.length === 0) {
2945
+ container.innerHTML = '<div style="color:#888">No active files in time window.</div>';
2946
+ return;
2947
+ }
2948
+ let html = '';
2949
+ for (let i = 0; i < top.length; i++) {
2950
+ const f = top[i];
2951
+ html += '<div class="hotspot-item"><div class="path">' + (i + 1) + '. ' + f.path +
2952
+ ' <span class="risk-badge risk-' + f.riskLevel + '">' + f.riskLevel.toUpperCase() + '</span></div>' +
2953
+ '<div class="meta">Fam: ' + Math.round(f.familiarity * 100) + '% | Changes: ' + f.changeFrequency + ' | Risk: ' + f.risk.toFixed(2) + '</div></div>';
2954
+ }
2955
+ container.innerHTML = html;
2956
+ }
2957
+
2958
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2959
+ // \u2500\u2500 MULTI-USER TAB \u2500\u2500
2960
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2961
+
2962
+ function initMultiUserSelect() {
2963
+ const select = document.getElementById('userSelect');
2964
+ select.innerHTML = '';
2965
+ multiUserNames.forEach((name, i) => {
2966
+ const opt = document.createElement('option');
2967
+ opt.value = i;
2968
+ const summary = multiUserSummaries[i];
2969
+ opt.textContent = name + ' (' + Math.round(summary.overallScore * 100) + '%)';
2970
+ select.appendChild(opt);
2971
+ });
2972
+ }
2973
+
2974
+ function onUserChange() {
2975
+ currentUser = parseInt(document.getElementById('userSelect').value);
2976
+ renderMultiUser();
2977
+ }
2978
+
2979
+ function getUserScore(node) {
2980
+ if (!node.userScores || node.userScores.length === 0) return node.score;
2981
+ const s = node.userScores[currentUser];
2982
+ return s ? s.score : 0;
2983
+ }
2984
+
2985
+ function renderMultiUser() {
2986
+ const container = document.getElementById('tab-multiuser');
2987
+ container.innerHTML = '';
2988
+ const height = getContentHeight();
2989
+ const width = window.innerWidth;
2990
+
2991
+ const targetNode = multiuserPath ? findNode(multiUserData, multiuserPath) : multiUserData;
2992
+ if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
2993
+
2994
+ const hierarchyData = {
2995
+ name: targetNode.path || 'root',
2996
+ children: targetNode.children.map(c => buildHierarchy(c)),
2997
+ };
2998
+
2999
+ const root = d3.hierarchy(hierarchyData)
3000
+ .sum(d => d.value || 0)
3001
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
3002
+
3003
+ d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
3004
+
3005
+ const svg = d3.select('#tab-multiuser').append('svg').attr('width', width).attr('height', height);
3006
+ const nodes = root.descendants().filter(d => d.depth > 0);
3007
+
3008
+ const groups = svg.selectAll('g').data(nodes).join('g')
3009
+ .attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
3010
+
3011
+ groups.append('rect')
3012
+ .attr('width', d => Math.max(0, d.x1 - d.x0))
3013
+ .attr('height', d => Math.max(0, d.y1 - d.y0))
3014
+ .attr('fill', d => d.data.data ? scoreColor(getUserScore(d.data.data)) : '#333')
3015
+ .attr('opacity', d => d.children ? 0.35 : 0.88)
3016
+ .attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
3017
+ .style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
3018
+ .on('click', (event, d) => {
3019
+ if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
3020
+ })
3021
+ .on('mouseover', function(event, d) {
3022
+ if (!d.data.data) return;
3023
+ d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
3024
+ let html = '<strong>' + (d.data.data.path || 'root') + '</strong>';
3025
+ if (d.data.data.userScores && d.data.data.userScores.length > 0) {
3026
+ html += '<table style="margin-top:6px;width:100%">';
3027
+ d.data.data.userScores.forEach((s, i) => {
3028
+ const isCurrent = (i === currentUser);
3029
+ const style = isCurrent ? 'font-weight:bold;color:#5eadf7' : '';
3030
+ html += '<tr style="' + style + '"><td>' + multiUserNames[i] + '</td><td style="text-align:right">' + Math.round(s.score * 100) + '%</td></tr>';
3031
+ });
3032
+ html += '</table>';
3033
+ }
3034
+ if (d.data.data.type === 'folder') {
3035
+ html += '<br>Files: ' + d.data.data.fileCount;
3036
+ html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
3037
+ } else {
3038
+ html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
3039
+ }
3040
+ showTooltipAt(html, event);
3041
+ })
3042
+ .on('mousemove', moveTooltip)
3043
+ .on('mouseout', function(event, d) {
3044
+ d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
3045
+ hideTooltip();
3046
+ });
3047
+
3048
+ groups.append('text')
3049
+ .attr('x', 4).attr('y', 14).attr('fill', '#fff')
3050
+ .attr('font-size', d => d.children ? '11px' : '10px')
3051
+ .attr('font-weight', d => d.children ? 'bold' : 'normal')
3052
+ .style('pointer-events', 'none')
3053
+ .text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
3054
+ }
3055
+
3056
+ // \u2500\u2500 Init \u2500\u2500
3057
+ rendered.scoring = true;
3058
+ renderScoring();
3059
+ window.addEventListener('resize', () => {
3060
+ if (activeTab === 'scoring') renderScoring();
3061
+ else if (activeTab === 'coverage') renderCoverage();
3062
+ else if (activeTab === 'hotspots') renderHotspot();
3063
+ else if (activeTab === 'multiuser') renderMultiUser();
3064
+ });
3065
+ </script>
3066
+ </body>
3067
+ </html>`;
3068
+ }
3069
+ async function generateAndOpenUnifiedHTML(data, repoPath) {
3070
+ const html = generateUnifiedHTML(data);
3071
+ const outputPath = join5(repoPath, "gitfamiliar-dashboard.html");
3072
+ writeFileSync5(outputPath, html, "utf-8");
3073
+ console.log(`Dashboard generated: ${outputPath}`);
3074
+ await openBrowser(outputPath);
3075
+ }
3076
+
3077
+ // src/cli/index.ts
3078
+ function collect(value, previous) {
3079
+ return previous.concat([value]);
3080
+ }
3081
+ function createProgram() {
2209
3082
  const program2 = new Command();
2210
3083
  program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.1").option(
2211
3084
  "-m, --mode <mode>",
2212
- "Scoring mode: binary, authorship, review-coverage, weighted",
3085
+ "Scoring mode: binary, authorship, weighted",
2213
3086
  "binary"
2214
3087
  ).option(
2215
3088
  "-u, --user <user>",
2216
3089
  "Git user name or email (repeatable for comparison)",
2217
3090
  collect,
2218
3091
  []
2219
- ).option(
2220
- "-f, --filter <filter>",
2221
- "Filter mode: all, written, reviewed",
2222
- "all"
2223
3092
  ).option(
2224
3093
  "-e, --expiration <policy>",
2225
3094
  "Expiration policy: never, time:180d, change:50%, combined:365d:50%",
2226
3095
  "never"
2227
3096
  ).option("--html", "Generate HTML treemap report", false).option(
2228
3097
  "-w, --weights <weights>",
2229
- 'Weights for weighted mode: blame,commit,review (e.g., "0.5,0.35,0.15")'
3098
+ 'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
2230
3099
  ).option("--team", "Compare all contributors", false).option(
2231
3100
  "--team-coverage",
2232
3101
  "Show team coverage map (bus factor analysis)",
@@ -2234,19 +3103,14 @@ function createProgram() {
2234
3103
  ).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
2235
3104
  "--window <days>",
2236
3105
  "Time window for hotspot analysis in days (default: 90)"
2237
- ).option(
2238
- "--github-url <hostname>",
2239
- "GitHub Enterprise hostname (e.g. ghe.example.com). Auto-detected from git remote if omitted."
2240
- ).option(
2241
- "--check-github",
2242
- "Verify GitHub API connectivity and show connection info",
2243
- false
2244
3106
  ).action(async (rawOptions) => {
2245
3107
  try {
2246
3108
  const repoPath = process.cwd();
2247
3109
  const options = parseOptions(rawOptions, repoPath);
2248
- if (options.checkGithub) {
2249
- await checkGitHubConnection(repoPath, options.githubUrl);
3110
+ const isMultiUserCheck = options.team || Array.isArray(options.user) && options.user.length > 1;
3111
+ if (options.html && !options.hotspot && !options.teamCoverage && !isMultiUserCheck) {
3112
+ const data = await computeUnified(options);
3113
+ await generateAndOpenUnifiedHTML(data, repoPath);
2250
3114
  return;
2251
3115
  }
2252
3116
  if (options.hotspot) {