-
Notifications
You must be signed in to change notification settings - Fork 460
fix: migrate 5 linters from syntactic stdlib-package matching to astutil.IsPkgSelector #43939
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
Changes from all commits
02a7a21
243fc35
8d59e82
1f9aca1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # ADR-43939: Use astutil.IsPkgSelector for Stdlib Package Detection in Linters | ||
|
|
||
| **Date**: 2026-07-07 | ||
| **Status**: Draft | ||
| **Deciders**: Unknown | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| Five custom linters (`errstringmatch`, `fprintlnsprintf`, `osexitinlibrary`, `fileclosenotdeferred`, `contextcancelnotdeferred`) identified stdlib package calls using syntactic identifier name comparison: `ident.Name == "os"`, `ident.Name == "fmt"`, etc. This approach only inspects the AST identifier name, not the resolved import path, which produces two classes of defects: | ||
|
|
||
| 1. **False negatives**: aliased imports (e.g., `import xos "os"`) bypass the guard entirely because `xos != "os"`, causing the linter to miss real violations. | ||
| 2. **False positives**: local variables that shadow the package name (e.g., `var os fakeOS`) satisfy the string match even though `os` no longer refers to the stdlib package. Three of these linters are CI-enforced, making false positives build-blockers. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will replace every syntactic `ident.Name == "<pkg>"` guard in these five linters with `astutil.IsPkgSelector(pass, sel, "<pkg>")`, which resolves the selector's receiver through `pass.TypesInfo` to the actual imported package path. This requires threading `*analysis.Pass` into the affected private helper functions (`isFileOpenCall`, `isContextWithCancelCall`, `brittleErrStringFuncName`, `isFmtFunc`). Test data cases for aliased imports and local shadowing are added per linter to lock in the corrected behaviour. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Retain Syntactic Matching and Document Limitations | ||
|
|
||
| Continue using `ident.Name == "<pkg>"` and add a comment acknowledging that aliased imports are not detected and shadowed variables may produce false positives. This was rejected because the false-positive case is a concrete build-blocker for three CI-enforced linters — documenting a known defect does not eliminate the build failures it causes. The false-negative case also undermines the purpose of the linters, which is to catch real coding mistakes regardless of import style. | ||
|
|
||
| #### Alternative 2: Resolve Aliases Manually via import-spec Iteration | ||
|
|
||
| Iterate over the file's `ast.ImportSpec` nodes to build a map from local identifier to canonical package path, then check the local identifier's canonical path. This would fix false negatives for aliased imports. It was rejected because it reimplements logic that `pass.TypesInfo` already provides correctly, adds per-file bookkeeping to every linter run function, and still requires a custom lookup at each call site — more code and more opportunity for error than delegating to `astutil.IsPkgSelector`. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - False-positive build failures caused by shadowed package names are eliminated for all three CI-enforced linters. | ||
| - Aliased stdlib imports (e.g., `import ctx "context"`) are now correctly detected and flagged. | ||
| - The fix uses the type system (`pass.TypesInfo`) as the authoritative source for package identity, consistent with how Go analysis tools are intended to work. | ||
| - New testdata files provide regression coverage for both the alias case and the shadowing case. | ||
|
|
||
| #### Negative | ||
| - All five affected helper functions now require `*analysis.Pass` as an additional parameter, making their signatures more verbose and coupling them to the analysis pass context. | ||
| - Callers cannot invoke the helpers outside an `analysis.Pass` run (e.g., in a standalone unit test that constructs AST nodes without a full type-checker pass), which reduces testability in isolation. | ||
|
|
||
| #### Neutral | ||
| - No change to the diagnostic messages or suggested fixes produced by these linters; only the package-identity predicate changes. | ||
| - The pattern established here (`astutil.IsPkgSelector` instead of `ident.Name`) can serve as a guide for any future linter that needs to identify stdlib calls. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package contextcancelnotdeferred | ||
|
|
||
| import ( | ||
| ctx "context" | ||
| "time" | ||
| ) | ||
|
|
||
| // flagged: aliased context import still resolves to context package | ||
| func BadWithCancelAliased(parent ctx.Context) error { | ||
| c, cancel := ctx.WithCancel(parent) // want `context cancel function should be deferred immediately after context.WithCancel/WithTimeout/WithDeadline` | ||
| _ = c | ||
| cancel() | ||
| return nil | ||
| } | ||
|
|
||
| // not flagged: defer used correctly with aliased import | ||
| func GoodWithTimeoutAliased(parent ctx.Context) error { | ||
| c, cancel := ctx.WithTimeout(parent, time.Second) | ||
| defer cancel() | ||
| _ = c | ||
| return nil | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No shadowing test for 💡 Suggested addition// not flagged: local type shadows package name
type fakeContext struct{}
func (fakeContext) WithCancel(parent interface{}) (interface{}, func()) { return parent, func() {} }
func noFalsePosContextShadow() {
var context fakeContext
_, cancel := context.WithCancel(nil)
cancel() // direct call, not deferred — should NOT be flagged
}Consistency with the other four linters makes it easier to verify the pattern is fully symmetric. @copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing shadow/negative test case: same gap as 💡 DetailsThe three CI-enforced linters in this PR all have negative shadow cases; the two non-CI-enforced linters ( // not flagged: local var named after the context alias — not the stdlib package
type fakeContext struct{}
func (fakeContext) WithCancel(parent context.Context) (context.Context, func()) {
return parent, func() {}
}
func notFlaggedShadowedContext(parent context.Context) {
var ctx fakeContext
c, cancel := ctx.WithCancel(parent) // no diagnostic expected
defer cancel()
_ = c
}Without this, the false-positive regression path for this linter is completely untested. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package errstringmatch | ||
|
|
||
| import ( | ||
| str "strings" | ||
| ) | ||
|
|
||
| // flagged: aliased strings import still resolves to strings package | ||
| func checkErrorAliased(err error) bool { | ||
| return str.Contains(err.Error(), "not found") // want `avoid strings\.Contains\(err\.Error\(\)` | ||
| } | ||
|
|
||
| // not flagged: shadowing local named "strings" — not the stdlib package | ||
| type fakeStrings struct{} | ||
|
|
||
| func (fakeStrings) Contains(s, substr string) bool { return false } | ||
|
|
||
| func checkShadowedStrings(err error) bool { | ||
| var strings fakeStrings | ||
| return strings.Contains(err.Error(), "prefix") | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,23 @@ | ||||||||||
| package fileclosenotdeferred | ||||||||||
|
|
||||||||||
| import xos "os" | ||||||||||
|
|
||||||||||
| // flagged: aliased os import still resolves to os package | ||||||||||
| func ReadFileManualCloseAliased() error { | ||||||||||
| file, err := xos.Open("test.txt") // want `file Close\(\) should be deferred immediately after successful open to prevent resource leaks` | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
| file.Close() | ||||||||||
| return nil | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // not flagged: defer used correctly with aliased import | ||||||||||
| func ReadFileDeferCloseAliased() error { | ||||||||||
| file, err := xos.Open("test.txt") | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
| defer file.Close() | ||||||||||
| return nil | ||||||||||
| } | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested additions// flagged: os.Create with alias
func WriteFileManualCloseAliased() error {
file, err := xos.Create("out.txt") // want `file Close\(\) should be deferred immediately after successful open`
if err != nil { return err }
file.Close()
return nil
}
// flagged: os.OpenFile with alias
func OpenFileManualCloseAliased() error {
file, err := xos.OpenFile("rw.txt", xos.O_RDWR, 0644) // want `file Close\(\) should be deferred ...`
if err != nil { return err }
file.Close()
return nil
}@copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing shadow/negative test case: this alias file only tests the true-positive path (alias import → still flagged) and the correct-defer path, but never tests that a local variable shadowing the package name is not flagged — the central regression guard for this migration. 💡 DetailsAll three CI-enforced linters that received this fix have matching shadow tests in their alias files:
// not flagged: local var shadows the alias name — not the stdlib package
type fakeOS struct{}
func (fakeOS) Open(name string) (*os.File, error) { return nil, nil }
func ReadFileShadowedOS() error {
var xos fakeOS
file, err := xos.Open("test.txt")
// no diagnostic expected here
_ = file
return err
}If |
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package fprintlnsprintf | ||
|
|
||
| import ( | ||
| xfmt "fmt" | ||
| "os" | ||
| ) | ||
|
|
||
| // flagged: aliased fmt import still resolves to fmt package | ||
| func flaggedAliased(name string) { | ||
| xfmt.Fprintln(os.Stderr, xfmt.Sprintf("hello %s", name)) // want "use fmt.Fprintf" | ||
| } | ||
|
|
||
| // not flagged: shadowing local named "fmt" — not the stdlib package | ||
| type fakeFmt struct{} | ||
|
|
||
| func (fakeFmt) Fprintln(_ interface{}, args ...interface{}) {} | ||
| func (fakeFmt) Sprintf(format string, args ...interface{}) string { return "" } | ||
|
|
||
| func notFlaggedShadowedFmt(name string) { | ||
| var fmt fakeFmt | ||
| fmt.Fprintln(os.Stderr, fmt.Sprintf("hello %s", name)) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The shadowing negative case only tests that the 💡 Example// not flagged: fmt.Fprintln with a real fmt, but inner call is from fakeFmt — should not match
func mixedRealFakeFmt(name string) {
var f fakeFmt
xfmt.Fprintln(os.Stderr, f.Sprintf("hello %s", name)) // inner Sprintf not fmt.Sprintf
}This guards against partial matches where only one selector resolves to the stdlib package. @copilot please address this. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package fprintlnsprintf | ||
|
|
||
| import ( | ||
| xfmt "fmt" | ||
| "os" | ||
| ) | ||
|
|
||
| // flagged: aliased fmt import still resolves to fmt package | ||
| func flaggedAliased(name string) { | ||
| xfmt.Fprintf(os.Stderr, "hello %s\n", name) // want "use fmt.Fprintf" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spurious 💡 DetailsLine 10 reads: xfmt.Fprintf(os.Stderr, "hello %s\n", name) // want "use fmt.Fprintf"After autofix, Remove the trailing |
||
| } | ||
|
|
||
| // not flagged: shadowing local named "fmt" — not the stdlib package | ||
| type fakeFmt struct{} | ||
|
|
||
| func (fakeFmt) Fprintln(_ interface{}, args ...interface{}) {} | ||
| func (fakeFmt) Sprintf(format string, args ...interface{}) string { return "" } | ||
|
|
||
| func notFlaggedShadowedFmt(name string) { | ||
| var fmt fakeFmt | ||
| fmt.Fprintln(os.Stderr, fmt.Sprintf("hello %s", name)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package osexitinlibrary | ||
|
|
||
| import xos "os" | ||
|
|
||
| // flagged: aliased os import still resolves to os package | ||
| func stopProcessAliased() { | ||
| xos.Exit(1) // want `os.Exit called in library package` | ||
| } | ||
|
|
||
| // not flagged: shadowing local named "os" — not the stdlib package | ||
| type fakeOS struct{} | ||
|
|
||
| func (fakeOS) Exit(code int) {} | ||
|
|
||
| func notFlaggedShadowedOS() { | ||
| var os fakeOS | ||
| os.Exit(1) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] Missing
ctx.WithDeadlinealias test —isContextWithCancelCallhandlesWithCancel,WithTimeout, andWithDeadline, but only the first two are covered with an aliased import.💡 Suggested addition
All three
switchcases inisContextWithCancelCallshould be covered by alias tests to guard against per-variant regressions.@copilot please address this.