processor/history_hotspots.go GO 429 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"encoding/csv"7	"fmt"8	"os"9	"slices"10	"strings"1112	jsoniter "github.com/json-iterator/go"13	glanguage "golang.org/x/text/language"14	gmessage "golang.org/x/text/message"15)1617// HotspotsJSONReport walks the git history at repoPath and returns the18// hotspots report as a JSON string. It is the programmatic entry point used by19// the MCP server, which needs the rendered data rather than the stdout/file20// side effects of runHotspotsReport. A limit > 0 caps the number of files in21// the output (highest-scoring first); limit <= 0 returns every scored file.22// HistoryDepth and the mailmap folding behave exactly as on the CLI path.23func HotspotsJSONReport(repoPath string, limit int) (string, error) {24	observer := newHotspotsObserver()25	if _, err := runHistory(repoPath, observer); err != nil {26		return "", err27	}28	return renderHotspotsJSONLimited(observer, limit)29}3031// runHotspotsReport is the dispatch entry point called from Process() when32// --hotspots is set. Opens the repo at repoPath, walks history, and writes33// the chosen format to stdout or FileOutput.34func runHotspotsReport(repoPath string) error {35	observer := newHotspotsObserver()36	if _, err := runHistory(repoPath, observer); err != nil {37		return err38	}39	out, err := renderHotspots(observer)40	if err != nil {41		return err42	}43	if FileOutput == "" {44		fmt.Print(out)45	} else {46		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {47			return err48		}49		fmt.Println("results written to " + FileOutput)50	}51	return nil52}5354// hotspotsRecord is the per-file accumulator and the final row.55type hotspotsRecord struct {56	File         string57	Language     string58	Complexity   int6459	Cognitive    int64 // nesting-weighted complexity; drives Score when Cognitive is enabled60	Commits      int61	LinesChanged int6462	Authors      map[authorID]struct{}63	CodeChurn    int6464	CommentChurn int6465	Score        float6466}6768// hotspotsObserver accumulates per-file commit / churn / author stats during69// the walk, then materialises the table at Finalise using the HEAD snapshot70// for current language and complexity. Implements MailmapObserver so the71// Authrs column folds identities the same way the author rollup does,72// without paying for the full baseline tree classification.73type hotspotsObserver struct {74	files    map[string]*hotspotsRecord75	registry *authorRegistry76	window   HistoryWindow77	snapshot HeadSnapshot78	records  []hotspotsRecord79	totalRaw int // total files seen across the window (for the "X of Y" footer)80}8182func newHotspotsObserver() *hotspotsObserver {83	return &hotspotsObserver{84		files:    map[string]*hotspotsRecord{},85		registry: newAuthorRegistry(nil),86	}87}8889// SetMailmap satisfies MailmapObserver — rebuilds the registry with the90// repo's .mailmap so Authrs folds identities the same way the author rollup91// does.92func (o *hotspotsObserver) SetMailmap(mm *mailmap) {93	o.registry = newAuthorRegistry(mm)94}9596func (o *hotspotsObserver) Observe(c CommitInfo, changes []FileChange) {97	aid := o.registry.intern(c.Author, c.Email)98	for _, fc := range changes {99		// Rename: migrate the old path's accumulator so churn history is100		// continuous across the rename.101		if fc.FromPath != "" && fc.FromPath != fc.Path {102			if old, ok := o.files[fc.FromPath]; ok {103				old.File = fc.Path104				o.files[fc.Path] = old105				delete(o.files, fc.FromPath)106			}107		}108		rec := o.files[fc.Path]109		if rec == nil {110			rec = &hotspotsRecord{111				File:    fc.Path,112				Authors: map[authorID]struct{}{},113			}114			o.files[fc.Path] = rec115		}116		rec.Commits++117		added := countRangeLines(fc.AddedRanges)118		removed := countRangeLines(fc.RemovedRanges)119		rec.LinesChanged += int64(added + removed)120		rec.Authors[aid] = struct{}{}121122		code, comment := splitChurnByType(fc.AddedRanges, fc.LineTypes)123		rec.CodeChurn += int64(code)124		rec.CommentChurn += int64(comment)125	}126}127128func (o *hotspotsObserver) Finalise(window HistoryWindow, head HeadSnapshot) {129	o.window = window130	o.snapshot = head131	o.totalRaw = 0132133	for path, rec := range o.files {134		hf, alive := head.Files[path]135		if !alive {136			continue137		}138		rec.Language = hf.Language139		rec.Complexity = hf.Complexity140		rec.Cognitive = hf.Cognitive141		o.totalRaw++142143		// When cognitive complexity is enabled, rank by nesting-weighted144		// magnitude so deeply-nested churned code outscores flat branch-heavy145		// code with the same cyclomatic count. Default (flat) weighting is146		// unchanged.147		magnitude := rec.Complexity148		if Cognitive {149			magnitude = rec.Cognitive150		}151		score := float64(magnitude) * float64(rec.Commits)152		rec.Score = score153	}154155	records := make([]hotspotsRecord, 0, o.totalRaw)156	for path, rec := range o.files {157		if _, alive := head.Files[path]; !alive {158			continue159		}160		records = append(records, *rec)161	}162163	// Normalise 0–100 across the surviving set.164	maxScore := 0.0165	for _, r := range records {166		if r.Score > maxScore {167			maxScore = r.Score168		}169	}170	for i := range records {171		if maxScore > 0 {172			records[i].Score = records[i].Score / maxScore * 100.0173		}174	}175176	slices.SortFunc(records, func(a, b hotspotsRecord) int {177		if a.Score == b.Score {178			return strings.Compare(a.File, b.File)179		}180		if a.Score < b.Score {181			return 1182		}183		return -1184	})185	o.records = records186}187188// countRangeLines sums the line counts across a slice of line ranges.189func countRangeLines(ranges []LineRange) int {190	total := 0191	for _, r := range ranges {192		total += r.Count193	}194	return total195}196197// splitChurnByType classifies *added* lines only into code vs comment buckets198// using the per-line LineType vector for the new blob. Removed lines aren't199// classified — the old blob isn't fetched on the churn path. Reported as200// +Code% in the tabular output. Blank lines don't count toward either bucket.201func splitChurnByType(added []LineRange, lineTypes []LineType) (code, comment int) {202	for _, r := range added {203		for i := 0; i < r.Count; i++ {204			ln := r.Start - 1 + i // 0-based index into lineTypes205			if ln < 0 || ln >= len(lineTypes) {206				continue207			}208			switch lineTypes[ln] {209			case LINE_CODE:210				code++211			case LINE_COMMENT:212				comment++213			}214		}215	}216	return217}218219// renderHotspots returns the formatted output for the chosen --format.220func renderHotspots(o *hotspotsObserver) (string, error) {221	switch strings.ToLower(Format) {222	case "", "tabular", "wide":223		return renderHotspotsTabular(o), nil224	case "csv":225		return renderHotspotsCSV(o)226	case "json":227		return renderHotspotsJSON(o)228	default:229		return "", fmt.Errorf("unsupported --format %q for --hotspots (supported: tabular, csv, json)", Format)230	}231}232233// Tabular column formats.234//235//	%-27s %8s %7s %8s %8s %7s %8s236//	27 + 1 + 8 + 1 + 7 + 1 + 8 + 1 + 8 + 1 + 7 + 1 + 8 = 79237var tabularShortHotspotsFormatHead = "%-27s %8s %7s %8s %8s %7s %8s\n"238var tabularShortHotspotsFormatBody = "%-27s %8s %7d %8d %8s %7d %8.1f\n"239240// Wide variant — 109 columns, adds a 16-char hotspot bar and a +Code% column241// (%-share of *added* lines that were code; removed lines aren't classified).242// The bar carries the wide layout's extra width so it reads as a real chart;243// the File column stays modest rather than padding dead space to the rule.244//245//	%-32s %8s %7s %8s %8s %7s %8s %7s %-16s246//	32 + 1 + 8 + 1 + 7 + 1 + 8 + 1 + 8 + 1 + 7 + 1 + 8 + 1 + 7 + 1 + 16 = 109247var tabularWideHotspotsFormatHead = "%-32s %8s %7s %8s %8s %7s %8s %7s %-16s\n"248var tabularWideHotspotsFormatBody = "%-32s %8s %7d %8d %8s %7d %8.1f %6.1f%% %-16s\n"249250func renderHotspotsTabular(o *hotspotsObserver) string {251	wide := More || strings.EqualFold(Format, "wide")252	brk := tabularBreakFor(wide)253254	var sb strings.Builder255	sb.WriteString(historyHeader("Hotspots", o.window, wide))256257	printer := gmessage.NewPrinter(glanguage.Make(os.Getenv("LANG")))258	if wide {259		_, _ = fmt.Fprintf(&sb, tabularWideHotspotsFormatHead,260			"File", "Lang", "Cmplx", "Commits", "Lines±", "Authrs", "Hotspot", "+Code%", "Bar")261	} else {262		_, _ = fmt.Fprintf(&sb, tabularShortHotspotsFormatHead,263			"File", "Lang", "Cmplx", "Commits", "Lines±", "Authrs", "Hotspot")264	}265	sb.WriteString(brk)266267	shown := 0268	for _, r := range o.records {269		// Score 0 means no complexity signal — not a hotspot. The CSV and JSON270		// renderers skip these too, so the tabular view matches rather than271		// trailing the list with zero-score noise.272		if r.Score <= 0 {273			continue274		}275		shown++276		fileTrim, fileWidth := 26, 27277		if wide {278			fileTrim, fileWidth = 31, 32279		}280		fileCol := unicodeAwareTrim(r.File, fileTrim)281		fileCol = unicodeAwareRightPad(fileCol, fileWidth)282		langCol := trimLanguageShort(r.Language, 8)283		linesCol := formatWithCommas(printer, r.LinesChanged)284		if wide {285			codeShare := 0.0286			totalChurn := r.CodeChurn + r.CommentChurn287			if totalChurn > 0 {288				codeShare = float64(r.CodeChurn) / float64(totalChurn) * 100.0289			}290			bar := renderBar(r.Score/100.0, 16)291			_, _ = fmt.Fprintf(&sb, tabularWideHotspotsFormatBody,292				fileCol, langCol, r.Complexity, r.Commits, linesCol,293				len(r.Authors), r.Score, codeShare, bar)294		} else {295			_, _ = fmt.Fprintf(&sb, tabularShortHotspotsFormatBody,296				fileCol, langCol, r.Complexity, r.Commits, linesCol,297				len(r.Authors), r.Score)298		}299	}300301	sb.WriteString(brk)302	if shown > 0 {303		footer := fmt.Sprintf("complexity × change-frequency, normalised · %d files", shown)304		sb.WriteString(footer)305		sb.WriteByte('\n')306		sb.WriteString(brk)307	}308	return sb.String()309}310311func formatWithCommas(p *gmessage.Printer, n int64) string {312	return p.Sprintf("%d", n)313}314315func trimLanguageShort(lang string, size int) string {316	if len(lang) <= size {317		return lang318	}319	// keep most informative bit320	return lang[:size-1] + "…"321}322323func renderHotspotsCSV(o *hotspotsObserver) (string, error) {324	var sb strings.Builder325	sb.WriteString(formatWindowComment(o.window))326	sb.WriteByte('\n')327328	w := csv.NewWriter(&sb)329	_ = w.Write([]string{330		"File", "Language", "Complexity", "Commits",331		"LinesChanged", "Authors", "CodeChurn", "CommentChurn", "Score",332	})333334	for _, r := range o.records {335		if r.Score <= 0 {336			continue337		}338		_ = w.Write([]string{339			r.File,340			r.Language,341			fmt.Sprintf("%d", r.Complexity),342			fmt.Sprintf("%d", r.Commits),343			fmt.Sprintf("%d", r.LinesChanged),344			fmt.Sprintf("%d", len(r.Authors)),345			fmt.Sprintf("%d", r.CodeChurn),346			fmt.Sprintf("%d", r.CommentChurn),347			fmt.Sprintf("%.1f", r.Score),348		})349	}350	w.Flush()351	if err := w.Error(); err != nil {352		return "", err353	}354	return sb.String(), nil355}356357type hotspotsJSONFile struct {358	File         string  `json:"file"`359	Language     string  `json:"language"`360	Complexity   int64   `json:"complexity"`361	Commits      int     `json:"commits"`362	LinesChanged int64   `json:"linesChanged"`363	Authors      int     `json:"authors"`364	CodeChurn    int64   `json:"codeChurn"`365	CommentChurn int64   `json:"commentChurn"`366	Score        float64 `json:"score"`367}368369type hotspotsJSONWindow struct {370	Depth   int    `json:"depth"`371	Commits int    `json:"commits"`372	From    string `json:"from"`373	To      string `json:"to"`374}375376type hotspotsJSONDoc struct {377	Report string             `json:"report"`378	Window hotspotsJSONWindow `json:"window"`379	Files  []hotspotsJSONFile `json:"files"`380}381382func renderHotspotsJSON(o *hotspotsObserver) (string, error) {383	return renderHotspotsJSONLimited(o, 0)384}385386// renderHotspotsJSONLimited renders the JSON document, capping the file list at387// limit rows (highest-scoring first, matching the sort applied in Finalise). A388// limit <= 0 includes every scored file, preserving the uncapped CLI behaviour.389func renderHotspotsJSONLimited(o *hotspotsObserver, limit int) (string, error) {390	doc := hotspotsJSONDoc{391		Report: "hotspots",392		Window: hotspotsJSONWindow{393			Depth:   o.window.Depth,394			Commits: o.window.Commits,395			From:    formatWindowDate(o.window.From),396			To:      formatWindowDate(o.window.To),397		},398		Files: make([]hotspotsJSONFile, 0, len(o.records)),399	}400	for _, r := range o.records {401		if r.Score <= 0 {402			continue403		}404		if limit > 0 && len(doc.Files) >= limit {405			break406		}407		doc.Files = append(doc.Files, hotspotsJSONFile{408			File:         r.File,409			Language:     r.Language,410			Complexity:   r.Complexity,411			Commits:      r.Commits,412			LinesChanged: r.LinesChanged,413			Authors:      len(r.Authors),414			CodeChurn:    r.CodeChurn,415			CommentChurn: r.CommentChurn,416			Score:        round1(r.Score),417		})418	}419	b, err := jsoniter.Marshal(doc)420	if err != nil {421		return "", err422	}423	return string(b), nil424}425426func round1(f float64) float64 {427	return float64(int64(f*10+0.5)) / 10428}

Code quality findings 10

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularWideHotspotsFormatHead,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularShortHotspotsFormatHead,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularWideHotspotsFormatBody,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularShortHotspotsFormatBody,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
Unstructured output; use a structured logging library (e.g., slog, zap, zerolog, logrus)
info correctness fmt-println
fmt.Println("results written to " + FileOutput)
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
if old, ok := o.files[fc.FromPath]; ok {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for path, rec := range o.files {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for path, rec := range o.files {

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.