Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/report/profile_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
<span style="background:#0969da; color:#fff; border-radius:8px; padding:0 6px; font-size:10px;" title="{{.SharedFiles}} files / {{.SharedLines}} lines">{{.SharedFiles}} files · {{humanize .SharedLines}} lines</span>
</span>
{{end}}
{{if gt .Profile.CollaboratorsHidden 0}}
<span style="display:inline-flex; align-items:center; padding:3px 10px; font-size:11px; color:#656d76; font-style:italic;">+{{.Profile.CollaboratorsHidden}} more collaborators not shown</span>
{{end}}
</div>
</div>
{{end}}
Expand All @@ -145,6 +148,9 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
<td style="width:30%"><div style="display:flex;"><div class="bar bar-churn" style="width:{{pct .Churn $maxChurn}}%"></div></div></td>
</tr>
{{end}}
{{if gt .Profile.TopFilesHidden 0}}
<tr><td colspan="4" style="color:#656d76; font-style:italic; text-align:center;">+{{.Profile.TopFilesHidden}} more files not shown</td></tr>
{{end}}
</table>
{{end}}

Expand Down
17 changes: 17 additions & 0 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ type ReportData struct {
// so mature repos (linux-scale) don't blow up the HTML. nil when
// the dataset has no files.
Structure *TreeNode

// TotalDirectories / TotalExtensions / TotalBusFactorFiles are
// the full universe sizes (before top-N truncation) for sections
// whose denominators aren't in Summary. Templates render "20 of
// 127" headers so the reader sees the scale of what's been
// truncated. TotalBusFactorFiles specifically excludes files
// with empty devLines (pure-rename-only files post-ingest-fix)
// because BusFactor skips those — using Summary.TotalFiles here
// would make the header lie on rename-heavy repos. Summary
// already carries TotalDevs / TotalFiles / TotalCommits for the
// rest.
TotalDirectories int
TotalExtensions int
TotalBusFactorFiles int
}

// htmlTreeDepth caps the repo-structure tree baked into the HTML report.
Expand Down Expand Up @@ -359,6 +373,9 @@ func Generate(w io.Writer, ds *stats.Dataset, repoName string, topN int, sf stat
PatternGrid: grid,
MaxPattern: maxP,
Structure: BuildRepoTree(stats.FileHotspots(ds, 0), htmlTreeDepth),
TotalDirectories: stats.DirectoryCount(ds),
TotalExtensions: stats.ExtensionCount(ds),
TotalBusFactorFiles: stats.BusFactorCount(ds),
}
CapChildrenPerDir(data.Structure, htmlTreeMaxChildrenPerDir)

Expand Down
19 changes: 11 additions & 8 deletions internal/report/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .Contributors}}
<h2>Top Contributors</h2>
<h2>Top Contributors{{if lt (len .Contributors) .Summary.TotalDevs}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .Contributors)}} of {{thousands .Summary.TotalDevs}}</span>{{end}}</h2>
<p class="hint">Ranked by commit count. High commit count with low lines may indicate small fixes; low count with high lines may indicate large features. · {{docRef "contributors"}}</p>
<table>
<tr><th>Name</th><th>Email</th><th>Commits</th><th></th><th>Additions</th><th>Deletions</th></tr>
Expand All @@ -204,7 +204,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .Hotspots}}
<h2>File Hotspots</h2>
<h2>File Hotspots{{if lt (len .Hotspots) .Summary.TotalFiles}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .Hotspots)}} of {{thousands .Summary.TotalFiles}}</span>{{end}}</h2>
<p class="hint">Most frequently changed files. High churn with few devs = knowledge silo. High churn with many devs = shared bottleneck. · {{docRef "hotspots"}}</p>
<table>
<tr><th>Path</th><th>Commits</th><th>Churn</th><th></th><th>Devs</th></tr>
Expand All @@ -222,7 +222,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .Directories}}
<h2>Directories</h2>
<h2>Directories{{if lt (len .Directories) .TotalDirectories}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .Directories)}} of {{thousands .TotalDirectories}}</span>{{end}}</h2>
<p class="hint">Module-level health. <b>File touches</b> is the sum of per-file commit counts (one commit touching N files contributes N), not distinct commits. Low bus factor = knowledge concentrated in few people. · {{docRef "directories"}}</p>
<table>
<tr><th>Directory</th><th>File Touches</th><th>Churn</th><th>Files</th><th>Devs</th><th>Bus Factor</th></tr>
Expand All @@ -240,7 +240,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .Extensions}}
<h2>Extensions</h2>
<h2>Extensions{{if lt (len .Extensions) .TotalExtensions}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .Extensions)}} of {{thousands .TotalExtensions}}</span>{{end}}</h2>
<p class="hint">File extensions ranked by <b>recent churn</b> — "where is the team spending effort now", not "what exists at HEAD". Cross-read with Directories: a repo with high <code>.yaml</code> recent churn concentrated in one dir is config-as-code; spread across many dirs is config sprawl. · {{docRef "extensions"}}</p>
<table>
<tr><th>Ext</th><th>Files</th><th>Churn</th><th>Recent Churn</th><th></th><th>Devs</th><th>First Seen</th><th>Last Seen</th></tr>
Expand All @@ -261,7 +261,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .ChurnRisk}}
<h2>Churn Risk</h2>
<h2>Churn Risk{{if lt (len .ChurnRisk) .Summary.TotalFiles}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .ChurnRisk)}} of {{thousands .Summary.TotalFiles}}</span>{{end}}</h2>
<p class="hint">Files ranked by recent churn. Label classifies context so you can judge action: <b>legacy-hotspot</b> (old code + concentrated + declining) is the urgent alarm; <b>silo</b> suggests knowledge transfer; <b>active-core</b> is young code with a single author (often fine); <b>active</b> is shared healthy work; <b>cold</b> is quiet.{{if (index .ChurnRisk 0).AgePercentile}} <b>Age P__ / Trend P__</b> under the label show where this file sits in the repo's distribution: age P90 means older than 90% of tracked files; trend P10 means declining more sharply than 90%. Classification boundaries are the P75 age and P25 trend of this dataset (see {{docRef "churn-risk"}}).{{end}}</p>
{{if .ChurnRiskLabelCounts}}
<div class="churn-chips">
Expand Down Expand Up @@ -290,7 +290,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .BusFactor}}
<h2>Bus Factor Risk</h2>
<h2>Bus Factor Risk{{if lt (len .BusFactor) .TotalBusFactorFiles}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .BusFactor)}} of {{thousands .TotalBusFactorFiles}}</span>{{end}}</h2>
<p class="hint">Files with fewest developers owning 80%+ of changes. Bus factor 1 = if that person leaves, nobody else knows the code. · {{docRef "bus-factor"}}</p>
<table>
<tr><th>Path</th><th>Bus Factor</th><th>Top Devs</th></tr>
Expand Down Expand Up @@ -337,7 +337,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
{{end}}

{{if .TopCommits}}
<h2>Top Commits</h2>
<h2>Top Commits{{if lt (len .TopCommits) .Summary.TotalCommits}} <span style="font-size:13px; color:#656d76; font-weight:normal;">{{thousands (len .TopCommits)}} of {{thousands .Summary.TotalCommits}}</span>{{end}}</h2>
<p class="hint">Largest commits by lines changed. Unusually large commits may be imports, generated code, or risky big-bang changes worth reviewing. · {{docRef "top-commits"}}</p>
<table>
<tr><th>SHA</th><th>Author</th><th>Date</th><th>Lines</th><th>Files</th>{{if and (gt (len .TopCommits) 0) (index .TopCommits 0).Message}}<th>Message</th>{{end}}</tr>
Expand Down Expand Up @@ -398,7 +398,7 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
<span>{{printf "%.1f" .Pace}} commits/active day</span>

<span style="color:#656d76;">Collaboration</span>
<span>{{if .Collaborators}}{{range $i, $c := .Collaborators}}{{if $i}}, {{end}}{{$c.Email}} ({{thousands $c.SharedFiles}} files, {{thousands $c.SharedLines}} lines){{end}}{{else}}solo contributor{{end}}</span>
<span>{{if .Collaborators}}{{range $i, $c := .Collaborators}}{{if $i}}, {{end}}{{$c.Email}} ({{thousands $c.SharedFiles}} files, {{thousands $c.SharedLines}} lines){{end}}{{if gt .CollaboratorsHidden 0}} <span style="color:#656d76; font-style:italic;">(+{{.CollaboratorsHidden}} more)</span>{{end}}{{else}}solo contributor{{end}}</span>

<span style="color:#656d76;">Weekend</span>
<span>{{printf "%.1f" .WeekendPct}}%</span>
Expand All @@ -414,6 +414,9 @@ footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; col
<span style="color:#656d76;">{{thousands .Churn}} churn</span>
</div>
{{end}}
{{if gt .TopFilesHidden 0}}
<div style="color:#656d76; font-style:italic; margin-top:2px;">+{{.TopFilesHidden}} more files not shown</div>
{{end}}
</div>
{{end}}
</div>
Expand Down
119 changes: 119 additions & 0 deletions internal/stats/extension_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stats

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -56,6 +57,62 @@ func TestExtractExtensionPolicy(t *testing.T) {
}
}

// DirectoryCount and ExtensionCount back the "N of M" header badges
// in the main report. They must match what DirectoryStats and
// ExtensionStats would produce pre-truncation — otherwise "showing 20
// of 127" lies when the user expands to --top 0 and finds a
// different number.
func TestDirectoryCountAndExtensionCount(t *testing.T) {
ds := &Dataset{
files: map[string]*fileEntry{
"cmd/main.go": {devLines: map[string]int64{"a": 1}},
"cmd/util.go": {devLines: map[string]int64{"a": 1}},
"internal/a.go": {devLines: map[string]int64{"a": 1}},
"internal/b.go": {devLines: map[string]int64{"a": 1}},
"docs/x.md": {devLines: map[string]int64{"a": 1}},
"README.md": {devLines: map[string]int64{"a": 1}}, // "." bucket
"Makefile": {devLines: map[string]int64{"a": 1}}, // "." bucket, (none) ext
},
}
// Distinct dirs: "cmd", "internal", "docs", "." → 4
if n := DirectoryCount(ds); n != 4 {
t.Errorf("DirectoryCount = %d, want 4", n)
}
// Distinct exts: ".go", ".md", "(none)" → 3
if n := ExtensionCount(ds); n != 3 {
t.Errorf("ExtensionCount = %d, want 3", n)
}
// Consistency invariant: must match len of the stats function's
// full output. If they ever drift, the "N of M" header lies.
if got, want := DirectoryCount(ds), len(DirectoryStats(ds, 0)); got != want {
t.Errorf("DirectoryCount (%d) != len(DirectoryStats(_, 0)) (%d)", got, want)
}
if got, want := ExtensionCount(ds), len(ExtensionStats(ds, 0)); got != want {
t.Errorf("ExtensionCount (%d) != len(ExtensionStats(_, 0)) (%d)", got, want)
}
}

// BusFactorCount must exclude pure-rename files (empty devLines) —
// those are skipped by BusFactor itself and so cannot be part of its
// denominator. Using Summary.TotalFiles would over-count here. Build
// a dataset with one file that carries dev lines and one that
// doesn't; assert BusFactorCount == 1 and matches the real output.
func TestBusFactorCountExcludesEmptyDevLines(t *testing.T) {
ds := &Dataset{
UniqueFileCount: 2, // Summary total; includes both
files: map[string]*fileEntry{
"src/authored.go": {devLines: map[string]int64{"alice@x": 10}},
"src/pure-rename-only": {devLines: map[string]int64{}}, // no authored lines
},
}
if got := BusFactorCount(ds); got != 1 {
t.Errorf("BusFactorCount = %d, want 1 (pure-rename file must be excluded)", got)
}
if got, want := BusFactorCount(ds), len(BusFactor(ds, 0)); got != want {
t.Errorf("BusFactorCount (%d) != len(BusFactor(_, 0)) (%d) — header would lie", got, want)
}
}

func TestExtensionStatsAggregation(t *testing.T) {
// Hand-built dataset so aggregation is inspectable: two .go files
// with distinct devs, one .yaml shared by both, and a Makefile
Expand Down Expand Up @@ -667,6 +724,62 @@ func TestDevProfileHiddenCounters(t *testing.T) {
}
}

// Completes the Hidden-counter family: TopFiles truncates at 10,
// Collaborators at 5. Both used to drop buckets silently — the "+N
// more" surfaced for Scope/Extensions had no counterpart here, so a
// dev with 25 touched files or 12 frequent collaborators looked like
// they had exactly 10 / 5. Build a dev with 12 files and 7
// collaborators; assert the counters report the true drop count.
func TestDevProfileHiddenCountersTopFilesAndCollaborators(t *testing.T) {
files := map[string]*fileEntry{}
// 12 files authored by alice → TopFilesHidden = 2.
for i := 0; i < 12; i++ {
path := fmt.Sprintf("dir%d/file%d.go", i, i)
files[path] = &fileEntry{
devLines: map[string]int64{"alice@x": int64(100 - i*5)},
devCommits: map[string]int{"alice@x": 1},
}
}
// Seed 6 shared files between alice and each of 6 other devs →
// alice has 6 collaborators total; top-5 truncation gives
// CollaboratorsHidden = 1.
for i := 0; i < 6; i++ {
path := fmt.Sprintf("shared/collab%d.go", i)
collab := fmt.Sprintf("bob%d@x", i)
files[path] = &fileEntry{
devLines: map[string]int64{"alice@x": 50, collab: 50},
devCommits: map[string]int{"alice@x": 1, collab: 1},
}
}
contribs := map[string]*ContributorStat{
"alice@x": {Email: "alice@x", Commits: 18, FilesTouched: 18, ActiveDays: 1},
}
for i := 0; i < 6; i++ {
contribs[fmt.Sprintf("bob%d@x", i)] = &ContributorStat{
Email: fmt.Sprintf("bob%d@x", i), Commits: 1, FilesTouched: 1, ActiveDays: 1,
}
}
ds := &Dataset{
contributors: contribs,
files: files,
commits: map[string]*commitEntry{},
workGrid: [7][24]int{},
}
p := DevProfiles(ds, "alice@x", 0)[0]
if len(p.TopFiles) != 10 {
t.Fatalf("TopFiles len = %d, want 10 (truncated from 18)", len(p.TopFiles))
}
if p.TopFilesHidden != 8 {
t.Errorf("TopFilesHidden = %d, want 8 (18 - 10)", p.TopFilesHidden)
}
if len(p.Collaborators) != 5 {
t.Fatalf("Collaborators len = %d, want 5 (truncated from 6)", len(p.Collaborators))
}
if p.CollaboratorsHidden != 1 {
t.Errorf("CollaboratorsHidden = %d, want 1 (6 - 5)", p.CollaboratorsHidden)
}
}

// Silent when nothing to hide — the counters must be zero so the
// renderers don't emit "+0 more" (noise) for the common case.
func TestDevProfileHiddenCountersZeroWhenFits(t *testing.T) {
Expand All @@ -687,6 +800,12 @@ func TestDevProfileHiddenCountersZeroWhenFits(t *testing.T) {
t.Errorf("Hidden counters: Scope=%d Ext=%d, want 0/0 (dev has ≤5 buckets each)",
p.ScopeHidden, p.ExtensionsHidden)
}
// TopFiles cap is 10, Collaborators cap is 5 — bob has 3 files
// and zero collaborators, so both must stay at 0.
if p.TopFilesHidden != 0 || p.CollaboratorsHidden != 0 {
t.Errorf("Hidden counters: TopFiles=%d Collab=%d, want 0/0",
p.TopFilesHidden, p.CollaboratorsHidden)
}
}

// Truncate to top-5 when a dev's extension set is larger. Under the
Expand Down
6 changes: 6 additions & 0 deletions internal/stats/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,9 @@ func (f *Formatter) PrintProfiles(profiles []DevProfile) error {
}
fmt.Fprintf(f.w, "%s (%d shared)", c.Email, c.SharedFiles)
}
if p.CollaboratorsHidden > 0 {
fmt.Fprintf(f.w, " (+%d more)", p.CollaboratorsHidden)
}
} else {
fmt.Fprintf(f.w, "solo contributor")
}
Expand All @@ -512,6 +515,9 @@ func (f *Formatter) PrintProfiles(profiles []DevProfile) error {
for _, tf := range p.TopFiles {
fmt.Fprintf(f.w, " %-50s %3d commits %6d churn\n", tf.Path, tf.Commits, tf.Churn)
}
if p.TopFilesHidden > 0 {
fmt.Fprintf(f.w, " ... (+%d more files not shown)\n", p.TopFilesHidden)
}
}

if len(p.MonthlyActivity) > 0 {
Expand Down
Loading
Loading