Declared map variable without initialization; writing to a nil map causes a panic. Use make() to initialize
var ReportSkipNames = map[string]bool{}
1// SPDX-License-Identifier: MIT23package processor45import (6 "fmt"7 "html/template"8 "os"9 "os/exec"10 "path/filepath"11 "regexp"12 "sort"13 "strings"14 "sync"15 "time"1617 "github.com/boyter/gocodewalker"18 "github.com/go-git/go-git/v5"19)2021// DefaultReportName is the file name used when --report is invoked without22// a path (pflag's NoOptDefVal). main.go wires this in as the bare-flag23// default; runReport compares ReportOut to it to decide whether the user24// supplied an explicit path or relied on the default.25const DefaultReportName = "scc-report.html"2627// ReportOut is the output path supplied via --report. Empty means report28// mode is off; any other value (including DefaultReportName when the user29// passed a bare `--report`) flips Process() into the HTML-report branch.30var ReportOut = ""3132// ReportSkip is the raw comma-separated value supplied via --report-skip.33// Process() parses it into ReportSkipNames before the report runs.34var ReportSkip = ""3536// ReportSkipNames is the parsed, lower-cased set of section names supplied37// via --report-skip. Wired from main.go (spec 05). CollectReportData reads38// this through ReportSkipped to decide which *Result pointers to nil out39// before returning.40var ReportSkipNames = map[string]bool{}4142// ReportTitle is the override for the repo name used in the report banner43// (spec 05). Empty means "auto-detect".44var ReportTitle = ""4546// reportSkipRecognised is the set of section names --report-skip accepts.47// Kept here (next to ReportSkipped) so future template authors can find the48// authoritative list in one place. Names must match what the report template49// and CollectReportData branch on. Spec 05 fixes this set.50var reportSkipRecognised = map[string]bool{51 "cocomo": true,52 "locomo": true,53 "hotspots": true,54 "coupling": true,55 "authors": true,56 "timeline": true,57 "files": true,58 "uloc": true,59 "linelength": true,60 "card": true,61}6263// ReportSkipped reports whether the given section name was listed in64// --report-skip. Section names are case-insensitive — callers can pass65// either case.66func ReportSkipped(section string) bool {67 if len(ReportSkipNames) == 0 {68 return false69 }70 return ReportSkipNames[strings.ToLower(section)]71}7273// Totals captures the headline numbers shown in the report's Overview strip.74// Mirrors the sums computed by the tabular formatter (sumFiles / sumLines /75// …) but pulled into a struct so the template can read them by name.76type Totals struct {77 Files int6478 Lines int6479 Code int6480 Comment int6481 Blank int6482 Complexity int6483 Bytes int6484}8586// ULOCResult is the unique-lines-of-code rollup. Maps are converted to a87// stable slice here so the template can range deterministically.88type ULOCResult struct {89 Global int90 PerLanguage []ULOCLanguage91 TotalLines int6492 Dryness float6493}9495// ULOCLanguage is one row of the per-language ULOC slice. Sorted by ULOC96// descending, then name ascending.97type ULOCLanguage struct {98 Language string99 ULOC int100}101102// LineLengthBucket is one bar in the line-length histogram. Edges are103// inclusive-left, exclusive-right except for the open-ended tail bucket.104type LineLengthBucket struct {105 Start int // inclusive106 End int // exclusive; 0 means "no upper bound" (the tail bucket)107 Count int64108 Label string // e.g. "0–20", "120+"109}110111// LineLengthOutlier is one entry in the longest-lines callout list.112type LineLengthOutlier struct {113 File string114 Language string115 LineLength int116}117118// LineLengthResult is the line-length histogram and summary statistics.119type LineLengthResult struct {120 Buckets []LineLengthBucket121 Mean float64122 Max int123 Outliers []LineLengthOutlier124 TotalLines int64125}126127// HotspotsResult mirrors the data the tabular hotspot formatter consumes.128// Records is already sorted by Score desc.129type HotspotsResult struct {130 Window HistoryWindow131 Records []HotspotRow132 TotalRaw int133 Available bool134}135136// HotspotRow is one row of the hotspots table. Pulled out so report consumers137// don't depend on the private hotspotsRecord type.138type HotspotRow struct {139 File string140 Language string141 Complexity int64142 Commits int143 LinesChanged int64144 Authors int145 CodeChurn int64146 CommentChurn int64147 Score float64148}149150// CouplingResult mirrors the all-pairs change-coupling data. Pairs is already151// sorted strongest-first (raw co-change volume — the report never uses the152// complexity-weighted ranking).153type CouplingResult struct {154 Window HistoryWindow155 Pairs []CouplingPairRow156 TotalPairs int157 Available bool158}159160// CouplingPairRow is one file pair. Public so report consumers don't depend on161// the private CouplingCount type.162type CouplingPairRow struct {163 FileA string164 FileB string165 Shared int166 Degree float64167}168169// AuthorsResult mirrors the data the authors tabular formatter consumes. The170// Sentinel pseudo-row (`(before window)`) is included in Rows; consumers171// filter or call it out separately.172type AuthorsResult struct {173 Window HistoryWindow174 Rows []AuthorRow175 BusFactor int176 BusAuthors []string177 BusCovered float64178 InWindowCode int64179}180181// AuthorRow is one row of the authors rollup table. Mirrors authorRow but182// public for template consumers.183type AuthorRow struct {184 Name string185 Email string186 Code int64187 Comment int64188 Complexity int64189 Files int190 OwnsPercent float64191 InWindowPercent float64192 LastCommit time.Time193 Sentinel bool194}195196// LangTimelineResult mirrors the language-timeline observer output.197type LangTimelineResult struct {198 Window HistoryWindow199 Bucket Bucketing200 Rows []LangTimelineRow201 Buckets int202}203204// LangTimelineRow is one row of the language timeline table.205type LangTimelineRow struct {206 Language string207 StartingLines int64208 CodeNow int64209 Change int64210 SharePercent float64211 Deltas []int64212 Trajectory []int64213}214215// AuthorTimelineResult mirrors the author-timeline observer output.216type AuthorTimelineResult struct {217 Window HistoryWindow218 Bucket Bucketing219 Rows []AuthorTimelineRow220 Buckets int221}222223// AuthorTimelineRow is one row of the author timeline table.224type AuthorTimelineRow struct {225 Name string226 Email string227 TotalCommits int228 CodeDelta int64229 Series []AuthorTimelineBucket230}231232// AuthorTimelineBucket is one bucket of an author's timeline series.233type AuthorTimelineBucket struct {234 Commits int235 CodeDelta int64236}237238// ReportData is the in-memory aggregate produced by CollectReportData. The239// HTML template consumes one of these values per report run.240type ReportData struct {241 // Metadata242 RepoName string243 GeneratedAt time.Time244 SccVersion string245 Duration time.Duration246 GitAvailable bool247248 // Default rollup (always present)249 Summary []LanguageSummary250 Totals Totals251252 // Optional analyses — nil/empty if skipped or unavailable.253 ULOC *ULOCResult254 LineLength *LineLengthResult255 Hotspots *HotspotsResult256 Coupling *CouplingResult257 Authors *AuthorsResult258 LanguageTimeline *LangTimelineResult259 AuthorTimeline *AuthorTimelineResult260 Files []*FileJob261262 // Cost263 Cocomo *CocomoResult264 Locomo *LocomoResult265266 // Rendered share-card SVG (data: URL safe). Populated by RenderReport267 // before the main template runs so it can be embedded as og:image.268 CardSVG template.HTML269}270271// reportFlagState snapshots the package-level flag vars CollectReportData272// flips on entry so they can be restored on exit.273//274// scc's analysis modes (ULOC, line-length, per-file table) are gated by275// process-wide globals. The report mode flips them on inside a single276// invocation; we snapshot and restore via defer so panics, errors, or277// in-process re-entrancy don't leak state into a later scc call.278type reportFlagState struct {279 UlocMode bool280 MaxMean bool281 Files bool282 CouplingWeighted bool283}284285func saveReportFlags() reportFlagState {286 return reportFlagState{287 UlocMode: UlocMode,288 MaxMean: MaxMean,289 Files: Files,290 CouplingWeighted: CouplingWeighted,291 }292}293294func (s reportFlagState) restore() {295 UlocMode = s.UlocMode296 MaxMean = s.MaxMean297 Files = s.Files298 CouplingWeighted = s.CouplingWeighted299}300301// CollectReportData orchestrates the full scc analysis surface for one302// report. It walks the tree once for default counts, runs the git-history303// observers (when git is available), computes cost estimates, and returns a304// ReportData ready for HTML templating.305//306// IMPORTANT: this function mutates the package-level analysis flags307// (UlocMode, MaxMean, Files) while it runs. The previous values are308// snapshotted and restored via defer, but callers should not assume the309// flags retain their on-entry values during the call.310func CollectReportData(path string) (ReportData, error) {311 start := time.Now()312313 saved := saveReportFlags()314 defer saved.restore()315316 if !ReportSkipped("uloc") {317 UlocMode = true318 }319 if !ReportSkipped("linelength") {320 MaxMean = true321 }322 if !ReportSkipped("files") {323 Files = true324 }325326 // Reset the package-level ULOC accumulators so repeated in-process327 // invocations don't see stale data from an earlier walk.328 ulocMutex.Lock()329 ulocGlobalCount = map[string]struct{}{}330 ulocLanguageCount = map[string]map[string]struct{}{}331 ulocMutex.Unlock()332333 gitAvailable := detectGit(path)334335 data := ReportData{336 GeneratedAt: time.Now().UTC(),337 SccVersion: Version,338 GitAvailable: gitAvailable,339 RepoName: detectRepoName(path),340 }341342 files, summary, totals, err := walkAndAggregate(path)343 if err != nil {344 return ReportData{}, err345 }346 data.Files = files347 data.Summary = summary348 data.Totals = totals349350 if !ReportSkipped("uloc") {351 data.ULOC = snapshotULOC(totals.Lines)352 }353354 if !ReportSkipped("linelength") {355 data.LineLength = bucketLineLengths(files)356 }357358 // The report always shows raw co-change coupling, never the opt-in359 // complexity-weighted ranking. saved.restore() puts the flag back on exit.360 CouplingWeighted = false361362 if gitAvailable {363 if !ReportSkipped("hotspots") {364 obs := newHotspotsObserver()365 if window, err := runHistory(path, obs); err == nil {366 data.Hotspots = hotspotsResultFromObserver(obs, window)367 } else {368 printWarnF("report: hotspots observer failed: %s", err)369 }370 }371 if !ReportSkipped("coupling") {372 obs := newCouplingObserver()373 if window, err := runHistory(path, obs); err == nil {374 data.Coupling = couplingResultFromObserver(obs, window)375 } else {376 printWarnF("report: coupling observer failed: %s", err)377 }378 }379 if !ReportSkipped("authors") {380 obs := newHistoryAuthorsObserver()381 if window, err := runHistory(path, obs); err == nil {382 data.Authors = authorsResultFromObserver(obs, window)383 } else {384 printWarnF("report: authors observer failed: %s", err)385 }386 }387 if !ReportSkipped("timeline") {388 lObs := newHistoryLanguagesObserver(HistoryBuckets)389 if window, err := runHistory(path, lObs); err == nil {390 data.LanguageTimeline = languageTimelineResultFromObserver(lObs, window)391 } else {392 printWarnF("report: language timeline observer failed: %s", err)393 }394 aObs := newHistoryAuthorTimelineObserver(HistoryBuckets)395 if window, err := runHistory(path, aObs); err == nil {396 data.AuthorTimeline = authorTimelineResultFromObserver(aObs, window)397 } else {398 printWarnF("report: author timeline observer failed: %s", err)399 }400 }401 }402403 if !Cocomo && !ReportSkipped("cocomo") {404 c := computeCocomo(totals.Code)405 data.Cocomo = &c406 }407 if !ReportSkipped("locomo") {408 l := computeLocomo(totals.Code, totals.Complexity)409 data.Locomo = &l410 }411412 data.Duration = time.Since(start)413 return data, nil414}415416// detectGit returns true if the path (or any parent) contains a git working417// directory. Uses go-git's PlainOpenWithOptions with DetectDotGit so callers418// can pass a subdirectory of a repo. Cached behaviour is not needed here —419// this is called once at the start of CollectReportData.420func detectGit(path string) bool {421 _, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{DetectDotGit: true})422 return err == nil423}424425// detectRepoName implements the resolution chain from spec 05:426// 1. ReportTitle (set from --report-title) if non-empty.427// 2. Last path segment of `git config --get remote.origin.url` (strip `.git`).428// 3. Basename of the analysed path.429// 4. "scc report" fallback.430func detectRepoName(path string) string {431 if ReportTitle != "" {432 return ReportTitle433 }434 if name := remoteOriginName(path); name != "" {435 return name436 }437 abs, err := filepath.Abs(path)438 if err == nil && abs != "" {439 base := filepath.Base(abs)440 if base != "" && base != "." && base != string(filepath.Separator) {441 return base442 }443 }444 return "scc report"445}446447// remoteOriginName runs `git config --get remote.origin.url` inside path and448// returns the last segment of the URL with a trailing `.git` stripped. Empty449// when git is unavailable, the command fails, or the remote isn't set.450func remoteOriginName(path string) string {451 cmd := exec.Command("git", "config", "--get", "remote.origin.url")452 cmd.Dir = path453 out, err := cmd.Output()454 if err != nil {455 return ""456 }457 url := strings.TrimSpace(string(out))458 if url == "" {459 return ""460 }461 // Strip transport prefix (https://, git@host:) and trailing slash.462 url = strings.TrimSuffix(url, "/")463 // Take last path-or-colon segment.464 for _, sep := range []string{"/", ":"} {465 if idx := strings.LastIndex(url, sep); idx >= 0 {466 url = url[idx+1:]467 }468 }469 url = strings.TrimSuffix(url, ".git")470 return url471}472473// walkAndAggregate runs scc's standard file walker against path, drains the474// resulting FileJob channel once, and tees the results into the language475// rollup and a flat per-file slice. Reuses aggregateLanguageSummary by476// feeding it the same FileJobs through a buffered channel.477func walkAndAggregate(path string) ([]*FileJob, []LanguageSummary, Totals, error) {478 if path == "" {479 path = "."480 }481482 fpath := filepath.Clean(path)483 info, err := os.Stat(fpath)484 if err != nil {485 return nil, nil, Totals{}, fmt.Errorf("file or directory could not be read: %s", fpath)486 }487488 dirPaths := []string{}489 filePaths := []string{}490 if info.IsDir() {491 dirPaths = append(dirPaths, fpath)492 } else {493 filePaths = append(filePaths, fpath)494 }495496 ctx := processorContext{remap: newRemapConfig(RemapAll, RemapUnknown)}497498 potentialFilesQueue := make(chan *gocodewalker.File, FileListQueueSize)499 fileListQueue := make(chan *FileJob, FileListQueueSize)500 fileSummaryJobQueue := make(chan *FileJob, FileSummaryJobQueueSize)501502 if len(dirPaths) > 0 {503 fileWalker := gocodewalker.NewParallelFileWalker(dirPaths, potentialFilesQueue)504 fileWalker.SetErrorHandler(func(e error) bool {505 printError(e.Error())506 return true507 })508 fileWalker.IgnoreGitIgnore = GitIgnore509 fileWalker.IgnoreIgnoreFile = Ignore510 fileWalker.IgnoreGitModules = GitModuleIgnore511 fileWalker.IncludeHidden = true512 fileWalker.ExcludeDirectory = PathDenyList513 fileWalker.SetConcurrency(DirectoryWalkerJobWorkers)514 if !SccIgnore {515 fileWalker.CustomIgnore = []string{".sccignore"}516 }517 fileWalker.CustomIgnoreFiles = IgnoreFiles518519 var excludePathRegexes []*regexp.Regexp520 for _, exclude := range Exclude {521 re, err := regexp.Compile(exclude)522 if err == nil {523 fileWalker.ExcludeFilenameRegex = append(fileWalker.ExcludeFilenameRegex, re)524 fileWalker.ExcludeDirectoryRegex = append(fileWalker.ExcludeDirectoryRegex, re)525 excludePathRegexes = append(excludePathRegexes, re)526 } else {527 printError(err.Error())528 }529 }530531 go func() {532 if err := fileWalker.Start(); err != nil {533 printError(err.Error())534 }535 }()536537 go func() {538 for fi := range potentialFilesQueue {539 shouldExclude := false540 for _, re := range excludePathRegexes {541 if re.MatchString(fi.Location) {542 shouldExclude = true543 break544 }545 }546 if shouldExclude {547 continue548 }549 fileInfo, err := os.Lstat(fi.Location)550 if err != nil {551 continue552 }553 if !fileInfo.IsDir() {554 if job := newFileJob(fi.Location, fi.Filename, fileInfo); job != nil {555 fileListQueue <- job556 }557 }558 }559 close(fileListQueue)560 }()561 } else {562 go func() {563 for _, f := range filePaths {564 fileInfo, err := os.Lstat(f)565 if err != nil {566 continue567 }568 if job := newFileJob(f, f, fileInfo); job != nil {569 fileListQueue <- job570 }571 }572 close(fileListQueue)573 }()574 }575576 go ctx.fileProcessorWorker(fileListQueue, fileSummaryJobQueue)577578 // Tee: as each FileJob arrives, append to the flat slice and forward to579 // a buffered channel that aggregateLanguageSummary drains. We forward580 // synchronously so totals/files always see the same set.581 aggregateInput := make(chan *FileJob, FileSummaryJobQueueSize)582 var (583 files []*FileJob584 totals Totals585 mu sync.Mutex586 )587588 go func() {589 for job := range fileSummaryJobQueue {590 mu.Lock()591 files = append(files, job)592 totals.Files++593 totals.Lines += job.Lines594 totals.Code += job.Code595 totals.Comment += job.Comment596 totals.Blank += job.Blank597 totals.Complexity += job.Complexity598 totals.Bytes += job.Bytes599 mu.Unlock()600 aggregateInput <- job601 }602 close(aggregateInput)603 }()604605 summary := aggregateLanguageSummary(aggregateInput)606 summary = sortLanguageSummary(summary)607608 // Ensure deterministic ordering of the flat Files slice — the worker609 // pool can interleave file emissions.610 sort.Slice(files, func(i, j int) bool {611 return files[i].Location < files[j].Location612 })613614 return files, summary, totals, nil615}616617// snapshotULOC converts the package-level ULOC maps into a sorted slice so618// the template can range deterministically. totalLines drives the DRYness619// number — unique lines / total lines, capped at 1.0.620func snapshotULOC(totalLines int64) *ULOCResult {621 ulocMutex.Lock()622 defer ulocMutex.Unlock()623624 res := &ULOCResult{625 Global: len(ulocGlobalCount),626 TotalLines: totalLines,627 }628 if totalLines > 0 {629 res.Dryness = float64(res.Global) / float64(totalLines)630 }631632 res.PerLanguage = make([]ULOCLanguage, 0, len(ulocLanguageCount))633 for lang, set := range ulocLanguageCount {634 res.PerLanguage = append(res.PerLanguage, ULOCLanguage{Language: lang, ULOC: len(set)})635 }636 sort.Slice(res.PerLanguage, func(i, j int) bool {637 if res.PerLanguage[i].ULOC != res.PerLanguage[j].ULOC {638 return res.PerLanguage[i].ULOC > res.PerLanguage[j].ULOC639 }640 return res.PerLanguage[i].Language < res.PerLanguage[j].Language641 })642643 return res644}645646// lineLengthBucketEdges defines the histogram bins used in the report — six647// 20-wide bins plus an open-ended tail.648var lineLengthBucketEdges = []struct {649 start, end int650 label string651}{652 {0, 20, "0–20"},653 {20, 40, "20–40"},654 {40, 60, "40–60"},655 {60, 80, "60–80"},656 {80, 100, "80–100"},657 {100, 120, "100–120"},658 {120, 0, "120+"},659}660661// lineLengthOutlierCount is the maximum number of longest-line outliers662// surfaced in the report. The tabular formatter only shows top-N; the663// HTML report has more vertical room so we collect a slightly larger set.664const lineLengthOutlierCount = 10665666// bucketLineLengths walks every file's per-line lengths into the histogram667// buckets and tracks mean / max / longest-N outliers. Returns nil if no file668// had per-line length data (e.g. MaxMean was off everywhere).669func bucketLineLengths(files []*FileJob) *LineLengthResult {670 res := &LineLengthResult{}671 res.Buckets = make([]LineLengthBucket, len(lineLengthBucketEdges))672 for i, e := range lineLengthBucketEdges {673 res.Buckets[i] = LineLengthBucket{Start: e.start, End: e.end, Label: e.label}674 }675676 type outlier struct {677 file, lang string678 length int679 }680 var (681 total int64682 count int64683 maxLength int684 outliers []outlier685 )686687 for _, fj := range files {688 fileMax := 0689 for _, ll := range fj.LineLength {690 count++691 total += int64(ll)692 if ll > maxLength {693 maxLength = ll694 }695 if ll > fileMax {696 fileMax = ll697 }698 for i, edge := range lineLengthBucketEdges {699 if edge.end == 0 {700 if ll >= edge.start {701 res.Buckets[i].Count++702 break703 }704 } else if ll >= edge.start && ll < edge.end {705 res.Buckets[i].Count++706 break707 }708 }709 }710 if fileMax > 0 {711 outliers = append(outliers, outlier{712 file: fj.Location,713 lang: fj.Language,714 length: fileMax,715 })716 }717 }718719 if count == 0 {720 return nil721 }722723 res.TotalLines = count724 res.Mean = float64(total) / float64(count)725 res.Max = maxLength726727 sort.Slice(outliers, func(i, j int) bool {728 if outliers[i].length != outliers[j].length {729 return outliers[i].length > outliers[j].length730 }731 return outliers[i].file < outliers[j].file732 })733 if len(outliers) > lineLengthOutlierCount {734 outliers = outliers[:lineLengthOutlierCount]735 }736 res.Outliers = make([]LineLengthOutlier, 0, len(outliers))737 for _, o := range outliers {738 res.Outliers = append(res.Outliers, LineLengthOutlier{739 File: o.file,740 Language: o.lang,741 LineLength: o.length,742 })743 }744 return res745}746747func hotspotsResultFromObserver(o *hotspotsObserver, window HistoryWindow) *HotspotsResult {748 res := &HotspotsResult{749 Window: window,750 TotalRaw: o.totalRaw,751 Available: true,752 }753 res.Records = make([]HotspotRow, 0, len(o.records))754 for _, r := range o.records {755 res.Records = append(res.Records, HotspotRow{756 File: r.File,757 Language: r.Language,758 Complexity: r.Complexity,759 Commits: r.Commits,760 LinesChanged: r.LinesChanged,761 Authors: len(r.Authors),762 CodeChurn: r.CodeChurn,763 CommentChurn: r.CommentChurn,764 Score: r.Score,765 })766 }767 return res768}769770func couplingResultFromObserver(o *couplingObserver, window HistoryWindow) *CouplingResult {771 res := &CouplingResult{772 Window: window,773 TotalPairs: o.totalPairs,774 Available: true,775 }776 res.Pairs = make([]CouplingPairRow, 0, len(o.pairs))777 for _, p := range o.pairs {778 res.Pairs = append(res.Pairs, CouplingPairRow{779 FileA: p.A,780 FileB: p.B,781 Shared: p.Shared,782 Degree: p.Degree(),783 })784 }785 return res786}787788func authorsResultFromObserver(o *historyAuthorsObserver, window HistoryWindow) *AuthorsResult {789 res := &AuthorsResult{790 Window: window,791 BusFactor: o.busFactor,792 BusAuthors: append([]string(nil), o.busAuthors...),793 BusCovered: o.busCovered,794 InWindowCode: o.inWindowCode,795 }796 res.Rows = make([]AuthorRow, 0, len(o.rows))797 for _, r := range o.rows {798 res.Rows = append(res.Rows, AuthorRow{799 Name: r.Name,800 Email: r.Email,801 Code: r.Code,802 Comment: r.Comment,803 Complexity: r.Complexity,804 Files: r.Files,805 OwnsPercent: r.OwnsPercent,806 InWindowPercent: r.InWindowPercent,807 LastCommit: r.LastCommit,808 Sentinel: r.Sentinel,809 })810 }811 return res812}813814func languageTimelineResultFromObserver(o *historyLanguagesObserver, window HistoryWindow) *LangTimelineResult {815 res := &LangTimelineResult{816 Window: window,817 Bucket: o.bucket,818 Buckets: o.bucket.N,819 }820 res.Rows = make([]LangTimelineRow, 0, len(o.rows))821 for _, r := range o.rows {822 row := LangTimelineRow{823 Language: r.Language,824 StartingLines: r.StartingLines,825 CodeNow: r.CodeNow,826 Change: r.Change,827 SharePercent: r.SharePercent,828 Deltas: append([]int64(nil), r.Deltas...),829 Trajectory: append([]int64(nil), r.Trajectory...),830 }831 res.Rows = append(res.Rows, row)832 }833 return res834}835836func authorTimelineResultFromObserver(o *historyAuthorTimelineObserver, window HistoryWindow) *AuthorTimelineResult {837 res := &AuthorTimelineResult{838 Window: window,839 Bucket: o.bucket,840 Buckets: o.bucket.N,841 }842 res.Rows = make([]AuthorTimelineRow, 0, len(o.rows))843 for _, r := range o.rows {844 row := AuthorTimelineRow{845 Name: r.Name,846 Email: r.Email,847 TotalCommits: r.TotalCommits,848 CodeDelta: r.CodeDelta,849 Series: make([]AuthorTimelineBucket, len(r.Series)),850 }851 for i, b := range r.Series {852 row.Series[i] = AuthorTimelineBucket{853 Commits: b.Commits,854 CodeDelta: b.CodeDelta,855 }856 }857 res.Rows = append(res.Rows, row)858 }859 return res860}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.