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 := 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}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.