processor/history.go GO 848 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 := openRepository(repoPath)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		// An unborn HEAD (freshly initialised, no commits yet) and a HEAD we216		// simply could not resolve are the same error here, and there is no217		// signal to tell them apart: both are a symbolic HEAD naming a ref218		// that does not exist. So say what we actually know rather than219		// guess. The warning is verbose-gated like every other one, so this220		// does not shout at someone pointing a report at an empty repo — it221		// means the next resolution failure of this shape can be diagnosed222		// with --verbose instead of only looking like an empty table. That223		// silence is what made the worktree case (issue #765) hard to spot.224		if errors.Is(err, plumbing.ErrReferenceNotFound) {225			printWarnF("history: cannot resolve HEAD in %s, treating as an empty repository", repoPath)226			observer.Finalise(HistoryWindow{}, emptySnapshot())227			return HistoryWindow{}, nil228		}229		return HistoryWindow{}, fmt.Errorf("read HEAD: %w", err)230	}231232	iter, err := repo.Log(&git.LogOptions{233		From:  head.Hash(),234		Order: git.LogOrderCommitterTime,235	})236	if err != nil {237		return HistoryWindow{}, fmt.Errorf("walk log: %w", err)238	}239240	collected := make([]*object.Commit, 0)241	walkErr := iter.ForEach(func(c *object.Commit) error {242		collected = append(collected, c)243		if HistoryDepth > 0 && len(collected) >= HistoryDepth {244			return errStopIter245		}246		return nil247	})248	// A shallow clone (e.g. CI's default `git checkout --depth 1`) stores a249	// parent hash for its oldest commit but not the parent object itself.250	// go-git's commit walker resolves each commit's parents as it advances, so251	// it surfaces that absent object as ErrObjectNotFound — exactly the same252	// "no more history to walk" situation as the root commit reaching zero253	// parents, just reached via a missing-object error instead of a count.254	// Treat it as end-of-history and keep what we walked, rather than aborting.255	if walkErr != nil && !errors.Is(walkErr, errStopIter) && !errors.Is(walkErr, plumbing.ErrObjectNotFound) {256		return HistoryWindow{}, fmt.Errorf("collect commits: %w", walkErr)257	}258259	if len(collected) == 0 {260		observer.Finalise(HistoryWindow{Head: head.Hash()}, emptySnapshot())261		return HistoryWindow{Head: head.Hash()}, nil262	}263264	window := HistoryWindow{265		Depth:   HistoryDepth,266		Commits: len(collected),267		From:    collected[len(collected)-1].Author.When,268		To:      collected[0].Author.When,269		Head:    head.Hash(),270	}271272	ignore, err := buildHistoryIgnore(repo, head.Hash())273	if err != nil {274		printWarnF("history: ignore matcher: %s", err)275	}276277	cache := newBlobClassifyCache()278279	if mo, ok := observer.(MailmapObserver); ok {280		mo.SetMailmap(loadMailmapForHead(collected[0]))281	}282283	if bo, ok := observer.(BaselineObserver); ok {284		baseline := buildBaselineForObserver(collected, ignore, cache)285		bo.Seed(baseline)286	}287288	ctx := context.Background()289	for i := len(collected) - 1; i >= 0; i-- {290		commit := collected[i]291		changes, err := commitChanges(ctx, commit, ignore, cache)292		if err != nil {293			printWarnF("history: diff %s: %s", commit.Hash, err)294			continue295		}296		observer.Observe(CommitInfo{297			Hash:   commit.Hash,298			Author: commit.Author.Name,299			Email:  commit.Author.Email,300			When:   commit.Author.When,301		}, changes)302	}303304	snapshot, err := buildHeadSnapshot(collected[0], ignore, cache)305	if err != nil {306		printWarnF("history: head snapshot: %s", err)307		snapshot = emptySnapshot()308	}309310	observer.Finalise(window, snapshot)311	return window, nil312}313314// loadMailmapForHead parses .mailmap from the HEAD commit's tree. Returns315// nil when there is no .mailmap or the HEAD tree cannot be read. Cheap316// compared to building the full baseline, so observers that only need317// author folding (Hotspots, author timeline) can satisfy MailmapObserver318// without paying for the start-tree classification.319func loadMailmapForHead(headCommit *object.Commit) *mailmap {320	if headCommit == nil {321		return nil322	}323	tree, err := headCommit.Tree()324	if err != nil {325		return nil326	}327	return loadMailmapFromTree(tree)328}329330// buildBaselineForObserver loads the mailmap from HEAD and classifies the331// tree at the window's start commit. The start commit is the first-parent of332// the oldest commit in the window; if that commit has no parents (the window333// covers all history) the baseline files map is empty.334func buildBaselineForObserver(collected []*object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (baseline BaselineSnapshot) {335	baseline = BaselineSnapshot{Files: map[string]BaselineFile{}}336	// Backstop for panics outside the per-file recover below — go-git's337	// tree.Files() iterator can itself panic on a corrupt object, and the338	// per-file handler is not in scope for that. Return whatever was339	// accumulated so far rather than crashing the report.340	defer func() {341		if r := recover(); r != nil {342			printWarnF("history: baseline walk panicked, using partial result: %v", r)343		}344	}()345	if len(collected) == 0 {346		return baseline347	}348349	baseline.Mailmap = loadMailmapForHead(collected[0])350351	oldest := collected[len(collected)-1]352	if oldest.NumParents() == 0 {353		return baseline354	}355	parent, err := oldest.Parent(0)356	if err != nil {357		printWarnF("history: baseline parent: %s", err)358		return baseline359	}360	tree, err := parent.Tree()361	if err != nil {362		printWarnF("history: baseline tree: %s", err)363		return baseline364	}365366	_ = tree.Files().ForEach(func(f *object.File) error {367		defer func() {368			if r := recover(); r != nil {369				name := "<unknown>"370				if f != nil {371					name = f.Name372				}373				printWarnF("history: skipping %s in baseline — panicked: %v", name, r)374			}375		}()376		if f.Mode == filemode.Dir || f.Mode == filemode.Submodule || f.Mode == filemode.Symlink {377			return nil378		}379		if ignore != nil && ignore.Match(f.Name, false) {380			return nil381		}382		reader, err := f.Reader()383		if err != nil {384			return nil385		}386		defer reader.Close()387		blob, err := io.ReadAll(reader)388		if err != nil {389			return nil390		}391		res := cache.classify(f.Hash, f.Name, blob)392		if !res.ok {393			return nil394		}395		baseline.Files[f.Name] = BaselineFile{396			Path:       f.Name,397			Language:   res.language,398			LineTypes:  res.lineTypes,399			Complexity: res.complexLine,400		}401		return nil402	})403404	return baseline405}406407// buildHeadSnapshot walks the HEAD commit's tree and runs scc's classifier408// on each file. Used by hotspots (and future reports) to know each surviving409// file's current language and complexity.410func buildHeadSnapshot(headCommit *object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (snap HeadSnapshot, err error) {411	snap = emptySnapshot()412	// Backstop for panics outside the per-file recover below — go-git's413	// tree.Files() iterator can itself panic on a corrupt object. Return the414	// partial snapshot with no error so the caller keeps what we collected.415	defer func() {416		if r := recover(); r != nil {417			printWarnF("history: HEAD snapshot walk panicked, using partial result: %v", r)418			err = nil419		}420	}()421422	tree, err := headCommit.Tree()423	if err != nil {424		return emptySnapshot(), err425	}426427	snap = HeadSnapshot{Files: map[string]HeadFile{}}428	err = tree.Files().ForEach(func(f *object.File) error {429		defer func() {430			if r := recover(); r != nil {431				name := "<unknown>"432				if f != nil {433					name = f.Name434				}435				printWarnF("history: skipping %s in HEAD snapshot — panicked: %v", name, r)436			}437		}()438		if f.Mode == filemode.Dir || f.Mode == filemode.Submodule || f.Mode == filemode.Symlink {439			return nil440		}441		if ignore != nil && ignore.Match(f.Name, false) {442			return nil443		}444		reader, err := f.Reader()445		if err != nil {446			return nil447		}448		defer reader.Close()449		blob, err := io.ReadAll(reader)450		if err != nil {451			return nil452		}453454		res := cache.classify(f.Hash, f.Name, blob)455		if !res.ok {456			return nil457		}458459		snap.Files[f.Name] = HeadFile{460			Path:       f.Name,461			Language:   res.language,462			Complexity: res.complexity,463			Cognitive:  res.cognitive,464		}465		return nil466	})467	return snap, err468}469470// historyDiffOptions forces rename detection on. The rename-aware reports471// (author rollup, hotspots) depend on renames arriving as a single change472// rather than a delete + add pair, so pin it explicitly — a future go-git473// bump can't silently disable it.474var historyDiffOptions = &object.DiffTreeOptions{DetectRenames: true}475476// commitChanges computes the first-parent diff for commit and projects every477// change into a FileChange. Skips paths that the engine can't count478// (binary blobs, no language detected, submodules, symlinks, ignored paths).479// Deletes are dropped because hotspots-style reports can't render files that480// no longer exist.481//482// The outer recover catches anything the per-call wrappers don't (corrupt483// packfiles via go-git object resolution, future regressions in the diff484// pipeline). One bad commit becomes a warning, not a crash.485func commitChanges(ctx context.Context, commit *object.Commit, ignore *historyIgnore, cache *blobClassifyCache) (out []FileChange, err error) {486	defer func() {487		if r := recover(); r != nil {488			printWarnF("history: skipping commit %s — diff pipeline panicked: %v", commit.Hash, r)489			out = nil490			err = nil491		}492	}()493494	toTree, err := commit.Tree()495	if err != nil {496		return nil, err497	}498499	var fromTree *object.Tree500	if commit.NumParents() > 0 {501		parent, err := commit.Parent(0)502		// A shallow-clone boundary commit carries a parent hash whose object is503		// absent. Treat the missing parent as no parent (leave fromTree nil, so504		// the commit diffs against the empty tree) instead of failing — the505		// same end-of-history handling as commits with zero parents.506		if errors.Is(err, plumbing.ErrObjectNotFound) {507			return nil, nil508		}509		if err != nil {510			return nil, err511		}512		fromTree, err = parent.Tree()513		if errors.Is(err, plumbing.ErrObjectNotFound) {514			return nil, nil515		}516		if err != nil {517			return nil, err518		}519	}520521	changes, err := object.DiffTreeWithOptions(ctx, fromTree, toTree, historyDiffOptions)522	if err != nil {523		return nil, err524	}525526	out = make([]FileChange, 0, len(changes))527	for _, change := range changes {528		fc, ok := buildFileChange(change, ignore, cache)529		if !ok {530			continue531		}532		out = append(out, fc)533	}534	return out, nil535}536537// buildFileChange converts a single object.Change into a FileChange.538func buildFileChange(change *object.Change, ignore *historyIgnore, cache *blobClassifyCache) (FileChange, bool) {539	action, err := change.Action()540	if err != nil {541		return FileChange{}, false542	}543	if action == merkletrie.Delete {544		return FileChange{}, false545	}546547	path := change.To.Name548	fromPath := change.From.Name549	toEntry := change.To.TreeEntry550	if toEntry.Mode == filemode.Dir || toEntry.Mode == filemode.Submodule || toEntry.Mode == filemode.Symlink {551		return FileChange{}, false552	}553554	if ignore != nil && ignore.Match(path, false) {555		return FileChange{}, false556	}557558	languages, _ := DetectLanguage(path)559	if len(languages) == 0 {560		return FileChange{}, false561	}562563	patch, ok := safePatch(change)564	if !ok {565		return FileChange{}, false566	}567568	var added, removed []LineRange569	for _, fp := range patch.FilePatches() {570		if fp.IsBinary() {571			return FileChange{}, false572		}573		toLine, fromLine := 1, 1574		for _, chunk := range fp.Chunks() {575			lines := lineCount(chunk.Content())576			switch chunk.Type() {577			case fdiff.Equal:578				toLine += lines579				fromLine += lines580			case fdiff.Add:581				if lines > 0 {582					added = append(added, LineRange{Start: toLine, Count: lines})583				}584				toLine += lines585			case fdiff.Delete:586				if lines > 0 {587					removed = append(removed, LineRange{Start: fromLine, Count: lines})588				}589				fromLine += lines590			}591		}592	}593594	blob, err := readBlob(change.To.Tree, &toEntry)595	if err != nil {596		return FileChange{}, false597	}598599	res := cache.classify(toEntry.Hash, path, blob)600	if !res.ok {601		return FileChange{}, false602	}603604	// Classify the parent blob so removed lines can be filtered to code —605	// the timeline reports need a symmetric code-only delta. The blob cache606	// makes this near-free: the old blob is normally an earlier commit's607	// new blob. Skip entirely when there are no removals (pure adds, or608	// when the diff produced no removed ranges).609	var removedLineTypes []LineType610	if len(removed) > 0 && change.From.Name != "" {611		fromEntry := change.From.TreeEntry612		if fromEntry.Mode != filemode.Dir &&613			fromEntry.Mode != filemode.Submodule &&614			fromEntry.Mode != filemode.Symlink {615			if oldBlob, rerr := readBlob(change.From.Tree, &fromEntry); rerr == nil {616				if oldRes := cache.classify(fromEntry.Hash, change.From.Name, oldBlob); oldRes.ok {617					removedLineTypes = oldRes.lineTypes618				}619			}620		}621	}622623	return FileChange{624		Path:             path,625		FromPath:         fromPath,626		Language:         res.language,627		AddedRanges:      added,628		RemovedRanges:    removed,629		LineTypes:        res.lineTypes,630		RemovedLineTypes: removedLineTypes,631		Complexity:       res.complexLine,632		NewBlob:          blob,633	}, true634}635636// safePatch wraps change.Patch() with panic recovery. The underlying637// sergi/go-diff line-to-rune encoding panics when a file has more distinct638// lines than fit in the Unicode code-point space (generated SQL, huge639// minified bundles, vendored data files). Treat any panic or error as640// "skip this file" so one bad file does not abort the whole report.641func safePatch(change *object.Change) (patch *object.Patch, ok bool) {642	defer func() {643		if r := recover(); r != nil {644			path := ""645			if change != nil {646				path = change.To.Name647				if path == "" {648					path = change.From.Name649				}650			}651			printWarnF("history: skipping %s — diff library panicked: %v", path, r)652			patch = nil653			ok = false654		}655	}()656	p, err := change.Patch()657	if err != nil {658		return nil, false659	}660	return p, true661}662663// classifyFn is the indirect reference to classifyHistoryBlob used by664// safeClassify. Tests substitute a panicking stub to exercise the recover665// path; production behaviour is unchanged.666var classifyFn = classifyHistoryBlob667668// safeClassify wraps the classifier with panic recovery. The history walk669// feeds the classifier many more blob shapes than the working-tree counter670// ever sees (legacy encodings, partial UTF-8, oversized blobs, vendored671// data). A panic in any one path-blob pair must not abort the report —672// skip the file with a warning instead.673func safeClassify(path string, blob []byte) (job *FileJob, lineTypes []LineType, ok bool) {674	defer func() {675		if r := recover(); r != nil {676			printWarnF("history: skipping %s — classifier panicked: %v", path, r)677			job = nil678			lineTypes = nil679			ok = false680		}681	}()682	return classifyFn(path, blob)683}684685// readBlob fetches the raw bytes for a tree entry.686func readBlob(tree *object.Tree, entry *object.TreeEntry) ([]byte, error) {687	file, err := tree.TreeEntryFile(entry)688	if err != nil {689		return nil, err690	}691	reader, err := file.Reader()692	if err != nil {693		return nil, err694	}695	defer reader.Close()696	return io.ReadAll(reader)697}698699// blobClassifyResult is the cached output of classifyHistoryBlob for a single700// blob hash. ok=false means the classifier rejected the blob (binary, no701// language); the vectors are nil in that case.702type blobClassifyResult struct {703	language      string704	complexity    int64705	cognitive     int64 // nesting-weighted complexity; zero unless the Cognitive global is on706	lineTypes     []LineType707	complexLine   []int708	cognitiveLine []int // 1-based lines that accrued cognitive weight; nil unless Cognitive is on709	ok            bool710}711712// blobClassifyCache memoises classifyHistoryBlob output keyed by blob hash so713// the same blob seen in baseline, commit changes, and HEAD is classified once714// per runHistory. The walk is sequential, so no mutex is required.715type blobClassifyCache struct {716	entries map[plumbing.Hash]blobClassifyResult717}718719func newBlobClassifyCache() *blobClassifyCache {720	return &blobClassifyCache{entries: make(map[plumbing.Hash]blobClassifyResult)}721}722723// classify returns the classifier output for blob, computing and caching it724// on first sight. Slices in the returned result are shared between callers —725// they must be treated as read-only. Negative results (ok=false) are cached726// too so binary/unknown blobs aren't re-attempted.727func (c *blobClassifyCache) classify(hash plumbing.Hash, path string, blob []byte) blobClassifyResult {728	if c != nil {729		if hit, found := c.entries[hash]; found {730			return hit731		}732	}733	job, lineTypes, ok := safeClassify(path, blob)734	res := blobClassifyResult{ok: ok}735	if ok {736		res.language = job.Language737		res.complexity = job.Complexity738		res.cognitive = job.Cognitive739		res.lineTypes = lineTypes740		res.complexLine = complexityLineNumbers(job)741		res.cognitiveLine = cognitiveLineNumbers(job)742	}743	if c != nil {744		c.entries[hash] = res745	}746	return res747}748749// classifyHistoryBlob runs scc's existing classifier on a git blob's bytes750// and returns the resulting FileJob (Language / Complexity / Code / Comment751// / Blank populated) plus the per-line type vector. ok=false means the file752// is binary or the language could not be resolved.753func classifyHistoryBlob(path string, blob []byte) (*FileJob, []LineType, bool) {754	languages, extension := DetectLanguage(path)755	if len(languages) == 0 {756		return nil, nil, false757	}758	for _, l := range languages {759		LoadLanguageFeature(l)760	}761762	job := &FileJob{763		Location:             path,764		Filename:             basename(path),765		Extension:            extension,766		PossibleLanguages:    languages,767		Bytes:                int64(len(blob)),768		Content:              blob,769		TrackComplexityLines: true,770	}771772	job.Language = DetermineLanguage(job.Filename, job.Language, job.PossibleLanguages, job.Content)773	if job.Language == SheBang {774		cutoff := min(len(blob), 200)775		lang, err := DetectSheBang(blob[:cutoff])776		if err != nil {777			return nil, nil, false778		}779		job.Language = lang780		LoadLanguageFeature(lang)781	}782783	classifier := &historyLineCallback{}784	job.Callback = classifier785786	CountStats(job)787788	if job.Binary {789		return nil, nil, false790	}791792	return job, classifier.lineTypes, true793}794795// complexityLineNumbers returns the 1-based line numbers in job that fired a796// complexity tick. Convenience wrapper for observers that want per-line797// complexity placement (the per-line attribution reports in plans 03–04).798func complexityLineNumbers(job *FileJob) []int {799	out := make([]int, 0)800	for i, count := range job.ComplexityLine {801		if count > 0 {802			out = append(out, i+1)803		}804	}805	return out806}807808// cognitiveLineNumbers returns the 1-based line numbers in job that accrued809// cognitive weight. Mirrors complexityLineNumbers for the cognitive per-line810// array; returns an empty slice when cognitive tracking is off (CognitiveLine811// nil), so callers get the same shape either way.812func cognitiveLineNumbers(job *FileJob) []int {813	out := make([]int, 0)814	for i, weight := range job.CognitiveLine {815		if weight > 0 {816			out = append(out, i+1)817		}818	}819	return out820}821822type historyLineCallback struct {823	lineTypes []LineType824}825826func (h *historyLineCallback) ProcessLine(job *FileJob, currentLine int64, lineType LineType) bool {827	h.lineTypes = append(h.lineTypes, lineType)828	return true829}830831func basename(path string) string {832	if i := strings.LastIndex(path, "/"); i >= 0 {833		return path[i+1:]834	}835	return path836}837838func lineCount(s string) int {839	if s == "" {840		return 0841	}842	n := strings.Count(s, "\n")843	if !strings.HasSuffix(s, "\n") {844		n++845	}846	return n847}

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.