processor/history.go GO 838 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"context"7	"errors"8	"fmt"9	"io"10	"strings"11	"time"1213	"github.com/go-git/go-git/v5"14	"github.com/go-git/go-git/v5/plumbing"15	"github.com/go-git/go-git/v5/plumbing/filemode"16	fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"17	"github.com/go-git/go-git/v5/plumbing/object"18	"github.com/go-git/go-git/v5/utils/merkletrie"19)2021// HistoryDepth is the maximum number of commits the history engine walks. 022// means "entire history". Wired to --depth in main.go.23var HistoryDepth = 10002425// LineRange is a half-open line span [Start, Start+Count) in 1-based line26// numbers. A FileChange carries one entry per contiguous run of added (or27// removed) lines emitted by go-git's diff.28type LineRange struct {29	Start int30	Count int31}3233// CommitInfo is the per-commit metadata handed to observers.34type CommitInfo struct {35	Hash   plumbing.Hash36	Author string37	Email  string38	When   time.Time39}4041// FileChange is one changed file inside a commit. AddedRanges/RemovedRanges42// describe the diff against the first parent; LineTypes and Complexity are43// scc's classifier output for the new blob (one LineType per line, one entry44// in Complexity per line that fired a complexity tick).45type FileChange struct {46	Path             string47	FromPath         string // != Path on a detected rename; "" on a pure add48	Language         string49	AddedRanges      []LineRange50	RemovedRanges    []LineRange51	LineTypes        []LineType52	RemovedLineTypes []LineType // old-blob line types, for code-filtered removals53	Complexity       []int54	NewBlob          []byte55}5657// CommitObserver is implemented by each report's accumulator. The engine58// invokes Observe once per commit oldest-first, then Finalise once with the59// window metadata and a snapshot of the HEAD tree (latest language /60// complexity per surviving file).61type CommitObserver interface {62	Observe(c CommitInfo, changes []FileChange)63	Finalise(window HistoryWindow, head HeadSnapshot)64}6566// HistoryWindow describes the commit window the engine walked.67type HistoryWindow struct {68	Depth   int69	Commits int70	From    time.Time71	To      time.Time72	Head    plumbing.Hash73}7475// HeadFile is one file in the HEAD tree, classified by scc's engine.76type HeadFile struct {77	Path       string78	Language   string79	Complexity int6480	Cognitive  int64 // nesting-weighted complexity; zero unless the Cognitive global is on81}8283// HeadSnapshot is the set of files in HEAD, keyed by path.84type HeadSnapshot struct {85	Files map[string]HeadFile86}8788// BaselineFile is one file from the window's start-commit tree, classified by89// scc's engine. Carries per-line type and complexity placement so observers90// can attribute lines that survive untouched from before the window.91type BaselineFile struct {92	Path       string93	Language   string94	LineTypes  []LineType95	Complexity []int // 1-based line numbers that fired a complexity tick96}9798// BaselineSnapshot is the optional pre-walk state handed to observers that99// implement BaselineObserver. Files holds the classified contents of the100// window's start-commit tree (empty when the window covers all history);101// Mailmap is the parsed .mailmap from the HEAD tree, if present.102type BaselineSnapshot struct {103	Files   map[string]BaselineFile104	Mailmap *mailmap105}106107// BaselineObserver is an optional extension to CommitObserver. When an108// observer implements it, the engine builds the baseline snapshot before the109// walk and calls Seed once. Observers that don't need the baseline (e.g.110// Hotspots) skip the expense by not implementing the interface.111type BaselineObserver interface {112	Seed(BaselineSnapshot)113}114115// MailmapObserver is an optional extension to CommitObserver. The engine116// always parses the repo's .mailmap from HEAD — one small blob — and hands117// it to observers that implement this, before the walk. Unlike118// BaselineObserver it does NOT trigger the expensive start-tree119// classification, so observers that only need author folding (e.g. Hotspots,120// the author timeline) can implement it cheaply.121type MailmapObserver interface {122	SetMailmap(*mailmap)123}124125// errStopIter is a local sentinel used to terminate iter.ForEach once we've126// collected --depth commits. iter.ForEach surfaces whatever the callback127// returns, so we can compare it back at the call site directly.128var errStopIter = errors.New("history: stop iteration")129130// Bucketing divides [From, To] into N equal time slices. Used by the timeline131// reports (plans 04 and 05) to map per-commit timestamps to a fixed-resolution132// per-bucket series independent of terminal width.133type Bucketing struct {134	From  time.Time135	To    time.Time136	N     int137	Width time.Duration138}139140// NewBucketing constructs a Bucketing covering [from, to] divided into n141// equal-width slices. n must be > 0; n <= 0 is normalised to 1 so callers can142// pass user input unchecked. A degenerate window (from == to or to before143// from) yields Width=0; all commits land in bucket 0 / N-1.144func NewBucketing(from, to time.Time, n int) Bucketing {145	if n <= 0 {146		n = 1147	}148	b := Bucketing{From: from, To: to, N: n}149	if to.After(from) {150		b.Width = to.Sub(from) / time.Duration(n)151	}152	return b153}154155// Index returns the 0..N-1 bucket slot for commit time t. Times before From156// clamp to 0 (defensive — should not happen given the walk window). Times at157// or after To clamp to N-1.158func (b Bucketing) Index(t time.Time) int {159	if b.N <= 0 {160		return 0161	}162	if b.Width <= 0 {163		return 0164	}165	if !t.After(b.From) {166		return 0167	}168	if !t.Before(b.To) {169		return b.N - 1170	}171	idx := int(t.Sub(b.From) / b.Width)172	if idx < 0 {173		return 0174	}175	if idx >= b.N {176		return b.N - 1177	}178	return idx179}180181// Start returns the wall-clock start time of bucket i. Indexes outside182// [0, N) are clamped.183func (b Bucketing) Start(i int) time.Time {184	if b.N <= 0 {185		return b.From186	}187	if i <= 0 {188		return b.From189	}190	if i >= b.N {191		i = b.N - 1192	}193	return b.From.Add(time.Duration(i) * b.Width)194}195196// emptySnapshot is what observers see when HEAD is missing or empty.197func emptySnapshot() HeadSnapshot {198	return HeadSnapshot{Files: map[string]HeadFile{}}199}200201// runHistory opens the repo at repoPath, walks up to HistoryDepth commits202// (newest first → oldest first), and feeds every commit's first-parent diff203// to the observer.204func runHistory(repoPath string, observer CommitObserver) (HistoryWindow, error) {205	// Turn GC back on because we have no idea how much we are about to process206	EnableGc()207208	repo, err := git.PlainOpenWithOptions(repoPath, &git.PlainOpenOptions{DetectDotGit: true})209	if err != nil {210		return HistoryWindow{}, fmt.Errorf("open git repository: %w", err)211	}212213	head, err := repo.Head()214	if err != nil {215		if errors.Is(err, plumbing.ErrReferenceNotFound) {216			observer.Finalise(HistoryWindow{}, emptySnapshot())217			return HistoryWindow{}, nil218		}219		return HistoryWindow{}, fmt.Errorf("read HEAD: %w", err)220	}221222	iter, err := repo.Log(&git.LogOptions{223		From:  head.Hash(),224		Order: git.LogOrderCommitterTime,225	})226	if err != nil {227		return HistoryWindow{}, fmt.Errorf("walk log: %w", err)228	}229230	collected := make([]*object.Commit, 0)231	walkErr := iter.ForEach(func(c *object.Commit) error {232		collected = append(collected, c)233		if HistoryDepth > 0 && len(collected) >= HistoryDepth {234			return errStopIter235		}236		return nil237	})238	// A shallow clone (e.g. CI's default `git checkout --depth 1`) stores a239	// parent hash for its oldest commit but not the parent object itself.240	// go-git's commit walker resolves each commit's parents as it advances, so241	// it surfaces that absent object as ErrObjectNotFound — exactly the same242	// "no more history to walk" situation as the root commit reaching zero243	// parents, just reached via a missing-object error instead of a count.244	// Treat it as end-of-history and keep what we walked, rather than aborting.245	if walkErr != nil && !errors.Is(walkErr, errStopIter) && !errors.Is(walkErr, plumbing.ErrObjectNotFound) {246		return HistoryWindow{}, fmt.Errorf("collect commits: %w", walkErr)247	}248249	if len(collected) == 0 {250		observer.Finalise(HistoryWindow{Head: head.Hash()}, emptySnapshot())251		return HistoryWindow{Head: head.Hash()}, nil252	}253254	window := HistoryWindow{255		Depth:   HistoryDepth,256		Commits: len(collected),257		From:    collected[len(collected)-1].Author.When,258		To:      collected[0].Author.When,259		Head:    head.Hash(),260	}261262	ignore, err := buildHistoryIgnore(repo, head.Hash())263	if err != nil {264		printWarnF("history: ignore matcher: %s", err)265	}266267	cache := newBlobClassifyCache()268269	if mo, ok := observer.(MailmapObserver); ok {270		mo.SetMailmap(loadMailmapForHead(collected[0]))271	}272273	if bo, ok := observer.(BaselineObserver); ok {274		baseline := buildBaselineForObserver(collected, ignore, cache)275		bo.Seed(baseline)276	}277278	ctx := context.Background()279	for i := len(collected) - 1; i >= 0; i-- {280		commit := collected[i]281		changes, err := commitChanges(ctx, commit, ignore, cache)282		if err != nil {283			printWarnF("history: diff %s: %s", commit.Hash, err)284			continue285		}286		observer.Observe(CommitInfo{287			Hash:   commit.Hash,288			Author: commit.Author.Name,289			Email:  commit.Author.Email,290			When:   commit.Author.When,291		}, changes)292	}293294	snapshot, err := buildHeadSnapshot(collected[0], ignore, cache)295	if err != nil {296		printWarnF("history: head snapshot: %s", err)297		snapshot = emptySnapshot()298	}299300	observer.Finalise(window, snapshot)301	return window, nil302}303304// loadMailmapForHead parses .mailmap from the HEAD commit's tree. Returns305// nil when there is no .mailmap or the HEAD tree cannot be read. Cheap306// compared to building the full baseline, so observers that only need307// author folding (Hotspots, author timeline) can satisfy MailmapObserver308// without paying for the start-tree classification.309func loadMailmapForHead(headCommit *object.Commit) *mailmap {310	if headCommit == nil {311		return nil312	}313	tree, err := headCommit.Tree()314	if err != nil {315		return nil316	}317	return loadMailmapFromTree(tree)318}319320// buildBaselineForObserver loads the mailmap from HEAD and classifies the321// tree at the window's start commit. The start commit is the first-parent of322// the oldest commit in the window; if that commit has no parents (the window323// covers all history) the baseline files map is empty.324func buildBaselineForObserver(collected []*object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (baseline BaselineSnapshot) {325	baseline = BaselineSnapshot{Files: map[string]BaselineFile{}}326	// Backstop for panics outside the per-file recover below — go-git's327	// tree.Files() iterator can itself panic on a corrupt object, and the328	// per-file handler is not in scope for that. Return whatever was329	// accumulated so far rather than crashing the report.330	defer func() {331		if r := recover(); r != nil {332			printWarnF("history: baseline walk panicked, using partial result: %v", r)333		}334	}()335	if len(collected) == 0 {336		return baseline337	}338339	baseline.Mailmap = loadMailmapForHead(collected[0])340341	oldest := collected[len(collected)-1]342	if oldest.NumParents() == 0 {343		return baseline344	}345	parent, err := oldest.Parent(0)346	if err != nil {347		printWarnF("history: baseline parent: %s", err)348		return baseline349	}350	tree, err := parent.Tree()351	if err != nil {352		printWarnF("history: baseline tree: %s", err)353		return baseline354	}355356	_ = tree.Files().ForEach(func(f *object.File) error {357		defer func() {358			if r := recover(); r != nil {359				name := "<unknown>"360				if f != nil {361					name = f.Name362				}363				printWarnF("history: skipping %s in baseline — panicked: %v", name, r)364			}365		}()366		if f.Mode == filemode.Dir || f.Mode == filemode.Submodule || f.Mode == filemode.Symlink {367			return nil368		}369		if ignore != nil && ignore.Match(f.Name, false) {370			return nil371		}372		reader, err := f.Reader()373		if err != nil {374			return nil375		}376		defer reader.Close()377		blob, err := io.ReadAll(reader)378		if err != nil {379			return nil380		}381		res := cache.classify(f.Hash, f.Name, blob)382		if !res.ok {383			return nil384		}385		baseline.Files[f.Name] = BaselineFile{386			Path:       f.Name,387			Language:   res.language,388			LineTypes:  res.lineTypes,389			Complexity: res.complexLine,390		}391		return nil392	})393394	return baseline395}396397// buildHeadSnapshot walks the HEAD commit's tree and runs scc's classifier398// on each file. Used by hotspots (and future reports) to know each surviving399// file's current language and complexity.400func buildHeadSnapshot(headCommit *object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (snap HeadSnapshot, err error) {401	snap = emptySnapshot()402	// Backstop for panics outside the per-file recover below — go-git's403	// tree.Files() iterator can itself panic on a corrupt object. Return the404	// partial snapshot with no error so the caller keeps what we collected.405	defer func() {406		if r := recover(); r != nil {407			printWarnF("history: HEAD snapshot walk panicked, using partial result: %v", r)408			err = nil409		}410	}()411412	tree, err := headCommit.Tree()413	if err != nil {414		return emptySnapshot(), err415	}416417	snap = HeadSnapshot{Files: map[string]HeadFile{}}418	err = tree.Files().ForEach(func(f *object.File) error {419		defer func() {420			if r := recover(); r != nil {421				name := "<unknown>"422				if f != nil {423					name = f.Name424				}425				printWarnF("history: skipping %s in HEAD snapshot — panicked: %v", name, r)426			}427		}()428		if f.Mode == filemode.Dir || f.Mode == filemode.Submodule || f.Mode == filemode.Symlink {429			return nil430		}431		if ignore != nil && ignore.Match(f.Name, false) {432			return nil433		}434		reader, err := f.Reader()435		if err != nil {436			return nil437		}438		defer reader.Close()439		blob, err := io.ReadAll(reader)440		if err != nil {441			return nil442		}443444		res := cache.classify(f.Hash, f.Name, blob)445		if !res.ok {446			return nil447		}448449		snap.Files[f.Name] = HeadFile{450			Path:       f.Name,451			Language:   res.language,452			Complexity: res.complexity,453			Cognitive:  res.cognitive,454		}455		return nil456	})457	return snap, err458}459460// historyDiffOptions forces rename detection on. The rename-aware reports461// (author rollup, hotspots) depend on renames arriving as a single change462// rather than a delete + add pair, so pin it explicitly — a future go-git463// bump can't silently disable it.464var historyDiffOptions = &object.DiffTreeOptions{DetectRenames: true}465466// commitChanges computes the first-parent diff for commit and projects every467// change into a FileChange. Skips paths that the engine can't count468// (binary blobs, no language detected, submodules, symlinks, ignored paths).469// Deletes are dropped because hotspots-style reports can't render files that470// no longer exist.471//472// The outer recover catches anything the per-call wrappers don't (corrupt473// packfiles via go-git object resolution, future regressions in the diff474// pipeline). One bad commit becomes a warning, not a crash.475func commitChanges(ctx context.Context, commit *object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (out []FileChange, err error) {476	defer func() {477		if r := recover(); r != nil {478			printWarnF("history: skipping commit %s — diff pipeline panicked: %v", commit.Hash, r)479			out = nil480			err = nil481		}482	}()483484	toTree, err := commit.Tree()485	if err != nil {486		return nil, err487	}488489	var fromTree *object.Tree490	if commit.NumParents() > 0 {491		parent, err := commit.Parent(0)492		// A shallow-clone boundary commit carries a parent hash whose object is493		// absent. Treat the missing parent as no parent (leave fromTree nil, so494		// the commit diffs against the empty tree) instead of failing — the495		// same end-of-history handling as commits with zero parents.496		if errors.Is(err, plumbing.ErrObjectNotFound) {497			return nil, nil498		}499		if err != nil {500			return nil, err501		}502		fromTree, err = parent.Tree()503		if errors.Is(err, plumbing.ErrObjectNotFound) {504			return nil, nil505		}506		if err != nil {507			return nil, err508		}509	}510511	changes, err := object.DiffTreeWithOptions(ctx, fromTree, toTree, historyDiffOptions)512	if err != nil {513		return nil, err514	}515516	out = make([]FileChange, 0, len(changes))517	for _, change := range changes {518		fc, ok := buildFileChange(change, ignore, cache)519		if !ok {520			continue521		}522		out = append(out, fc)523	}524	return out, nil525}526527// buildFileChange converts a single object.Change into a FileChange.528func buildFileChange(change *object.Change, ignore *historyIgnore, cache *blobClassifyCache) (FileChange, bool) {529	action, err := change.Action()530	if err != nil {531		return FileChange{}, false532	}533	if action == merkletrie.Delete {534		return FileChange{}, false535	}536537	path := change.To.Name538	fromPath := change.From.Name539	toEntry := change.To.TreeEntry540	if toEntry.Mode == filemode.Dir || toEntry.Mode == filemode.Submodule || toEntry.Mode == filemode.Symlink {541		return FileChange{}, false542	}543544	if ignore != nil && ignore.Match(path, false) {545		return FileChange{}, false546	}547548	languages, _ := DetectLanguage(path)549	if len(languages) == 0 {550		return FileChange{}, false551	}552553	patch, ok := safePatch(change)554	if !ok {555		return FileChange{}, false556	}557558	var added, removed []LineRange559	for _, fp := range patch.FilePatches() {560		if fp.IsBinary() {561			return FileChange{}, false562		}563		toLine, fromLine := 1, 1564		for _, chunk := range fp.Chunks() {565			lines := lineCount(chunk.Content())566			switch chunk.Type() {567			case fdiff.Equal:568				toLine += lines569				fromLine += lines570			case fdiff.Add:571				if lines > 0 {572					added = append(added, LineRange{Start: toLine, Count: lines})573				}574				toLine += lines575			case fdiff.Delete:576				if lines > 0 {577					removed = append(removed, LineRange{Start: fromLine, Count: lines})578				}579				fromLine += lines580			}581		}582	}583584	blob, err := readBlob(change.To.Tree, &toEntry)585	if err != nil {586		return FileChange{}, false587	}588589	res := cache.classify(toEntry.Hash, path, blob)590	if !res.ok {591		return FileChange{}, false592	}593594	// Classify the parent blob so removed lines can be filtered to code —595	// the timeline reports need a symmetric code-only delta. The blob cache596	// makes this near-free: the old blob is normally an earlier commit's597	// new blob. Skip entirely when there are no removals (pure adds, or598	// when the diff produced no removed ranges).599	var removedLineTypes []LineType600	if len(removed) > 0 && change.From.Name != "" {601		fromEntry := change.From.TreeEntry602		if fromEntry.Mode != filemode.Dir &&603			fromEntry.Mode != filemode.Submodule &&604			fromEntry.Mode != filemode.Symlink {605			if oldBlob, rerr := readBlob(change.From.Tree, &fromEntry); rerr == nil {606				if oldRes := cache.classify(fromEntry.Hash, change.From.Name, oldBlob); oldRes.ok {607					removedLineTypes = oldRes.lineTypes608				}609			}610		}611	}612613	return FileChange{614		Path:             path,615		FromPath:         fromPath,616		Language:         res.language,617		AddedRanges:      added,618		RemovedRanges:    removed,619		LineTypes:        res.lineTypes,620		RemovedLineTypes: removedLineTypes,621		Complexity:       res.complexLine,622		NewBlob:          blob,623	}, true624}625626// safePatch wraps change.Patch() with panic recovery. The underlying627// sergi/go-diff line-to-rune encoding panics when a file has more distinct628// lines than fit in the Unicode code-point space (generated SQL, huge629// minified bundles, vendored data files). Treat any panic or error as630// "skip this file" so one bad file does not abort the whole report.631func safePatch(change *object.Change) (patch *object.Patch, ok bool) {632	defer func() {633		if r := recover(); r != nil {634			path := ""635			if change != nil {636				path = change.To.Name637				if path == "" {638					path = change.From.Name639				}640			}641			printWarnF("history: skipping %s — diff library panicked: %v", path, r)642			patch = nil643			ok = false644		}645	}()646	p, err := change.Patch()647	if err != nil {648		return nil, false649	}650	return p, true651}652653// classifyFn is the indirect reference to classifyHistoryBlob used by654// safeClassify. Tests substitute a panicking stub to exercise the recover655// path; production behaviour is unchanged.656var classifyFn = classifyHistoryBlob657658// safeClassify wraps the classifier with panic recovery. The history walk659// feeds the classifier many more blob shapes than the working-tree counter660// ever sees (legacy encodings, partial UTF-8, oversized blobs, vendored661// data). A panic in any one path-blob pair must not abort the report —662// skip the file with a warning instead.663func safeClassify(path string, blob []byte) (job *FileJob, lineTypes []LineType, ok bool) {664	defer func() {665		if r := recover(); r != nil {666			printWarnF("history: skipping %s — classifier panicked: %v", path, r)667			job = nil668			lineTypes = nil669			ok = false670		}671	}()672	return classifyFn(path, blob)673}674675// readBlob fetches the raw bytes for a tree entry.676func readBlob(tree *object.Tree, entry *object.TreeEntry) ([]byte, error) {677	file, err := tree.TreeEntryFile(entry)678	if err != nil {679		return nil, err680	}681	reader, err := file.Reader()682	if err != nil {683		return nil, err684	}685	defer reader.Close()686	return io.ReadAll(reader)687}688689// blobClassifyResult is the cached output of classifyHistoryBlob for a single690// blob hash. ok=false means the classifier rejected the blob (binary, no691// language); the vectors are nil in that case.692type blobClassifyResult struct {693	language      string694	complexity    int64695	cognitive     int64 // nesting-weighted complexity; zero unless the Cognitive global is on696	lineTypes     []LineType697	complexLine   []int698	cognitiveLine []int // 1-based lines that accrued cognitive weight; nil unless Cognitive is on699	ok            bool700}701702// blobClassifyCache memoises classifyHistoryBlob output keyed by blob hash so703// the same blob seen in baseline, commit changes, and HEAD is classified once704// per runHistory. The walk is sequential, so no mutex is required.705type blobClassifyCache struct {706	entries map[plumbing.Hash]blobClassifyResult707}708709func newBlobClassifyCache() *blobClassifyCache {710	return &blobClassifyCache{entries: make(map[plumbing.Hash]blobClassifyResult)}711}712713// classify returns the classifier output for blob, computing and caching it714// on first sight. Slices in the returned result are shared between callers —715// they must be treated as read-only. Negative results (ok=false) are cached716// too so binary/unknown blobs aren't re-attempted.717func (c *blobClassifyCache) classify(hash plumbing.Hash, path string, blob []byte) blobClassifyResult {718	if c != nil {719		if hit, found := c.entries[hash]; found {720			return hit721		}722	}723	job, lineTypes, ok := safeClassify(path, blob)724	res := blobClassifyResult{ok: ok}725	if ok {726		res.language = job.Language727		res.complexity = job.Complexity728		res.cognitive = job.Cognitive729		res.lineTypes = lineTypes730		res.complexLine = complexityLineNumbers(job)731		res.cognitiveLine = cognitiveLineNumbers(job)732	}733	if c != nil {734		c.entries[hash] = res735	}736	return res737}738739// classifyHistoryBlob runs scc's existing classifier on a git blob's bytes740// and returns the resulting FileJob (Language / Complexity / Code / Comment741// / Blank populated) plus the per-line type vector. ok=false means the file742// is binary or the language could not be resolved.743func classifyHistoryBlob(path string, blob []byte) (*FileJob, []LineType, bool) {744	languages, extension := DetectLanguage(path)745	if len(languages) == 0 {746		return nil, nil, false747	}748	for _, l := range languages {749		LoadLanguageFeature(l)750	}751752	job := &FileJob{753		Location:             path,754		Filename:             basename(path),755		Extension:            extension,756		PossibleLanguages:    languages,757		Bytes:                int64(len(blob)),758		Content:              blob,759		TrackComplexityLines: true,760	}761762	job.Language = DetermineLanguage(job.Filename, job.Language, job.PossibleLanguages, job.Content)763	if job.Language == SheBang {764		cutoff := min(len(blob), 200)765		lang, err := DetectSheBang(blob[:cutoff])766		if err != nil {767			return nil, nil, false768		}769		job.Language = lang770		LoadLanguageFeature(lang)771	}772773	classifier := &historyLineCallback{}774	job.Callback = classifier775776	CountStats(job)777778	if job.Binary {779		return nil, nil, false780	}781782	return job, classifier.lineTypes, true783}784785// complexityLineNumbers returns the 1-based line numbers in job that fired a786// complexity tick. Convenience wrapper for observers that want per-line787// complexity placement (the per-line attribution reports in plans 03–04).788func complexityLineNumbers(job *FileJob) []int {789	out := make([]int, 0)790	for i, count := range job.ComplexityLine {791		if count > 0 {792			out = append(out, i+1)793		}794	}795	return out796}797798// cognitiveLineNumbers returns the 1-based line numbers in job that accrued799// cognitive weight. Mirrors complexityLineNumbers for the cognitive per-line800// array; returns an empty slice when cognitive tracking is off (CognitiveLine801// nil), so callers get the same shape either way.802func cognitiveLineNumbers(job *FileJob) []int {803	out := make([]int, 0)804	for i, weight := range job.CognitiveLine {805		if weight > 0 {806			out = append(out, i+1)807		}808	}809	return out810}811812type historyLineCallback struct {813	lineTypes []LineType814}815816func (h *historyLineCallback) ProcessLine(job *FileJob, currentLine int64, lineType LineType) bool {817	h.lineTypes = append(h.lineTypes, lineType)818	return true819}820821func basename(path string) string {822	if i := strings.LastIndex(path, "/"); i >= 0 {823		return path[i+1:]824	}825	return path826}827828func lineCount(s string) int {829	if s == "" {830		return 0831	}832	n := strings.Count(s, "\n")833	if !strings.HasSuffix(s, "\n") {834		n++835	}836	return n837}

Code quality findings 24

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 {
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 {
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 {
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = tree.Files().ForEach(func(f *object.File) error {
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 {
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 {
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 {
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 {
Uses root context; pass an existing context for cancellation and timeouts
info correctness root-context
ctx := context.Background()
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
for _, chunk := range fp.Chunks() {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
added = append(added, LineRange{Start: toLine, Count: lines})
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
if change != nil {
Deeply nested control structures reduce readability; consider extracting to functions or using early returns
info maintainability deep-nesting
if path == "" {
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, count := range job.ComplexityLine {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
out = append(out, i+1)
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, weight := range job.CognitiveLine {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
out = append(out, i+1)

Get this view in your editor

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