Skip to content

Commit 8e6d562

Browse files
Added enums linter
1 parent dda830a commit 8e6d562

File tree

9 files changed

+736
-0
lines changed

9 files changed

+736
-0
lines changed

docs/linters.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [ConflictingMarkers](#conflictingmarkers) - Detects mutually exclusive markers on the same field
77
- [DefaultOrRequired](#defaultorrequired) - Ensures fields marked as required do not have default values
88
- [DuplicateMarkers](#duplicatemarkers) - Checks for exact duplicates of markers
9+
- [Enums](#enums) - Enforces proper usage of enumerated fields with type aliases and +enum marker
910
- [ForbiddenMarkers](#forbiddenmarkers) - Checks that no forbidden markers are present on types/fields.
1011
- [Integers](#integers) - Validates usage of supported integer types
1112
- [JSONTags](#jsontags) - Ensures proper JSON tag formatting
@@ -195,6 +196,14 @@ will not.
195196
The `duplicatemarkers` linter can automatically fix all markers that are exact match to another markers.
196197
If there are duplicates across fields and their underlying type, the marker on the type will be preferred and the marker on the field will be removed.
197198

199+
## Enums
200+
201+
The `enums` linter enforces that enumerated fields use type aliases with the `+enum` marker (either `// +kubebuilder:validation:Enum` or `// +k8s:enum`) and that enum values follow PascalCase naming conventions.
202+
203+
This provides better API evolution, self-documentation, and validation compared to plain strings.
204+
205+
By default, `enums` is not enabled.
206+
198207
## ForbiddenMarkers
199208

200209
The `forbiddenmarkers` linter ensures that types and fields do not contain any markers that are forbidden.

pkg/analysis/enums/analyzer.go

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package enums
17+
18+
import (
19+
"fmt"
20+
"go/ast"
21+
"go/types"
22+
"strings"
23+
"unicode"
24+
25+
"golang.org/x/tools/go/analysis"
26+
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
27+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
28+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
29+
markershelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
30+
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
31+
"sigs.k8s.io/kube-api-linter/pkg/markers"
32+
)
33+
34+
const (
35+
name = "enums"
36+
)
37+
38+
type analyzer struct {
39+
config *Config
40+
}
41+
42+
// newAnalyzer creates a new analysis.Analyzer for the enums linter based on the provided config.
43+
func newAnalyzer(cfg *Config) *analysis.Analyzer {
44+
a := &analyzer{
45+
config: cfg,
46+
}
47+
48+
return &analysis.Analyzer{
49+
Name: name,
50+
Doc: "Enforces that enumerated fields use type aliases with +enum marker and have PascalCase values",
51+
Run: a.run,
52+
Requires: []*analysis.Analyzer{inspector.Analyzer},
53+
}
54+
}
55+
56+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
57+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
58+
if !ok {
59+
return nil, kalerrors.ErrCouldNotGetInspector
60+
}
61+
62+
// Check struct fields for proper enum usage
63+
inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers) {
64+
a.checkField(pass, field, markersAccess)
65+
})
66+
67+
// Check type declarations for +enum markers
68+
inspect.InspectTypeSpec(func(typeSpec *ast.TypeSpec, markersAccess markershelper.Markers) {
69+
a.checkTypeSpec(pass, typeSpec, markersAccess)
70+
})
71+
72+
// Check const values for PascalCase
73+
a.checkConstValues(pass, inspect)
74+
75+
return nil, nil //nolint:nilnil
76+
}
77+
78+
func (a *analyzer) checkField(pass *analysis.Pass, field *ast.Field, markersAccess markershelper.Markers) {
79+
fieldName := utils.FieldName(field)
80+
if fieldName == "" {
81+
return
82+
}
83+
// Get the underlying type, unwrapping pointers and arrays
84+
fieldType, isArray := unwrapTypeWithArrayTracking(field.Type)
85+
ident, ok := fieldType.(*ast.Ident)
86+
if !ok {
87+
return
88+
}
89+
90+
// Build appropriate prefix for error messages
91+
prefix := fmt.Sprintf("field %s", fieldName)
92+
if isArray {
93+
prefix = fmt.Sprintf("field %s array element", fieldName)
94+
}
95+
96+
// Check if it's a basic string type
97+
if ident.Name == "string" && utils.IsBasicType(pass, ident) {
98+
// Check if the field has an enum marker directly
99+
fieldMarkers := markersAccess.FieldMarkers(field)
100+
if !hasEnumMarker(fieldMarkers) {
101+
pass.Reportf(field.Pos(),
102+
"%s uses plain string without +enum marker. Enumerated fields should use a type alias with +enum marker",
103+
prefix)
104+
}
105+
return
106+
}
107+
// If it's a type alias, check that the alias has an enum marker
108+
if !utils.IsBasicType(pass, ident) {
109+
typeSpec, ok := utils.LookupTypeSpec(pass, ident)
110+
if !ok {
111+
return
112+
}
113+
// Check if the underlying type is string
114+
if !isStringTypeAlias(pass, typeSpec) {
115+
return
116+
}
117+
// Check if the type has an enum marker
118+
typeMarkers := markersAccess.TypeMarkers(typeSpec)
119+
if !hasEnumMarker(typeMarkers) {
120+
pass.Reportf(field.Pos(),
121+
"%s uses type %s which appears to be an enum but is missing +enum marker (kubebuilder:validation:Enum)",
122+
prefix, typeSpec.Name.Name)
123+
}
124+
}
125+
}
126+
127+
func (a *analyzer) checkTypeSpec(pass *analysis.Pass, typeSpec *ast.TypeSpec, markersAccess markershelper.Markers) {
128+
if typeSpec.Name == nil {
129+
return
130+
}
131+
typeMarkers := markersAccess.TypeMarkers(typeSpec)
132+
if !hasEnumMarker(typeMarkers) {
133+
return
134+
}
135+
136+
// Has +enum marker, verify it's a string type
137+
if !isStringTypeAlias(pass, typeSpec) {
138+
pass.Reportf(typeSpec.Pos(),
139+
"type %s has +enum marker but underlying type is not string",
140+
typeSpec.Name.Name)
141+
}
142+
}
143+
144+
func (a *analyzer) checkConstValues(pass *analysis.Pass, inspect inspector.Inspector) {
145+
// We need to check const declarations, but the inspector helper doesn't
146+
// have a method for this, so we'll iterate through files manually
147+
for _, file := range pass.Files {
148+
for _, decl := range file.Decls {
149+
genDecl, ok := decl.(*ast.GenDecl)
150+
if !ok || genDecl.Tok.String() != "const" {
151+
continue
152+
}
153+
for _, spec := range genDecl.Specs {
154+
valueSpec, ok := spec.(*ast.ValueSpec)
155+
if !ok {
156+
continue
157+
}
158+
a.checkConstSpec(pass, valueSpec)
159+
}
160+
}
161+
}
162+
}
163+
164+
func (a *analyzer) checkConstSpec(pass *analysis.Pass, valueSpec *ast.ValueSpec) {
165+
for i, name := range valueSpec.Names {
166+
if name == nil {
167+
continue
168+
}
169+
// Get the type of the constant
170+
obj := pass.TypesInfo.ObjectOf(name)
171+
if obj == nil {
172+
continue
173+
}
174+
constObj, ok := obj.(*types.Const)
175+
if !ok {
176+
continue
177+
}
178+
// Check if the type is a named type (potential enum)
179+
namedType, ok := constObj.Type().(*types.Named)
180+
if !ok {
181+
continue
182+
}
183+
// Check if the type is in the current package
184+
if namedType.Obj().Pkg() == nil || namedType.Obj().Pkg() != pass.Pkg {
185+
continue
186+
}
187+
// Find the type spec for this named type
188+
typeSpec := findTypeSpecByName(pass, namedType.Obj().Name())
189+
if typeSpec == nil {
190+
continue
191+
}
192+
// Check if this type has an enum marker
193+
if !hasEnumMarkerOnTypeSpec(pass, typeSpec) {
194+
continue
195+
}
196+
// This is an enum constant, validate the value
197+
if i >= len(valueSpec.Values) {
198+
continue
199+
}
200+
value := valueSpec.Values[i]
201+
basicLit, ok := value.(*ast.BasicLit)
202+
if !ok {
203+
continue
204+
}
205+
// Extract the string value (remove quotes)
206+
strValue := strings.Trim(basicLit.Value, `"`)
207+
// Check if it's in the allowlist
208+
if a.isInAllowlist(strValue) {
209+
continue
210+
}
211+
// Validate PascalCase
212+
if !isPascalCase(strValue) {
213+
pass.Reportf(basicLit.Pos(),
214+
"enum value %q should be PascalCase (e.g., \"PhasePending\", \"StateRunning\")",
215+
strValue)
216+
}
217+
}
218+
}
219+
220+
// unwrapType removes pointer and array wrappers to get the underlying type
221+
func unwrapType(expr ast.Expr) ast.Expr {
222+
switch t := expr.(type) {
223+
case *ast.StarExpr:
224+
return unwrapType(t.X)
225+
case *ast.ArrayType:
226+
return unwrapType(t.Elt)
227+
default:
228+
return expr
229+
}
230+
}
231+
232+
// unwrapTypeWithArrayTracking removes pointer and array wrappers to get the underlying type
233+
// and tracks whether an array was encountered during unwrapping.
234+
func unwrapTypeWithArrayTracking(expr ast.Expr) (ast.Expr, bool) {
235+
isArray := false
236+
for {
237+
switch t := expr.(type) {
238+
case *ast.StarExpr:
239+
expr = t.X
240+
case *ast.ArrayType:
241+
expr = t.Elt
242+
isArray = true
243+
default:
244+
return expr, isArray
245+
}
246+
}
247+
}
248+
249+
// isStringTypeAlias checks if a type spec is an alias for string
250+
func isStringTypeAlias(pass *analysis.Pass, typeSpec *ast.TypeSpec) bool {
251+
underlyingType := unwrapType(typeSpec.Type)
252+
ident, ok := underlyingType.(*ast.Ident)
253+
if !ok {
254+
return false
255+
}
256+
return ident.Name == "string" && utils.IsBasicType(pass, ident)
257+
}
258+
259+
// hasEnumMarker checks if a marker set contains an enum marker
260+
func hasEnumMarker(markerSet markershelper.MarkerSet) bool {
261+
return markerSet.Has(markers.KubebuilderEnumMarker) || markerSet.Has(markers.K8sEnumMarker)
262+
}
263+
264+
// hasEnumMarkerOnTypeSpec checks if a type spec has an enum marker by checking its doc comments
265+
func hasEnumMarkerOnTypeSpec(pass *analysis.Pass, typeSpec *ast.TypeSpec) bool {
266+
for _, file := range pass.Files {
267+
for _, decl := range file.Decls {
268+
genDecl, ok := decl.(*ast.GenDecl)
269+
if !ok {
270+
continue
271+
}
272+
for _, spec := range genDecl.Specs {
273+
if spec == typeSpec {
274+
if genDecl.Doc != nil {
275+
for _, comment := range genDecl.Doc.List {
276+
if strings.Contains(comment.Text, markers.KubebuilderEnumMarker) ||
277+
strings.Contains(comment.Text, markers.K8sEnumMarker) {
278+
return true
279+
}
280+
}
281+
}
282+
return false
283+
}
284+
}
285+
}
286+
}
287+
return false
288+
}
289+
290+
// isInAllowlist checks if a value is in the configured allowlist
291+
func (a *analyzer) isInAllowlist(value string) bool {
292+
if a.config == nil {
293+
return false
294+
}
295+
for _, allowed := range a.config.Allowlist {
296+
if value == allowed {
297+
return true
298+
}
299+
}
300+
return false
301+
}
302+
303+
// findTypeSpecByName searches through the AST files to find a TypeSpec by name
304+
func findTypeSpecByName(pass *analysis.Pass, typeName string) *ast.TypeSpec {
305+
for _, file := range pass.Files {
306+
for _, decl := range file.Decls {
307+
genDecl, ok := decl.(*ast.GenDecl)
308+
if !ok {
309+
continue
310+
}
311+
for _, spec := range genDecl.Specs {
312+
typeSpec, ok := spec.(*ast.TypeSpec)
313+
if !ok {
314+
continue
315+
}
316+
if typeSpec.Name != nil && typeSpec.Name.Name == typeName {
317+
return typeSpec
318+
}
319+
}
320+
}
321+
}
322+
return nil
323+
}
324+
325+
// isPascalCase validates that a string follows PascalCase convention
326+
// PascalCase: FirstLetterUpperCase, no underscores, no hyphens
327+
func isPascalCase(s string) bool {
328+
if len(s) == 0 {
329+
return false
330+
}
331+
// First character must be uppercase
332+
if !unicode.IsUpper(rune(s[0])) {
333+
return false
334+
}
335+
// Check for invalid characters (underscores, hyphens)
336+
for _, r := range s {
337+
if r == '_' || r == '-' {
338+
return false
339+
}
340+
// Only allow letters and digits
341+
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
342+
return false
343+
}
344+
}
345+
// Should not be all uppercase (that's SCREAMING_SNAKE_CASE or similar)
346+
allUpper := true
347+
for _, r := range s[1:] {
348+
if unicode.IsLower(r) {
349+
allUpper = false
350+
break
351+
}
352+
}
353+
if allUpper && len(s) > 1 {
354+
return false
355+
}
356+
return true
357+
}

0 commit comments

Comments
 (0)