Blank identifier discarding results; verify intentional ignoring of return values
_, _ = fmt.Fprintf(&sb, tabularWideHotspotsFormatHead,
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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.