processor/report.go GO 861 lines View on github.com → Search inside
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}

Code quality findings 17

Declared map variable without initialization; writing to a nil map causes a panic. Use make() to initialize
warning correctness nil-map-write
var ReportSkipNames = map[string]bool{}
Declared map variable without initialization; writing to a nil map causes a panic. Use make() to initialize
warning correctness nil-map-write
var reportSkipRecognised = map[string]bool{
Ensure errors are handled or logged
warning correctness unhandled-error
if err != nil {
Goroutine without waitgroup or channel; risks resource leaks or race conditions
warning correctness goroutine-without-sync
go func() {
Goroutine without waitgroup or channel; risks resource leaks or race conditions
warning correctness goroutine-without-sync
go func() {
Ensure errors are handled or logged
warning correctness unhandled-error
if err != nil {
Ensure errors are handled or logged
warning correctness unhandled-error
if err != nil {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
fileWalker.ExcludeFilenameRegex = append(fileWalker.ExcludeFilenameRegex, re)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
fileWalker.ExcludeDirectoryRegex = append(fileWalker.ExcludeDirectoryRegex, re)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
excludePathRegexes = append(excludePathRegexes, re)
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 lang, set := range ulocLanguageCount {
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 i, e := range lineLengthBucketEdges {
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
if ll > maxLength {
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
if ll > fileMax {
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 i, edge := range lineLengthBucketEdges {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
res.Rows = append(res.Rows, row)
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 i, b := range r.Series {

Get this view in your editor

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