-
Notifications
You must be signed in to change notification settings - Fork 530
Add coverage-aware perf gating for allocation-focused custom linters #51573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
10fc1dc
Add coverage-aware perf gating for allocation-focused linters
Copilot 49fccfc
Fix coverage-gated return value in stringbytesroundtrip per code review
Copilot 2a7c933
docs(adr): add draft ADR-51573 for coverage-aware perf linter gating
github-actions[bot] 87c6094
feat(linters/coverage): add missing coverage package with tests, fix …
Copilot a45b197
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] bb9d3af
feat(linters/coverage): add coverage package, consolidate stringbytes…
Copilot 83c3f13
feat(linters/coverage): add pkg/linters/internal/coverage package and…
Copilot a0d5b38
fix(gitignore): scope coverage/ rule to root-only so pkg/linters/inte…
Copilot 83fe723
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 4099593
feat(linters): add SKILL.md profile-gen section and stringsconcatloop…
Copilot b38961b
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 6fab5f9
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 57f83cc
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 60aa5b2
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 4bb1f70
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 3c444fc
Merge branch 'main' into copilot/update-golang-linters
github-actions[bot] 0d6e8bf
Merge branch 'main' into copilot/update-golang-linters
pelikhan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| # ADR-51573: Coverage-Aware Gating for Performance-Oriented Custom Linters | ||
|
|
||
| **Date**: 2026-08-09 | ||
| **Status**: Accepted | ||
| **Deciders**: pelikhan, copilot-swe-agent | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| The repository maintains a suite of custom Go static-analysis linters. A subset of these linters flag micro-optimizations that only matter on hot paths — code that executes frequently under real workloads. Examples include `stringsconcatloop` (O(n²) string concatenation), `appendbytestring`, `bytesbufferstring`, `seenmapbool`, and 9 others. Applying these rules uniformly to all code — including dead code or paths that tests never reach — generates review noise without a measurable performance payoff. At the same time, purely stylistic linters (e.g. `stringsindexcontains`, `lenstringzero`) carry no performance benefit regardless of execution frequency, so gating them on coverage would be misleading. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will add a shared `pkg/linters/internal/coverage` package that loads a Go coverage profile (produced by `go test -covermode=count -coverprofile=<path>`, referenced via the `GH_AW_LINT_COVERAGE_PROFILE` environment variable) and exposes two helpers: `ShouldApply(pass, pos, threshold)` to gate a diagnostic on the line's recorded execution hit count, and `RegisterHotThresholdFlag(analyzer)` to register a per-linter `-hot-threshold` flag. All 13 allocation/perf-oriented linters will integrate this mechanism via an `init()` function to avoid analyzer-initialization cycles. When no profile is loaded (the default), gating is a no-op and all linters behave exactly as before; purely stylistic linters are left ungated. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Uniform application (status quo) | ||
|
|
||
| Continue running all linters on all code regardless of coverage. This is simpler — no new package, no env-var convention, no per-linter flag — but produces diagnostic noise on dead or rarely-executed code paths, leading to review churn with no measurable performance return. | ||
|
|
||
| #### Alternative 2: Per-site `nolint` suppression | ||
|
|
||
| Require developers to suppress false-positive perf diagnostics on a case-by-case basis with `//nolint:stringsconcatloop` (or similar) comments. This keeps the linter infrastructure simple but places the burden on individual contributors each time a new cold-path finding appears, and does not scale as the codebase or linter set grows. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Perf linters fire only on code paths that tests actually exercise, eliminating diagnostic noise on dead or rarely-executed code. | ||
| - Fully permissive fallback: existing CI pipelines that do not set `GH_AW_LINT_COVERAGE_PROFILE` see no behavioral change. | ||
| - The `-hot-threshold` flag gives per-linter control; passing `0` disables coverage gating for a specific linter even when a profile is present. | ||
| - A documented, reusable pattern (`init()` + `coverage.RegisterHotThresholdFlag` + `coverage.ShouldApply`) makes it straightforward to wire future perf linters into the same mechanism. | ||
|
|
||
| #### Negative | ||
| - Activating coverage gating requires generating a coverage profile (`go test -covermode=count`) and setting `GH_AW_LINT_COVERAGE_PROFILE` in the lint environment; CI/CD pipeline configuration changes may be needed to realize the benefit. | ||
| - Two behavioral modes (gated vs. ungated) exist per perf linter, increasing the number of states to reason about when debugging a linter that is unexpectedly silent. | ||
|
|
||
| #### Neutral | ||
| - Purely stylistic/readability linters (`stringsindexcontains`, `stringsindexhasprefix`, `stringscountcontains`, `lenstringzero`, etc.) are intentionally excluded from coverage gating, which means the two linter categories now diverge in their configuration surface. | ||
| - The `init()` pattern (rather than a `var` initializer) is required to avoid an `Analyzer`/`run`/flag initialization cycle; this convention is documented in `pkg/linters/README.md` and `.github/skills/go-linters/SKILL.md`. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| // Package coverage provides helpers for coverage-aware perf linter gating. | ||
| // | ||
| // When the GH_AW_LINT_COVERAGE_PROFILE environment variable points to a Go | ||
| // coverage profile (produced by "go test -covermode=count -coverprofile=<path>"), | ||
| // [ShouldApply] returns false for code positions whose recorded hit count is | ||
| // below the configured threshold, suppressing findings on cold paths. | ||
| // | ||
| // When the variable is unset (the default), every call to [ShouldApply] | ||
| // returns true so all gated linters behave exactly as before. | ||
| package coverage | ||
|
|
||
| import ( | ||
| "go/token" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| xcov "golang.org/x/tools/cover" | ||
| "golang.org/x/tools/go/analysis" | ||
| ) | ||
|
|
||
| const envVar = "GH_AW_LINT_COVERAGE_PROFILE" | ||
|
|
||
| // profileIndex holds the lazily loaded coverage profiles indexed by filename. | ||
| type profileIndex struct { | ||
| profiles map[string]*xcov.Profile // key: Profile.FileName (package-qualified path) | ||
| } | ||
|
|
||
| var ( | ||
| once sync.Once | ||
| index *profileIndex // nil means no profile loaded (permissive fallback) | ||
| ) | ||
|
|
||
| // load loads the profile at most once per process from GH_AW_LINT_COVERAGE_PROFILE. | ||
| // On any error (env unset, bad path, parse failure) it leaves index nil so | ||
| // callers fall back to permissive behaviour. | ||
| func load() { | ||
| once.Do(func() { | ||
| path := os.Getenv(envVar) | ||
| if path == "" { | ||
| return | ||
| } | ||
| profiles, err := xcov.ParseProfiles(path) | ||
| if err != nil { | ||
| return | ||
| } | ||
| m := make(map[string]*xcov.Profile, len(profiles)) | ||
| for _, p := range profiles { | ||
| m[p.FileName] = p | ||
| } | ||
| index = &profileIndex{profiles: m} | ||
| }) | ||
| } | ||
|
|
||
| // findProfile looks up the profile entry that corresponds to the given on-disk | ||
| // filename. Coverage profile keys are package-qualified paths such as | ||
| // "github.com/org/repo/pkg/foo/foo.go", while pass.Fset returns absolute OS | ||
| // paths. We match by checking whether the normalised on-disk path ends with | ||
| // the profile key. | ||
| func (idx *profileIndex) findProfile(filename string) *xcov.Profile { | ||
| norm := filepath.ToSlash(filename) | ||
| for key, p := range idx.profiles { | ||
| normKey := filepath.ToSlash(key) | ||
| if strings.HasSuffix(norm, "/"+normKey) || norm == normKey { | ||
| return p | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // hitCount returns the execution count for the given 1-based line number. | ||
| // When multiple coverage blocks span the same line, the last one wins (standard | ||
| // Go coverage tool behaviour). Returns 0 when no block covers the line. | ||
| 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 | ||
| } | ||
|
|
||
| // ShouldApply reports whether a linter finding at pos should be reported. | ||
| // | ||
| // - When threshold is 0, coverage gating is disabled and the function | ||
| // always returns true. | ||
| // - When no coverage profile is loaded (GH_AW_LINT_COVERAGE_PROFILE is | ||
| // unset or the file cannot be parsed), the function always returns true | ||
| // (permissive fallback). | ||
| // - Otherwise it returns true only when the recorded hit count for the | ||
| // position's line is >= threshold. | ||
| func ShouldApply(pass *analysis.Pass, pos token.Pos, threshold int) bool { | ||
| if threshold == 0 { | ||
| return true | ||
| } | ||
| load() | ||
| if index == nil { | ||
| return true | ||
| } | ||
| position := pass.Fset.Position(pos) | ||
| if !position.IsValid() { | ||
| return true | ||
| } | ||
| p := index.findProfile(position.Filename) | ||
| if p == nil { | ||
| return true | ||
| } | ||
| return hitCount(p, position.Line) >= threshold | ||
| } | ||
|
|
||
| // RegisterHotThresholdFlag registers a -hot-threshold flag on the given | ||
| // analyzer and returns a pointer to the flag value. The default value is 1 | ||
| // (gate on any recorded execution). Pass 0 to disable gating entirely. | ||
| // | ||
| // This function must be called from an init() function, not from the analyzer | ||
| // Run function, to avoid an analyzer initialisation cycle. | ||
| func RegisterHotThresholdFlag(a *analysis.Analyzer) *int { | ||
| v := new(int) | ||
| *v = 1 | ||
| a.Flags.IntVar(v, "hot-threshold", 1, "minimum coverage hit count to report a finding (0 = always report)") | ||
| return v | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.