Ensure errors are handled or logged
if err != nil {
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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.