Skip to content

Commit ee6854f

Browse files
github-actions[bot]CopilotpelikhanclaudeCopilot
authored
[linter-miner] feat(linters): add uncheckedtypeassertion linter (run #18) (#34738)
* feat(linters): add uncheckedtypeassertion linter Reports single-value type assertions x.(T) that may panic at runtime when the dynamic type does not match. Flags cases where the safe two-value form v, ok := x.(T) should be used instead. Evidence: issue #34580 — unchecked type assertion in GraphQL response parsing caused a panic on unexpected nil/wrong-type values, while sibling fields used the safe ok idiom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(adr): add draft ADR-34738 for uncheckedtypeassertion linter Generated by the Design Decision Gate. Documents the decision to add a new in-house static analyzer that flags single-value type assertions x.(T) which may panic at runtime, recommending the safe two-value form v, ok := x.(T) instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linters): satisfy Go lint in uncheckedtypeassertion analyzer Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * fix(linters): satisfy Go lint in uncheckedtypeassertion analyzer Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 57b7977 commit ee6854f

5 files changed

Lines changed: 261 additions & 0 deletions

File tree

cmd/linters/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/github/gh-aw/pkg/linters/rawloginlib"
3030
"github.com/github/gh-aw/pkg/linters/regexpcompileinfunction"
3131
"github.com/github/gh-aw/pkg/linters/ssljson"
32+
"github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion"
3233
)
3334

3435
func main() {
@@ -46,5 +47,6 @@ func main() {
4647
rawloginlib.Analyzer,
4748
regexpcompileinfunction.Analyzer,
4849
ssljson.Analyzer,
50+
uncheckedtypeassertion.Analyzer,
4951
)
5052
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# ADR-34738: Add uncheckedtypeassertion Linter
2+
3+
**Date**: 2026-05-25
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
Issue #34580 reported a runtime panic in GraphQL response parsing caused by an unchecked single-value type assertion `project["id"].(string)` when the API returned a `nil` or unexpected-type value, while sibling fields in the same block used the safe two-value `s, ok := v.(string)` idiom. A pattern scan turned up additional single-value assertions across `pkg/` that follow the same risky shape. Go's single-value type assertion `x.(T)` is a latent panic whenever the asserted dynamic type does not hold, whereas the two-value form `v, ok := x.(T)` returns the zero value and `false` and is safe to recover from. The repository already houses a family of small, focused, in-house analyzers under `pkg/linters/` (e.g., `fileclosenotdeferred`, `manualmutexunlock`, `fprintlnsprintf`, `regexpcompileinfunction`) registered through `cmd/linters/main.go`, so the established convention is to add another analyzer rather than rely on review discipline or an external tool.
14+
15+
### Decision
16+
17+
We will add a new static-analysis linter, `uncheckedtypeassertion`, that flags every `*ast.TypeAssertExpr` whose result is consumed in a single-value position and recommends rewriting the call site as the safe two-value form `v, ok := x.(T)`. The linter lives under `pkg/linters/uncheckedtypeassertion/`, is registered in `cmd/linters/main.go` alongside the existing analyzers, walks each `*ast.TypeAssertExpr` via the shared `inspect.Analyzer`, builds a per-file parent map to decide whether the assertion sits on the RHS of an assignment with two LHS targets, and skips type-switch guards (`v.(type)`, identified by `TypeAssertExpr.Type == nil`) and test files (via `pkg/linters/internal/filecheck.IsTestFile`). The implementation mirrors the structure of ADR-34498 (`fprintlnsprintf`), ADR-34091 (`manualmutexunlock`), and ADR-33834 (`fileclosenotdeferred`) for consistency.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Fix the known instances and rely on review
22+
23+
Patch the unchecked `project["id"].(string)` call site flagged in issue #34580 and the additional sites surfaced by the pattern scan, then trust reviewers to catch new instances. Rejected because the same shape appeared in multiple independent sites across `pkg/`, indicating that human review has not been sufficient to catch it. A mechanical check on every PR is cheaper than reviewer attention and cannot regress as new code lands.
24+
25+
#### Alternative 2: Use a third-party linter (e.g., `gocritic`, `forcetypeassert`)
26+
27+
`forcetypeassert` (and overlapping checks in `gocritic`, `staticcheck`) detect single-value type assertions. Rejected to stay consistent with the project's convention of small, focused, in-house analyzers under `pkg/linters/`, each as its own package with custom logic. Pulling in an external linter for a single rule introduces a new dependency surface, inconsistent rule configuration, and noise from rules the project has not opted into.
28+
29+
#### Alternative 3: Type-based filtering via `go/types`
30+
31+
Use `pass.TypesInfo` to suppress the diagnostic when the asserted type is itself an `interface{...}` that the operand already satisfies, or when the operand is statically known to be of the asserted type. Rejected because the analyzer is intentionally syntactic and structural: any single-value type assertion is a panic risk in production code regardless of static narrowing, and a uniform rule is easier to enforce than a context-sensitive one. False positives can be silenced at the call site by switching to the two-value form, which is always a safe rewrite.
32+
33+
### Consequences
34+
35+
#### Positive
36+
- New `x.(T)` single-value type assertions introduced after merge are caught by `make golint-custom` and fail in CI rather than landing on `main`, preventing future recurrences of the issue #34580 panic class.
37+
- The linter follows the same `pkg/linters/<name>/` layout, `Analyzer` shape, and `testdata` convention as sibling analyzers, so contributors can extend it without learning a new pattern.
38+
- Creates incentive to clean up the pre-existing single-value assertion sites surfaced by the pattern scan to maintain a clean linter signal.
39+
40+
#### Negative
41+
- Detection is structural: it flags every single-value type assertion outside test files, including cases where the author has consciously decided that a panic is acceptable (e.g., assertions on values the caller has already type-checked through an unrelated path). False positives must be silenced by rewriting to the two-value form `v, _ := x.(T)`, even when the discard `_` is the only practical handling.
42+
- The pre-existing single-value assertion sites are not fixed in this PR, so the linter cannot be made a blocking CI gate without follow-up work or suppression.
43+
- Adds one more analyzer to the registry, marginally increasing `cmd/linters/main.go` compile and run time, plus a per-file parent-map construction pass which is `O(n)` in AST node count per file.
44+
45+
#### Neutral
46+
- Test files are deliberately excluded via `filecheck.IsTestFile`, matching the convention used by sibling linters. Tests may legitimately use single-value assertions on fixture data where a panic is the desired failure mode.
47+
- Type-switch guards (`switch v.(type)`) are not flagged because the analyzer skips `TypeAssertExpr` nodes with `Type == nil`. This matches Go's own definition: a type-switch guard is not a runtime-panicking assertion.
48+
- The diagnostic is positional only — it does not emit a suggested-fix code action. Authors must rewrite the call manually.
49+
- The parent map is constructed per `*ast.File` rather than reused across analyzers, because the existing `inspect.Analyzer` does not expose parent links. The duplication is local to this analyzer and acceptable for the analyzer's size.
50+
51+
---
52+
53+
## Part 2 — Normative Specification (RFC 2119)
54+
55+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
56+
57+
### Linter Behaviour
58+
59+
1. The analyzer **MUST** be exported as `uncheckedtypeassertion.Analyzer` with `Name` equal to `"uncheckedtypeassertion"`.
60+
2. The analyzer **MUST** report a diagnostic for every `*ast.TypeAssertExpr` whose `Type` field is non-nil and whose direct parent in the containing file's AST is **not** an `*ast.AssignStmt` with exactly two LHS expressions and exactly one RHS expression.
61+
3. The analyzer **MUST NOT** report a diagnostic for any `*ast.TypeAssertExpr` whose `Type` field is `nil` (i.e., a type-switch guard `v.(type)`).
62+
4. The analyzer **MUST NOT** report a diagnostic when the parent `*ast.AssignStmt` has `len(Lhs) == 2 && len(Rhs) == 1`, regardless of whether the second LHS is the blank identifier `_` or a named `ok` variable.
63+
5. The analyzer **MUST NOT** report a diagnostic when the containing file is a Go test file as determined by `pkg/linters/internal/filecheck.IsTestFile`.
64+
6. The diagnostic `Pos`/`End` range **MUST** cover the entire `*ast.TypeAssertExpr` node (i.e., `pass.ReportRangef(typeAssert, ...)`).
65+
7. The diagnostic `Message` **SHOULD** include both the rendered asserted type and a recommendation to use the two-value form, of the shape `"type assertion x.(T) is unchecked and may panic; use the two-value form v, ok := x.(T) instead"`, so downstream tooling can match on a stable substring.
66+
8. The analyzer **MUST** declare `inspect.Analyzer` in its `Requires` list.
67+
9. The analyzer **MAY** rely on `pass.TypesInfo.TypeOf(typeAssert.Type)` to render the asserted type in the diagnostic; if the resolved type is `nil` the analyzer **MUST** silently skip emitting the diagnostic for that node rather than panicking or emitting a malformed message.
68+
69+
### Parent Resolution
70+
71+
1. The analyzer **MUST** construct a per-file map from each AST node to its direct parent node before the inspect pass evaluates type-assertion nodes, so that the two-value-form detection in requirement 4 is decidable in `O(1)` per node.
72+
2. The parent map **MUST** be scoped to the `*ast.File` that contains the inspected node; nodes from a different file in the same package **MUST NOT** be present in the same parent map.
73+
74+
### Registration
75+
76+
1. The analyzer **MUST** be registered in `cmd/linters/main.go` via the `multichecker.Main` argument list alongside the existing custom analyzers.
77+
2. The package import in `cmd/linters/main.go` **MUST** use the path `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion`.
78+
79+
### Package Layout
80+
81+
1. The linter source **MUST** live under `pkg/linters/uncheckedtypeassertion/`.
82+
2. Test fixtures **MUST** live under `pkg/linters/uncheckedtypeassertion/testdata/src/uncheckedtypeassertion/` and **MUST** use `// want` comments compatible with `golang.org/x/tools/go/analysis/analysistest`.
83+
3. The test fixtures **MUST** include at least one positive case for each of: a single-value assertion used as a `return` expression, and a single-value assertion bound via `:=` to a single LHS.
84+
4. The test fixtures **MUST** include at least one negative case for each of: the two-value `v, ok :=` form, the two-value `v, _ :=` form with blank `ok`, and a type-switch guard `v.(type)`.
85+
86+
### Conformance
87+
88+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
89+
90+
---
91+
92+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26413798298) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package uncheckedtypeassertion
2+
3+
import "fmt"
4+
5+
// Good: two-value type assertion is safe.
6+
func GoodTwoValue(v interface{}) {
7+
s, ok := v.(string)
8+
if ok {
9+
fmt.Println(s)
10+
}
11+
}
12+
13+
// Good: type switch is safe — not flagged.
14+
func GoodTypeSwitch(v interface{}) {
15+
switch t := v.(type) {
16+
case string:
17+
fmt.Println(t)
18+
}
19+
}
20+
21+
// Bad: single-value assertion may panic.
22+
func BadSingleValue(v interface{}) string {
23+
return v.(string) // want `type assertion x\.\(string\) is unchecked and may panic`
24+
}
25+
26+
// Bad: single-value assertion stored in variable.
27+
func BadSingleValueAssign(v interface{}) {
28+
s := v.(string) // want `type assertion x\.\(string\) is unchecked and may panic`
29+
fmt.Println(s)
30+
}
31+
32+
// Good: two-value form with blank ok is still two-value.
33+
func GoodTwoValueBlankOk(v interface{}) string {
34+
s, _ := v.(string)
35+
return s
36+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Package uncheckedtypeassertion implements a Go analysis linter that flags
2+
// single-value type assertions x.(T) that may panic at runtime if the dynamic
3+
// type does not match, and where the two-value safe form x.(T) is not used.
4+
package uncheckedtypeassertion
5+
6+
import (
7+
"fmt"
8+
"go/ast"
9+
10+
"golang.org/x/tools/go/analysis"
11+
"golang.org/x/tools/go/analysis/passes/inspect"
12+
"golang.org/x/tools/go/ast/inspector"
13+
14+
"github.com/github/gh-aw/pkg/linters/internal/filecheck"
15+
)
16+
17+
// Analyzer is the unchecked-type-assertion analysis pass.
18+
var Analyzer = &analysis.Analyzer{
19+
Name: "uncheckedtypeassertion",
20+
Doc: "reports single-value type assertions that may panic if the dynamic type does not match",
21+
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/uncheckedtypeassertion",
22+
Requires: []*analysis.Analyzer{inspect.Analyzer},
23+
Run: run,
24+
}
25+
26+
func run(pass *analysis.Pass) (any, error) {
27+
insp, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
28+
if !ok {
29+
return nil, fmt.Errorf("inspect result has unexpected type %T", pass.ResultOf[inspect.Analyzer])
30+
}
31+
32+
// Build a parent map for each file so we can detect the two-value form.
33+
fileParents := make(map[*ast.File]map[ast.Node]ast.Node)
34+
for _, f := range pass.Files {
35+
fileParents[f] = buildParentMap(f)
36+
}
37+
38+
nodeFilter := []ast.Node{
39+
(*ast.TypeAssertExpr)(nil),
40+
}
41+
42+
insp.Preorder(nodeFilter, func(n ast.Node) {
43+
typeAssert, ok := n.(*ast.TypeAssertExpr)
44+
if !ok {
45+
return
46+
}
47+
48+
// Type-switch guards have nil Type; skip them.
49+
if typeAssert.Type == nil {
50+
return
51+
}
52+
53+
pos := pass.Fset.PositionFor(typeAssert.Pos(), false)
54+
if filecheck.IsTestFile(pos.Filename) {
55+
return
56+
}
57+
58+
// Find the parent map for the file containing this node.
59+
var parents map[ast.Node]ast.Node
60+
for _, f := range pass.Files {
61+
if f.Pos() <= typeAssert.Pos() && typeAssert.Pos() <= f.End() {
62+
parents = fileParents[f]
63+
break
64+
}
65+
}
66+
67+
// Skip the safe two-value form: v, ok := x.(T) or v, ok = x.(T)
68+
if parents != nil {
69+
if assign, ok := parents[typeAssert].(*ast.AssignStmt); ok {
70+
if isSafeTwoValueAssertion(assign) {
71+
return
72+
}
73+
}
74+
}
75+
76+
t := pass.TypesInfo.TypeOf(typeAssert.Type)
77+
if t == nil {
78+
return
79+
}
80+
81+
pass.ReportRangef(
82+
typeAssert,
83+
"type assertion x.(%s) is unchecked and may panic; use the two-value form v, ok := x.(%s) instead",
84+
t, t,
85+
)
86+
})
87+
88+
return nil, nil
89+
}
90+
91+
func isSafeTwoValueAssertion(assign *ast.AssignStmt) bool {
92+
return len(assign.Lhs) == 2 && len(assign.Rhs) == 1
93+
}
94+
95+
// buildParentMap constructs a map from each AST node to its direct parent node.
96+
func buildParentMap(root ast.Node) map[ast.Node]ast.Node {
97+
parents := make(map[ast.Node]ast.Node)
98+
var stack []ast.Node
99+
100+
ast.Inspect(root, func(n ast.Node) bool {
101+
if n == nil {
102+
if len(stack) > 0 {
103+
stack = stack[:len(stack)-1]
104+
}
105+
return false
106+
}
107+
if len(stack) > 0 {
108+
parents[n] = stack[len(stack)-1]
109+
}
110+
stack = append(stack, n)
111+
return true
112+
})
113+
114+
return parents
115+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build !integration
2+
3+
package uncheckedtypeassertion_test
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
10+
"github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion"
11+
)
12+
13+
func TestAnalyzer(t *testing.T) {
14+
testdata := analysistest.TestData()
15+
analysistest.Run(t, testdata, uncheckedtypeassertion.Analyzer, "uncheckedtypeassertion")
16+
}

0 commit comments

Comments
 (0)