Blank identifier discarding results; verify intentional ignoring of return values
_, _ = fmt.Fprintf(&sb, tabularWideAuthorsFormatHead,
1// SPDX-License-Identifier: MIT23package processor45import (6 "encoding/csv"7 "fmt"8 "os"9 "slices"10 "strings"11 "time"1213 jsoniter "github.com/json-iterator/go"14 "github.com/mattn/go-runewidth"15 glanguage "golang.org/x/text/language"16 gmessage "golang.org/x/text/message"17)1819// authorsTopN is the cap on tabular rows for the author rollup. CSV/JSON20// output is not capped. The remainder collapses into a single "others (N)"21// row in the tabular table.22const authorsTopN = 152324// authorNameColWidth / authorNameTrim are the trim/pad widths for the25// "Author" column. The 79-col report uses the short pair; the 109-col --wide26// report uses the wide pair so the table fills the wider rule instead of27// leaving the break line hanging past the content.28const (29 authorNameTrim = 3030 authorNameColWidth = 313132 authorNameTrimWide = 6033 authorNameColWidthWide = 6134)3536// authorRow is one materialised row in the report. Sentinel is true for the37// "(before window)" pseudo-author whose lines pre-date the walk window.38type authorRow struct {39 Name string40 // Display is the name as rendered in the tabular report and bus-factor41 // footer. It equals Name unless two or more in-window identities share42 // the same Name, in which case each colliding row is suffixed with a43 // distinguishing marker (see disambiguateNames) so the reader can tell44 // the identities apart. CSV/JSON emit Name and Email raw and ignore this.45 Display string46 Email string47 Code int6448 Comment int6449 Complexity int6450 Files int51 OwnsPercent float6452 InWindowPercent float6453 LastCommit time.Time54 Sentinel bool55}5657// historyAuthorsObserver accumulates per-file forward-replay blame during58// the walk, then collapses it into per-author totals on Finalise. It59// implements both CommitObserver and BaselineObserver, so the engine seeds60// it with the pre-window tree state (and the .mailmap) before the walk.61type historyAuthorsObserver struct {62 blame map[string][]authorID63 lineTypes map[string][]LineType64 complexity map[string][]int6566 registry *authorRegistry67 lastSeen map[authorID]time.Time6869 window HistoryWindow70 snapshot HeadSnapshot7172 rows []authorRow73 busFactor int74 busAuthors []string75 busCovered float6476 inWindowCode int6477}7879func newHistoryAuthorsObserver() *historyAuthorsObserver {80 return &historyAuthorsObserver{81 blame: map[string][]authorID{},82 lineTypes: map[string][]LineType{},83 complexity: map[string][]int{},84 lastSeen: map[authorID]time.Time{},85 registry: newAuthorRegistry(nil),86 }87}8889// Seed installs the mailmap and seeds the per-file blame maps from the90// baseline snapshot — every pre-window line maps to sentinelAuthorID so91// surviving untouched lines are correctly attributed to "(before window)".92func (o *historyAuthorsObserver) Seed(baseline BaselineSnapshot) {93 o.registry = newAuthorRegistry(baseline.Mailmap)94 for path, bf := range baseline.Files {95 n := len(bf.LineTypes)96 if n == 0 {97 continue98 }99 o.blame[path] = make([]authorID, n) // zero value = sentinelAuthorID100 o.lineTypes[path] = bf.LineTypes101 o.complexity[path] = bf.Complexity102 }103}104105func (o *historyAuthorsObserver) Observe(c CommitInfo, changes []FileChange) {106 aid := o.registry.intern(c.Author, c.Email)107 if prev, ok := o.lastSeen[aid]; !ok || c.When.After(prev) {108 o.lastSeen[aid] = c.When109 }110 for _, fc := range changes {111 prev := o.blame[fc.Path]112 // Rename: carry the old path's per-line blame forward as the prior113 // state, then drop the stale key so it is not double-counted. A114 // pure rename has no Added/Removed ranges, so applyDiffToBlame just115 // copies the carried-forward blame — every line keeps its original116 // author. A rename with edits attributes only the edited lines to117 // the renaming commit.118 if fc.FromPath != "" && fc.FromPath != fc.Path {119 if oldBlame, ok := o.blame[fc.FromPath]; ok {120 prev = oldBlame121 delete(o.blame, fc.FromPath)122 delete(o.lineTypes, fc.FromPath)123 delete(o.complexity, fc.FromPath)124 }125 }126 newN := len(fc.LineTypes)127 o.blame[fc.Path] = applyDiffToBlame(prev, newN, fc.AddedRanges, fc.RemovedRanges, aid)128 o.lineTypes[fc.Path] = fc.LineTypes129 o.complexity[fc.Path] = fc.Complexity130 }131}132133func (o *historyAuthorsObserver) Finalise(window HistoryWindow, head HeadSnapshot) {134 o.window = window135 o.snapshot = head136137 type acc struct {138 Code int64139 Comment int64140 Complexity int64141 Files int142 }143 totals := map[authorID]*acc{}144 var grandCode int64145146 for path, blame := range o.blame {147 if _, alive := head.Files[path]; !alive {148 continue149 }150 types := o.lineTypes[path]151 perFile := map[authorID]int64{}152153 for i := 0; i < len(blame) && i < len(types); i++ {154 aid := blame[i]155 a := totals[aid]156 if a == nil {157 a = &acc{}158 totals[aid] = a159 }160 switch types[i] {161 case LINE_CODE:162 a.Code++163 perFile[aid]++164 grandCode++165 case LINE_COMMENT:166 a.Comment++167 }168 }169 for _, lineNo := range o.complexity[path] {170 idx := lineNo - 1171 if idx < 0 || idx >= len(blame) {172 continue173 }174 aid := blame[idx]175 a := totals[aid]176 if a == nil {177 a = &acc{}178 totals[aid] = a179 }180 a.Complexity++181 }182183 // Plurality of code lines: who has the most code in this file. A184 // real author always outranks the sentinel — the sentinel only owns185 // the file when no real author has any code here. Tie-break on186 // smaller authorID for determinism.187 var plur authorID188 var plurCount int64189 for aid, c := range perFile {190 if aid == sentinelAuthorID {191 continue192 }193 if c > plurCount || (c == plurCount && aid < plur) {194 plur = aid195 plurCount = c196 }197 }198 if plurCount == 0 {199 // No real author has code here; fall back to the sentinel.200 if c, ok := perFile[sentinelAuthorID]; ok {201 plur = sentinelAuthorID202 plurCount = c203 }204 }205 if plurCount > 0 {206 totals[plur].Files++207 }208 }209210 var sentinelCode int64211 if s, ok := totals[sentinelAuthorID]; ok {212 sentinelCode = s.Code213 }214 inWindowCode := grandCode - sentinelCode215 o.inWindowCode = inWindowCode216217 rows := make([]authorRow, 0, len(totals))218 for aid, a := range totals {219 rec := o.registry.record(aid)220 row := authorRow{221 Name: rec.Name,222 Email: rec.Email,223 Code: a.Code,224 Comment: a.Comment,225 Complexity: a.Complexity,226 Files: a.Files,227 }228 if grandCode > 0 {229 row.OwnsPercent = float64(a.Code) / float64(grandCode) * 100.0230 }231 if aid == sentinelAuthorID {232 row.Sentinel = true233 } else {234 if inWindowCode > 0 {235 row.InWindowPercent = float64(a.Code) / float64(inWindowCode) * 100.0236 }237 if when, ok := o.lastSeen[aid]; ok {238 row.LastCommit = when239 }240 }241 rows = append(rows, row)242 }243244 // Sentinel sorted to the end; real authors by Code desc, then Name.245 slices.SortFunc(rows, func(a, b authorRow) int {246 if a.Sentinel != b.Sentinel {247 if a.Sentinel {248 return 1249 }250 return -1251 }252 if a.Code != b.Code {253 if a.Code < b.Code {254 return 1255 }256 return -1257 }258 return strings.Compare(a.Name, b.Name)259 })260 disambiguateNames(rows)261 o.rows = rows262263 cumPercent := 0.0264 for _, r := range rows {265 if r.Sentinel {266 continue267 }268 if r.Code == 0 {269 break270 }271 cumPercent += r.InWindowPercent272 o.busAuthors = append(o.busAuthors, r.Display)273 if cumPercent > 50 {274 break275 }276 }277 o.busFactor = len(o.busAuthors)278 o.busCovered = cumPercent279}280281// disambiguateNames sets Display on every row. When two or more in-window282// identities share the same display Name (e.g. one contributor committing283// under both a work and a noreply email — kept as distinct identities because284// no .mailmap merges them), each colliding row is suffixed with a short285// marker so the reader — and the bus-factor footer, which reuses Display —286// can tell them apart. Non-colliding names, the "others" roll-up and the287// sentinel are left bare.288func disambiguateNames(rows []authorRow) {289 groups := map[string][]int{}290 for i := range rows {291 if rows[i].Sentinel {292 continue293 }294 groups[rows[i].Name] = append(groups[rows[i].Name], i)295 }296 for _, idx := range groups {297 if len(idx) < 2 {298 continue299 }300 markers := disambiguationMarkers(rows, idx)301 for k, i := range idx {302 rows[i].Display = rows[i].Name + " (" + markers[k] + ")"303 }304 }305 for i := range rows {306 if rows[i].Display == "" {307 rows[i].Display = rows[i].Name308 }309 }310}311312// disambiguationMarkers returns one marker per row in idx (all sharing a313// display Name), picking the shortest candidate form that is distinct across314// the whole group: registrable domain first (the tidiest, e.g. "github.com"),315// then the full domain, then the full email. Because two identities with the316// same name and same email intern to one authorID, a collision group always317// has distinct emails, so the final form is guaranteed to separate them.318func disambiguationMarkers(rows []authorRow, idx []int) []string {319 forms := []func(string) string{320 registrableDomain,321 emailDomain,322 func(email string) string { return email },323 }324 for _, form := range forms {325 out := make([]string, len(idx))326 seen := map[string]struct{}{}327 distinct := true328 for k, i := range idx {329 m := form(rows[i].Email)330 if m == "" {331 distinct = false332 break333 }334 if _, dup := seen[m]; dup {335 distinct = false336 break337 }338 seen[m] = struct{}{}339 out[k] = m340 }341 if distinct {342 return out343 }344 }345 // Unreachable in practice (see doc comment); fall back to raw email.346 out := make([]string, len(idx))347 for k, i := range idx {348 out[k] = rows[i].Email349 }350 return out351}352353// registrableDomain returns the last two labels of the email's domain354// (e.g. "users.noreply.github.com" -> "github.com"), a short human-readable355// marker for the common single-TLD case. It is a display heuristic, not a356// public-suffix-correct computation — disambiguationMarkers falls back to the357// full domain when this form fails to separate a collision group.358func registrableDomain(email string) string {359 d := emailDomain(email)360 if d == "" {361 return ""362 }363 parts := strings.Split(d, ".")364 if len(parts) <= 2 {365 return d366 }367 return strings.Join(parts[len(parts)-2:], ".")368}369370// runAuthorsReport is the dispatch entry point called from Process() when371// --by-author is set (and --timeline is not). Opens the repo at repoPath,372// walks history with baseline seeding, and writes the chosen format to373// stdout or FileOutput.374func runAuthorsReport(repoPath string) error {375 observer := newHistoryAuthorsObserver()376 if _, err := runHistory(repoPath, observer); err != nil {377 return err378 }379 out, err := renderAuthors(observer)380 if err != nil {381 return err382 }383 if FileOutput == "" {384 fmt.Print(out)385 } else {386 if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {387 return err388 }389 fmt.Println("results written to " + FileOutput)390 }391 return nil392}393394func renderAuthors(o *historyAuthorsObserver) (string, error) {395 switch strings.ToLower(Format) {396 case "", "tabular", "wide":397 return renderAuthorsTabular(o), nil398 case "csv":399 return renderAuthorsCSV(o)400 case "json":401 return renderAuthorsJSON(o)402 default:403 return "", fmt.Errorf("unsupported --format %q for --by-author (supported: tabular, csv, json)", Format)404 }405}406407// Short tabular: %-31s %9s %9s %7s %8s %10s = 79.408var tabularShortAuthorsFormatHead = "%-31s %9s %9s %7s %8s %10s\n"409410// Wide tabular: identical columns to the short form, with the Author column411// stretched to fill the 109-col rule so long names show in full.412// %-61s %9s %9s %7s %8s %10s = 109.413var tabularWideAuthorsFormatHead = "%-61s %9s %9s %7s %8s %10s\n"414415func renderAuthorsTabular(o *historyAuthorsObserver) string {416 wide := More || strings.EqualFold(Format, "wide")417 brk := tabularBreakFor(wide)418419 var sb strings.Builder420 sb.WriteString(historyHeader("Authors", o.window, wide))421422 p := gmessage.NewPrinter(glanguage.Make(os.Getenv("LANG")))423424 if wide {425 _, _ = fmt.Fprintf(&sb, tabularWideAuthorsFormatHead,426 "Author", "Code", "Cmplx", "Files", "Owns", "Last seen")427 } else {428 _, _ = fmt.Fprintf(&sb, tabularShortAuthorsFormatHead,429 "Author", "Code", "Cmplx", "Files", "Owns", "Last seen")430 }431 sb.WriteString(brk)432433 realRows := make([]authorRow, 0, len(o.rows))434 var sentinel *authorRow435 for i := range o.rows {436 r := o.rows[i]437 if r.Sentinel {438 s := r439 sentinel = &s440 } else {441 realRows = append(realRows, r)442 }443 }444445 limit := min(len(realRows), authorsTopN)446447 for i := range limit {448 r := realRows[i]449 writeAuthorRow(&sb, p, wide, r.Display, r.Code, r.Comment, r.Complexity,450 fmt.Sprintf("%d", r.Files), r.OwnsPercent, lastSeenString(r))451 }452453 if limit < len(realRows) {454 var (455 count int456 code int64457 comment int64458 complexity int64459 owns float64460 )461 for i := limit; i < len(realRows); i++ {462 r := realRows[i]463 count++464 code += r.Code465 comment += r.Comment466 complexity += r.Complexity467 owns += r.OwnsPercent468 }469 writeAuthorRow(&sb, p, wide,470 fmt.Sprintf("others (%d)", count), code, comment, complexity,471 "—", owns, "—")472 }473474 if sentinel != nil && (sentinel.Code+sentinel.Comment+sentinel.Complexity) > 0 {475 writeAuthorRow(&sb, p, wide,476 "(before window)", sentinel.Code, sentinel.Comment, sentinel.Complexity,477 fmt.Sprintf("%d", sentinel.Files), sentinel.OwnsPercent, "—")478 }479480 sb.WriteString(brk)481482 footerWidth := runewidth.StringWidth(strings.TrimRight(brk, "\n"))483 footer := formatAuthorsFooter(o, footerWidth)484 sb.WriteString(footer)485 sb.WriteByte('\n')486 sb.WriteString(brk)487488 return sb.String()489}490491func lastSeenString(r authorRow) string {492 if r.LastCommit.IsZero() {493 return "—"494 }495 return r.LastCommit.UTC().Format(historyDateLayout)496}497498func writeAuthorRow(sb *strings.Builder, p *gmessage.Printer, wide bool,499 name string, code, comment, complexity int64,500 files string, owns float64, lastSeen string) {501502 trim, colWidth := authorNameTrim, authorNameColWidth503 format := tabularShortAuthorsFormatHead504 if wide {505 trim, colWidth = authorNameTrimWide, authorNameColWidthWide506 format = tabularWideAuthorsFormatHead507 }508 nameCol := unicodeAwareTrim(name, trim)509 nameCol = unicodeAwareRightPad(nameCol, colWidth)510 codeStr := formatWithCommas(p, code)511 cmplxStr := formatWithCommas(p, complexity)512 ownsStr := fmt.Sprintf("%6.1f%%", owns)513514 _, _ = fmt.Fprintf(sb, format,515 nameCol, codeStr, cmplxStr, files, ownsStr, lastSeen)516}517518func formatAuthorsFooter(o *historyAuthorsObserver, width int) string {519 if o.inWindowCode == 0 {520 return "Bus factor 0 · no code touched in window"521 }522 if o.busFactor == 0 {523 return "Bus factor 0 · no authored code in window"524 }525 covered := o.busCovered526 if covered > 100 {527 covered = 100528 }529530 prefix := fmt.Sprintf("Bus factor %d · ", o.busFactor)531 var suffix string532 if o.busFactor == 1 {533 suffix = fmt.Sprintf(" last-touched %.0f%% of in-window code (single point of failure)", covered)534 } else {535 suffix = fmt.Sprintf(" last-touched %.0f%% of in-window code", covered)536 }537538 single := prefix + strings.Join(o.busAuthors, " + ") + suffix539 if width <= 0 || runewidth.StringWidth(single) <= width {540 return single541 }542 return wrapBusFactorFooter(prefix, o.busAuthors, suffix, width)543}544545// wrapBusFactorFooter word-wraps the bus-factor line on " + " token546// boundaries when it would otherwise exceed width. Continuation lines are547// indented to align under the first name so the structure stays readable.548// Width is measured in display columns (runewidth), not bytes, so non-ASCII549// author names and CI-mode ASCII breaks both produce the right wrap point.550func wrapBusFactorFooter(prefix string, names []string, suffix string, width int) string {551 indent := strings.Repeat(" ", runewidth.StringWidth(prefix))552 var sb strings.Builder553 line := prefix554 lineWidth := runewidth.StringWidth(line)555556 for i, name := range names {557 token := name558 if i < len(names)-1 {559 token += " + "560 }561 tokenWidth := runewidth.StringWidth(token)562 if line != prefix && line != indent && lineWidth+tokenWidth > width {563 sb.WriteString(strings.TrimRight(line, " "))564 sb.WriteByte('\n')565 line = indent566 lineWidth = runewidth.StringWidth(indent)567 }568 line += token569 lineWidth += tokenWidth570 }571572 suffixWidth := runewidth.StringWidth(suffix)573 if lineWidth+suffixWidth > width && line != indent {574 sb.WriteString(strings.TrimRight(line, " "))575 sb.WriteByte('\n')576 line = indent + strings.TrimLeft(suffix, " ")577 } else {578 line += suffix579 }580 sb.WriteString(line)581 return sb.String()582}583584func renderAuthorsCSV(o *historyAuthorsObserver) (string, error) {585 var sb strings.Builder586 sb.WriteString(formatWindowComment(o.window))587 sb.WriteByte('\n')588589 w := csv.NewWriter(&sb)590 _ = w.Write([]string{591 "Author", "Email", "Code", "Complexity", "Comment", "Files",592 "OwnsPercent", "LastCommit", "BeforeWindow",593 })594 for _, r := range o.rows {595 name, email := r.Name, r.Email596 lastCommit := ""597 beforeWindow := "false"598 if r.Sentinel {599 name, email = "", ""600 beforeWindow = "true"601 } else if !r.LastCommit.IsZero() {602 lastCommit = r.LastCommit.UTC().Format(historyDateLayout)603 }604 _ = w.Write([]string{605 name,606 email,607 fmt.Sprintf("%d", r.Code),608 fmt.Sprintf("%d", r.Complexity),609 fmt.Sprintf("%d", r.Comment),610 fmt.Sprintf("%d", r.Files),611 fmt.Sprintf("%.1f", r.OwnsPercent),612 lastCommit,613 beforeWindow,614 })615 }616 w.Flush()617 if err := w.Error(); err != nil {618 return "", err619 }620 return sb.String(), nil621}622623type authorsJSONAuthor struct {624 Name *string `json:"name"`625 Email *string `json:"email"`626 Code int64 `json:"code"`627 Complexity int64 `json:"complexity"`628 Comment int64 `json:"comment"`629 Files int `json:"files"`630 OwnsPercent float64 `json:"ownsPercent"`631 InWindowPercent float64 `json:"inWindowPercent"`632 LastCommit string `json:"lastCommit,omitempty"`633 BeforeWindow bool `json:"beforeWindow"`634}635636type authorsJSONWindow struct {637 Depth int `json:"depth"`638 Commits int `json:"commits"`639 From string `json:"from"`640 To string `json:"to"`641}642643type authorsJSONDoc struct {644 Report string `json:"report"`645 Window authorsJSONWindow `json:"window"`646 BusFactor int `json:"busFactor"`647 Authors []authorsJSONAuthor `json:"authors"`648}649650func renderAuthorsJSON(o *historyAuthorsObserver) (string, error) {651 doc := authorsJSONDoc{652 Report: "authors",653 Window: authorsJSONWindow{654 Depth: o.window.Depth,655 Commits: o.window.Commits,656 From: formatWindowDate(o.window.From),657 To: formatWindowDate(o.window.To),658 },659 BusFactor: o.busFactor,660 Authors: make([]authorsJSONAuthor, 0, len(o.rows)),661 }662 for _, r := range o.rows {663 a := authorsJSONAuthor{664 Code: r.Code,665 Complexity: r.Complexity,666 Comment: r.Comment,667 Files: r.Files,668 OwnsPercent: round1(r.OwnsPercent),669 InWindowPercent: round1(r.InWindowPercent),670 }671 if r.Sentinel {672 a.BeforeWindow = true673 } else {674 name, email := r.Name, r.Email675 a.Name = &name676 a.Email = &email677 if !r.LastCommit.IsZero() {678 a.LastCommit = r.LastCommit.UTC().Format(historyDateLayout)679 }680 }681 doc.Authors = append(doc.Authors, a)682 }683 b, err := jsoniter.Marshal(doc)684 if err != nil {685 return "", err686 }687 return string(b), nil688}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.