Skip to content

Commit a6609de

Browse files
generics for bound checks, typos, updated tests for qualified field names, and other reviews
1 parent 798e62a commit a6609de

File tree

4 files changed

+99
-82
lines changed

4 files changed

+99
-82
lines changed

docs/linters.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -485,12 +485,12 @@ The `numericbounds` linter checks that numeric fields (`int32`, `int64`, `float3
485485
According to Kubernetes API conventions, numeric fields should have bounds checking to prevent values that are too small, negative (when not intended), or too large.
486486

487487
This linter ensures that:
488-
- Numeric fields have both `+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum` markers
489-
- K8s declarative validation markers (`+k8s:minimum` and `+k8s:maximum`) are also supported
490-
- Bounds values are within the type's valid range:
491-
- int32: full int32 range (±2^31-1)
492-
- int64: JavaScript-safe range (±2^53-1) per Kubernetes API conventions
493-
- float32/float64: within their respective ranges
488+
- Numeric fields have both `+k8s:minimum` and `+k8s:maximum` markers
489+
- Kubebuilder validation markers (`+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum`) are also supported
490+
- Bounds values are validated:
491+
- int32: within int32 range (±2^31-1)
492+
- int64: within JavaScript-safe range (±2^53-1) per K8s API conventions for JSON compatibility
493+
- float32/float64: marker values are valid (within type ranges)
494494

495495
**Note:** While `+k8s:minimum` is documented in the official Kubernetes declarative validation spec, `+k8s:maximum` is not yet officially documented but is supported by this linter for forward compatibility and consistency.
496496

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/google/go-cmp v0.7.0
99
github.com/onsi/ginkgo/v2 v2.23.4
1010
github.com/onsi/gomega v1.38.0
11+
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
1112
golang.org/x/tools v0.37.0
1213
k8s.io/apimachinery v0.32.3
1314
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b

pkg/analysis/numericbounds/analyzer.go

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"go/ast"
2222

23+
"golang.org/x/exp/constraints"
2324
"golang.org/x/tools/go/analysis"
2425
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
2526
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
@@ -68,10 +69,10 @@ func run(pass *analysis.Pass) (any, error) {
6869
return nil, kalerrors.ErrCouldNotGetInspector
6970
}
7071

71-
inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers, _ string) {
72-
// Create TypeChecker with closure capturing markersAccess
73-
typeChecker := utils.NewTypeChecker(func(pass *analysis.Pass, ident *ast.Ident, node ast.Node, prefix string) {
74-
checkNumericType(pass, ident, node, prefix, markersAccess)
72+
inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers, qualifiedFieldName string) {
73+
// Create TypeChecker with closure capturing markersAccess and qualifiedFieldName
74+
typeChecker := utils.NewTypeChecker(func(pass *analysis.Pass, ident *ast.Ident, node ast.Node, _ string) {
75+
checkNumericType(pass, ident, node, markersAccess, qualifiedFieldName)
7576
})
7677

7778
typeChecker.CheckNode(pass, field)
@@ -81,7 +82,7 @@ func run(pass *analysis.Pass) (any, error) {
8182
}
8283

8384
//nolint:cyclop
84-
func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, prefix string, markersAccess markershelper.Markers) {
85+
func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, markersAccess markershelper.Markers, qualifiedFieldName string) {
8586
// Only check int32, int64, float32, and float64 types
8687
if ident.Name != "int32" && ident.Name != "int64" && ident.Name != "float32" && ident.Name != "float64" {
8788
return
@@ -94,7 +95,7 @@ func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, pref
9495

9596
fieldMarkers := utils.TypeAwareMarkerCollectionForField(pass, markersAccess, field)
9697

97-
// Determine if this is a slice/array field
98+
// Check if this is an array/slice field
9899
isSlice := utils.IsArrayTypeOrAlias(pass, field)
99100

100101
// Determine which markers to look for based on whether the field is a slice
@@ -110,20 +111,20 @@ func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, pref
110111

111112
// Report any invalid marker values (e.g., non-numeric values)
112113
if minErr != nil && !minMissing {
113-
pass.Reportf(field.Pos(), "%s has an invalid minimum marker: %v", prefix, minErr)
114+
pass.Reportf(field.Pos(), "%s has an invalid minimum marker: %v", qualifiedFieldName, minErr)
114115
}
115116

116117
if maxErr != nil && !maxMissing {
117-
pass.Reportf(field.Pos(), "%s has an invalid maximum marker: %v", prefix, maxErr)
118+
pass.Reportf(field.Pos(), "%s has an invalid maximum marker: %v", qualifiedFieldName, maxErr)
118119
}
119120

120121
// Report if markers are missing
121122
if minMissing {
122-
pass.Reportf(field.Pos(), "%s is missing minimum bounds validation marker", prefix)
123+
pass.Reportf(field.Pos(), "%s is missing minimum bound validation marker", qualifiedFieldName)
123124
}
124125

125126
if maxMissing {
126-
pass.Reportf(field.Pos(), "%s is missing maximum bounds validation marker", prefix)
127+
pass.Reportf(field.Pos(), "%s is missing maximum bound validation marker", qualifiedFieldName)
127128
}
128129

129130
// If any markers are missing or invalid, don't continue with bounds checks
@@ -132,7 +133,7 @@ func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, pref
132133
}
133134

134135
// Validate bounds are within the type's valid range
135-
checkBoundsWithinTypeRange(pass, field, prefix, ident.Name, minimum, maximum)
136+
checkBoundsWithinTypeRange(pass, field, qualifiedFieldName, ident.Name, minimum, maximum)
136137
}
137138

138139
// getMarkerNames returns the appropriate minimum and maximum marker names
@@ -148,14 +149,16 @@ func getMarkerNames(isSlice bool) (minMarkers, maxMarkers []string) {
148149

149150
// getMarkerNumericValue extracts the numeric value from the first instance of any of the given marker names.
150151
// Checks multiple marker names to support both kubebuilder and k8s declarative validation markers.
152+
// Precedence: Markers checked in the order provided and first valid value found is returned.
153+
// We require a valid numeric value (not just marker presence) for both minimum and maximum markers.
151154
func getMarkerNumericValue(markerSet markershelper.MarkerSet, markerNames []string) (float64, error) {
152155
for _, markerName := range markerNames {
153156
markerList := markerSet.Get(markerName)
154157
if len(markerList) == 0 {
155158
continue
156159
}
157160

158-
// Use the exported utils function to parse the marker value
161+
// Use the exported utils.GetMarkerNumericValue function to parse the marker value
159162
value, err := utils.GetMarkerNumericValue[float64](markerList[0])
160163
if err != nil {
161164
if errors.Is(err, errMarkerMissingValue) {
@@ -174,43 +177,42 @@ func getMarkerNumericValue(markerSet markershelper.MarkerSet, markerNames []stri
174177
// checkBoundsWithinTypeRange validates that the bounds are within the valid range for the type.
175178
// For int64, enforces JavaScript-safe bounds as per Kubernetes API conventions to ensure
176179
// compatibility with JavaScript clients.
177-
//
178-
//nolint:cyclop
179180
func checkBoundsWithinTypeRange(pass *analysis.Pass, field *ast.Field, prefix, typeName string, minimum, maximum float64) {
180181
switch typeName {
181182
case "int32":
182-
if minimum < float64(minInt32) || minimum > float64(maxInt32) {
183-
pass.Reportf(field.Pos(), "%s has minimum bound %v that is outside the valid int32 range [%d, %d]", prefix, minimum, minInt32, maxInt32)
184-
}
185-
186-
if maximum < float64(minInt32) || maximum > float64(maxInt32) {
187-
pass.Reportf(field.Pos(), "%s has maximum bound %v that is outside the valid int32 range [%d, %d]", prefix, maximum, minInt32, maxInt32)
188-
}
183+
checkBoundInRange(pass, field, prefix, minimum, minInt32, maxInt32, "minimum", "int32")
184+
checkBoundInRange(pass, field, prefix, maximum, minInt32, maxInt32, "maximum", "int32")
189185
case "int64":
190-
// Kubernetes API conventions enforce JavaScript-safe bounds for int64 (±2^53-1)
191-
// to ensure compatibility with JavaScript clients
192-
if minimum < float64(minSafeInt64) {
193-
pass.Reportf(field.Pos(), "%s has minimum bound %v that is outside the JavaScript-safe int64 range [%d, %d]. Consider using a string type to avoid precision loss in JavaScript clients", prefix, minimum, int64(minSafeInt64), int64(maxSafeInt64))
194-
}
195-
196-
if maximum > float64(maxSafeInt64) {
197-
pass.Reportf(field.Pos(), "%s has maximum bound %v that is outside the JavaScript-safe int64 range [%d, %d]. Consider using a string type to avoid precision loss in JavaScript clients", prefix, maximum, int64(minSafeInt64), int64(maxSafeInt64))
198-
}
186+
// K8s API conventions enforce JavaScript-safe bounds for int64 (±2^53-1)
187+
checkBoundInRange(pass, field, prefix, minimum, int64(minSafeInt64), int64(maxSafeInt64), "minimum", "JavaScript-safe int64",
188+
"Consider using a string type to avoid precision loss in JavaScript clients")
189+
checkBoundInRange(pass, field, prefix, maximum, int64(minSafeInt64), int64(maxSafeInt64), "maximum", "JavaScript-safe int64",
190+
"Consider using a string type to avoid precision loss in JavaScript clients")
199191
case "float32":
200-
if minimum < float64(minFloat32) || minimum > float64(maxFloat32) {
201-
pass.Reportf(field.Pos(), "%s has minimum bound %v that is outside the valid float32 range", prefix, minimum)
202-
}
203-
204-
if maximum < float64(minFloat32) || maximum > float64(maxFloat32) {
205-
pass.Reportf(field.Pos(), "%s has maximum bound %v that is outside the valid float32 range", prefix, maximum)
206-
}
192+
checkFloatBoundInRange(pass, field, prefix, minimum, minFloat32, maxFloat32, "minimum", "float32")
193+
checkFloatBoundInRange(pass, field, prefix, maximum, minFloat32, maxFloat32, "maximum", "float32")
207194
case "float64":
208-
if minimum < minFloat64 || minimum > maxFloat64 {
209-
pass.Reportf(field.Pos(), "%s has minimum bound %v that is outside the valid float64 range", prefix, minimum)
210-
}
195+
checkFloatBoundInRange(pass, field, prefix, minimum, minFloat64, maxFloat64, "minimum", "float64")
196+
checkFloatBoundInRange(pass, field, prefix, maximum, minFloat64, maxFloat64, "maximum", "float64")
197+
}
198+
}
211199

212-
if maximum < minFloat64 || maximum > maxFloat64 {
213-
pass.Reportf(field.Pos(), "%s has maximum bound %v that is outside the valid float64 range", prefix, maximum)
200+
// checkBoundInRange checks if an integer bound value is within the valid range.
201+
// Uses generics to avoid type conversions.
202+
func checkBoundInRange[T constraints.Integer](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string, extraMsg ...string) {
203+
if value < float64(minBound) || value > float64(maxBound) {
204+
msg := fmt.Sprintf("%s has %s bound %%v that is outside the %s range [%%d, %%d]", prefix, boundType, typeName)
205+
if len(extraMsg) > 0 {
206+
msg += ". " + extraMsg[0]
214207
}
208+
209+
pass.Reportf(field.Pos(), msg, value, minBound, maxBound)
210+
}
211+
}
212+
213+
// checkFloatBoundInRange checks if a float bound value is within the valid range.
214+
func checkFloatBoundInRange[T constraints.Float](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string) {
215+
if value < float64(minBound) || value > float64(maxBound) {
216+
pass.Reportf(field.Pos(), "%s has %s bound %v that is outside the valid %s range", prefix, boundType, value, typeName)
215217
}
216218
}

0 commit comments

Comments
 (0)