Skip to content

Commit 7a7b830

Browse files
authored
rawloginlib: resolve log package identity via types to fix shadowing FP and alias FN (#39990)
1 parent da13dfa commit 7a7b830

6 files changed

Lines changed: 174 additions & 8 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ADR-39990: Resolve Package Identity via Type Information in Custom Linters
2+
3+
**Date**: 2026-06-18
4+
**Status**: Draft
5+
6+
## Context
7+
8+
The `rawloginlib` analyzer detects raw `log.Printf`/`log.Fatal`-style calls in library packages by matching the selector's receiver identifier name syntactically (`ident.Name == "log"`). This name-based heuristic is unaware of Go's actual binding semantics: it produced CI-blocking false positives when a local variable or parameter named `log` shadowed the package (e.g. a `*customLogger` receiver), and false negatives when the stdlib package was imported under an alias (`import applog "log"`). Custom linters in this repo (e.g. ADR-39133, ADR-39263) share the same family of selector-inspection logic, so the fix needs to be reusable rather than local to one analyzer.
9+
10+
## Decision
11+
12+
We will determine package identity for selector expressions using resolved type information (`go/types`) instead of matching identifier names. We added `astutil.IsPkgSelector(pass, sel, pkgPath)`, which resolves the selector's receiver identifier through `pass.TypesInfo.ObjectOf`, confirms it is a `*types.PkgName`, and compares the imported package's import path. `rawloginlib` now gates its diagnostic on `IsPkgSelector(pass, sel, "log")` rather than the receiver's spelling. The helper is centralized in the shared `astutil` package so other linters can reuse the same type-resolved identity check.
13+
14+
## Alternatives Considered
15+
16+
### Alternative 1: Keep name matching with extra heuristics
17+
We could have kept `ident.Name == "log"` and layered on ad-hoc handling for aliases and shadowing (e.g. scanning import specs to map alias names back to paths). This was rejected because it reconstructs, imperfectly, information the type checker already computes; it would remain fragile against any binding the heuristics did not anticipate.
18+
19+
### Alternative 2: Inline type resolution per linter
20+
We could have inlined the `TypesInfo` / `*types.PkgName` resolution directly inside `rawloginlib` without a shared helper. This was rejected because the same logic already recurs across the repo's custom linters; duplicating it invites drift and repeated nil-safety bugs.
21+
22+
## Consequences
23+
24+
### Positive
25+
- Eliminates the false-positive and false-negative classes for shadowed receivers and aliased imports, removing spurious CI failures.
26+
- Provides a single, tested `astutil.IsPkgSelector` helper reusable by current and future linters.
27+
28+
### Negative
29+
- Correctness now depends on `pass.TypesInfo` being populated; the helper degrades to `false` when type info is absent, so a misconfigured analysis pass silently under-reports rather than erroring.
30+
- Type-based resolution is marginally more complex and less obvious to a reader than a direct name comparison.
31+
32+
### Neutral
33+
- Diagnostic message text and `//nolint` suppression behavior are unchanged.
34+
- Adds testdata cases (alias import, shadowed param/local) and `astutil` unit tests covering the new helper.
35+
36+
---
37+
38+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27739763043) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

pkg/linters/internal/astutil/astutil.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ func IsFmtErrorf(pass *analysis.Pass, call *ast.CallExpr) bool {
7272
return pkgName.Imported().Path() == "fmt"
7373
}
7474

75+
// IsPkgSelector reports whether sel is a selector on an imported package with
76+
// the given import path.
77+
func IsPkgSelector(pass *analysis.Pass, sel *ast.SelectorExpr, pkgPath string) bool {
78+
if pass == nil || pass.TypesInfo == nil || sel == nil {
79+
return false
80+
}
81+
pkgIdent, ok := sel.X.(*ast.Ident)
82+
if !ok {
83+
return false
84+
}
85+
obj := pass.TypesInfo.ObjectOf(pkgIdent)
86+
if obj == nil {
87+
return false
88+
}
89+
pkgName, ok := obj.(*types.PkgName)
90+
if !ok || pkgName.Imported() == nil {
91+
return false
92+
}
93+
return pkgName.Imported().Path() == pkgPath
94+
}
95+
7596
// Inspector extracts the *inspector.Inspector from pass.ResultOf.
7697
// It returns an error if the result has an unexpected type.
7798
func Inspector(pass *analysis.Pass) (*inspector.Inspector, error) {

pkg/linters/internal/astutil/astutil_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package astutil
33
import (
44
"go/ast"
55
"go/token"
6+
"go/types"
67
"testing"
8+
9+
"golang.org/x/tools/go/analysis"
710
)
811

912
func TestRhsExprForIndex(t *testing.T) {
@@ -62,3 +65,87 @@ func TestNodeText(t *testing.T) {
6265
t.Fatalf("NodeText = %q, want %q", got, "myVar")
6366
}
6467
}
68+
69+
func TestIsPkgSelector(t *testing.T) {
70+
t.Parallel()
71+
72+
makePass := func(ident *ast.Ident, obj types.Object) *analysis.Pass {
73+
return &analysis.Pass{
74+
TypesInfo: &types.Info{
75+
Uses: map[*ast.Ident]types.Object{
76+
ident: obj,
77+
},
78+
},
79+
}
80+
}
81+
82+
logIdent := ast.NewIdent("log")
83+
aliasIdent := ast.NewIdent("applog")
84+
localIdent := ast.NewIdent("log")
85+
86+
logPkg := types.NewPackage("log", "log")
87+
customType := types.NewNamed(
88+
types.NewTypeName(token.NoPos, nil, "customLogger", nil),
89+
types.NewStruct(nil, nil),
90+
nil,
91+
)
92+
93+
tests := []struct {
94+
name string
95+
pass *analysis.Pass
96+
sel *ast.SelectorExpr
97+
pkgPath string
98+
want bool
99+
}{
100+
{
101+
name: "direct import name",
102+
pass: makePass(logIdent, types.NewPkgName(token.NoPos, nil, "log", logPkg)),
103+
sel: &ast.SelectorExpr{
104+
X: logIdent,
105+
Sel: ast.NewIdent("Printf"),
106+
},
107+
pkgPath: "log",
108+
want: true,
109+
},
110+
{
111+
name: "aliased import name",
112+
pass: makePass(aliasIdent, types.NewPkgName(token.NoPos, nil, "applog", logPkg)),
113+
sel: &ast.SelectorExpr{
114+
X: aliasIdent,
115+
Sel: ast.NewIdent("Fatal"),
116+
},
117+
pkgPath: "log",
118+
want: true,
119+
},
120+
{
121+
name: "local shadowed identifier",
122+
pass: makePass(localIdent, types.NewVar(token.NoPos, nil, "log", types.NewPointer(customType))),
123+
sel: &ast.SelectorExpr{
124+
X: localIdent,
125+
Sel: ast.NewIdent("Printf"),
126+
},
127+
pkgPath: "log",
128+
want: false,
129+
},
130+
{
131+
name: "nil pass",
132+
pass: nil,
133+
sel: &ast.SelectorExpr{
134+
X: logIdent,
135+
Sel: ast.NewIdent("Printf"),
136+
},
137+
pkgPath: "log",
138+
want: false,
139+
},
140+
}
141+
142+
for _, tt := range tests {
143+
t.Run(tt.name, func(t *testing.T) {
144+
t.Parallel()
145+
got := IsPkgSelector(tt.pass, tt.sel, tt.pkgPath)
146+
if got != tt.want {
147+
t.Fatalf("IsPkgSelector() = %v, want %v", got, tt.want)
148+
}
149+
})
150+
}
151+
}

pkg/linters/rawloginlib/rawloginlib.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,17 @@ func run(pass *analysis.Pass) (any, error) {
5555
if !ok {
5656
return
5757
}
58-
ident, ok := sel.X.(*ast.Ident)
59-
if !ok {
58+
if !astutil.IsPkgSelector(pass, sel, "log") {
59+
return
60+
}
61+
if !rawLogFuncs[sel.Sel.Name] {
6062
return
6163
}
62-
if ident.Name == "log" && rawLogFuncs[sel.Sel.Name] {
63-
position := pass.Fset.PositionFor(call.Pos(), false)
64-
if nolint.HasDirective(position, noLintLinesByFile) {
65-
return
66-
}
67-
pass.ReportRangef(call, "log.%s called in library package %s; use pkg/logger instead", sel.Sel.Name, pkgPath)
64+
position := pass.Fset.PositionFor(call.Pos(), false)
65+
if nolint.HasDirective(position, noLintLinesByFile) {
66+
return
6867
}
68+
pass.ReportRangef(call, "log.%s called in library package %s; use pkg/logger instead", sel.Sel.Name, pkgPath)
6969
})
7070

7171
return nil, nil
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package rawloginlib
2+
3+
import applog "log"
4+
5+
func badAlias() {
6+
applog.Fatal("boom") // want `log\.Fatal called in library package`
7+
}

pkg/linters/rawloginlib/testdata/src/rawloginlib/rawloginlib.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package rawloginlib
22

33
import "log"
44

5+
type customLogger struct{}
6+
7+
func (*customLogger) Printf(string, ...any) {}
8+
59
func bad() {
610
log.Printf("hello %s", "world") // want `log\.Printf called in library package`
711
log.Println("oops") // want `log\.Println called in library package`
@@ -17,3 +21,12 @@ func suppressed() {
1721
log.Printf("suppressed previous line")
1822
log.Println("suppressed same line") //nolint:rawloginlib
1923
}
24+
25+
func shadowedParam(log *customLogger) {
26+
log.Printf("shadowed parameter should not trigger")
27+
}
28+
29+
func shadowedLocal() {
30+
log := &customLogger{}
31+
log.Printf("shadowed local should not trigger")
32+
}

0 commit comments

Comments
 (0)