Skip to content

coverage.hitCount is column-blind: multiple same-line coverage blocks silently pick the wrong hit count, undermining perf-linter #66773

Description

@github-actions

Summary

First-ever audit of pkg/linters/internal/coverage (ADR-51573, coverage-aware perf-linter gating), the shared mechanism used by all 15 allocation/perf-oriented linters (appendbytestring, appendoneelement, bytesbufferstring, bytescomparestring, lenstringsplit, mapclearloop, reflectdeepequalusage, seenmapbool, slicemakezerolength, sortslice, stringbytesroundtrip, stringsconcatloop, stringsjoinone, tolowerequalfold, writebytestring) via coverage.ShouldApply. The helper hitCount resolves a diagnostic hit count by LINE only, with no column awareness, and silently picks the wrong blocks count whenever a source line is spanned by more than one coverage block.

Evidence

pkg/linters/internal/coverage/coverage.go hitCount (lines 103-111):

func hitCount(p *xcov.Profile, line int) int {
	count := 0
	for _, b := range p.Blocks {
		if b.StartLine <= line && line <= b.EndLine {
			count = b.Count
		}
	}
	return count
}

This loops over every block in the profile and keeps overwriting count whenever a block spans the queried line, so whichever matching block happens to sort last in p.Blocks wins, regardless of whether that block is the one actually containing the diagnostic position. ShouldApply (lines 122-139) calls this with only position.Line from pass.Fset.Position(pos) and never consults position.Column, so it cannot disambiguate.

A single source line can legitimately be spanned by two distinct coverage blocks, for example a one-line if x { a() } else { b() }, or any construct where go/cover emits adjacent blocks with the same StartLine/EndLine. If a gated linter reports a finding inside one block on that line but a different block on the same line happens to sort later in p.Blocks, hitCount silently returns the LATER blocks count instead of the block that actually contains the flagged position. Depending on which branch is hot vs cold, this either (a) suppresses a genuine hot-path finding because it inherits a colder sibling blocks count, or (b) reports a genuinely cold or dead-path finding because it inherits a hotter sibling blocks count - the opposite of what coverage gating is meant to guarantee.

Impact

Currently latent: GH_AW_LINT_COVERAGE_PROFILE is not set anywhere in CI today (grep of .github confirms it is referenced only in .github/skills/go-linters/SKILL.md documentation, never exported in a workflow), so ShouldApply always takes the permissive index == nil fallback and this code path never executes in practice. However this is the shared gating primitive for all 15 perf linters by design per ADR-51573, whose own stated intent is for a future CI pipeline to set this variable and activate gating - at that point every one of the 15 linters would inherit this column-blind mismatch on any source line shared by multiple coverage blocks, silently defeating the gating guarantee the ADR describes.

Recommendation

Either: (1) have hitCount select the block whose column range (StartCol/EndCol) actually contains the position, falling back to line-only matching only when no block has column info, or (2) change the last-one-wins tie-break to select the block with the smallest (most specific) span containing the position, consistent with how go tool cover -html resolves overlapping blocks. Add a coverage_test.go case with two synthetic blocks sharing one StartLine/EndLine but different Count and Column ranges to lock in the fix.

Validation checklist

  • Add a coverage_test.go case: two Profile.Blocks on the same line with different columns and counts, assert hitCount/ShouldApply picks the block matching the queried position
  • Re-run go test ./pkg/linters/internal/coverage/...
  • Confirm no behavioral change when GH_AW_LINT_COVERAGE_PROFILE is unset (permissive fallback path untouched)

Effort: small (single-file fix plus a targeted test), no risk to the 15 call sites since the public ShouldApply signature is unchanged.

Generated by 🤖 Sergo - Serena Go Expert · claude · agent · 277.9 AIC · ⊞ 5.2K · ◷

  • expires on Oct 14, 2026, 8:05 PM UTC-08:00

Activity

  1. pelikhan commented on Oct 11, 2026

    @pelikhan
    Collaborator

    Reviewed against completed essentials issue #67451 (#67451) and merged PR #67544 (#67544).

    Coverage block selection now uses source columns rather than line-only matching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cookieIssue Monster Loves Cookies!sergo

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions