Skip to content

Conversation

@krishagarwal278
Copy link
Contributor

@krishagarwal278 krishagarwal278 commented Oct 31, 2025

Numericbounds Linter

Adds a new analyzer that ensures int32, int64, float32, float64 fields have both minimum and maximum bounds validation markers, following Kubernetes API conventions.

What it checks

  • Both Minimum and Maximum markers are present on numeric fields
  • Uses items:Minimum and items:Maximum for slice elements
  • Unwraps pointers and slices

Fixes #18

@k8s-ci-robot k8s-ci-robot added the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Oct 31, 2025
@k8s-ci-robot k8s-ci-robot added cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Oct 31, 2025
@krishagarwal278 krishagarwal278 changed the title [WIP]: NumericBounds Linter NumericBounds Linter Nov 3, 2025
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Nov 3, 2025
@krishagarwal278
Copy link
Contributor Author

/label tide/merge-method-squash

@k8s-ci-robot k8s-ci-robot added the tide/merge-method-squash Denotes a PR that should be squashed by tide when it merges. label Nov 3, 2025
docs/linters.md Outdated

## NumericBounds

The `numericbounds` linter checks that numeric fields (`int32` and `int64`) have appropriate bounds validation markers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about floats? We discourage them, but people can/do still use them

docs/linters.md Outdated
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.

This linter ensures that:
- `int32` and `int64` fields have both `+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum` markers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check for declarative validation markers too if they are in beta at this point

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i didn't add it earlier as i only found +k8s:maximum in the k8s documentation. On exploring further i saw that the markers package does mention it. So i incorporated it 👍🏻

docs/linters.md Outdated

This linter ensures that:
- `int32` and `int64` fields have both `+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum` markers
- `int64` fields with bounds outside the JavaScript safe integer range are flagged
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also check if an int32 bound is beyond the limits of an int32

docs/linters.md Outdated
Comment on lines 427 to 502
### Examples

**Valid:** Numeric field with proper bounds markers
```go
type Example struct {
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100
Count int32
}
```

**Valid:** Int64 field with JavaScript-safe bounds
```go
type Example struct {
// +kubebuilder:validation:Minimum=-9007199254740991
// +kubebuilder:validation:Maximum=9007199254740991
Timestamp int64
}
```

**Invalid:** Missing bounds markers
```go
type Example struct {
Count int32 // want: should have minimum and maximum bounds validation markers
}
```

**Invalid:** Only one bound specified
```go
type Example struct {
// +kubebuilder:validation:Minimum=0
Count int32 // want: has minimum but is missing maximum bounds validation marker
}
```

**Invalid:** Int64 with bounds exceeding JavaScript safe range
```go
type Example struct {
// +kubebuilder:validation:Minimum=-10000000000000000
// +kubebuilder:validation:Maximum=10000000000000000
LargeNumber int64 // want: bounds exceed JavaScript safe integer range
}
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably too much detail for the docs here, we can have this in the package level docs I think

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed 👍🏻

// +kubebuilder:validation:Maximum=100
Count int32
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe copy the more complex examples you added in the docs to here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved it to docs in numericbounds package 👍🏻

Comment on lines 37 to 38
maxSafeInt = 9007199254740991 // 2^53 - 1
minSafeInt = -9007199254740991 // -(2^53 - 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename these to Int64 and also add versions for Int32 to check?

Comment on lines 163 to 174
// Check if it's a slice and unwrap (e.g., []int32)
if arrayType, ok := expr.(*ast.ArrayType); ok {
isSlice = true
expr = arrayType.Elt

// Handle pointer inside slice (e.g., []*int32)
if starExpr, ok := expr.(*ast.StarExpr); ok {
expr = starExpr.X
}
}

return expr, isSlice
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we unwrap array element types, we should explain that it is the array element type we are reporting the issue on

name,
newAnalyzer(),
Analyzer,
true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, this is only for CRDs (since it's only looking at kubebuilder markers). So cannot be on by default

Comment on lines 186 to 206
// getMarkerNumericValue extracts the numeric value from the first instance of the marker with the given name.
func getMarkerNumericValue(markerSet markershelper.MarkerSet, markerName string) (float64, error) {
markerList := markerSet.Get(markerName)
if len(markerList) == 0 {
return 0, errMarkerMissingValue
}

marker := markerList[0]
rawValue, ok := marker.Expressions[""]
if !ok {
return 0, errMarkerMissingValue
}

// Parse as float64 using strconv for better error handling
value, err := strconv.ParseFloat(rawValue, 64)
if err != nil {
return 0, fmt.Errorf("error converting value to number: %w", err)
}

return value, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the utils library, I'm sure we have some existing helpers for this

@krishagarwal278 krishagarwal278 force-pushed the numericbounds branch 2 times, most recently from 16a7234 to 2dd6445 Compare November 4, 2025 20:23
Comment on lines 83 to 90
// Unwrap pointers and slices to get the underlying type
fieldType, isSlice := unwrapType(field.Type)

// Get the underlying numeric type identifier (int32, int64, float32, float64)
ident := getNumericTypeIdent(pass, fieldType)
if ident == nil {
return
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can reuse

// NewTypeChecker returns a new TypeChecker with the provided checkFunc.
func NewTypeChecker(checkFunc func(pass *analysis.Pass, ident *ast.Ident, node ast.Node, prefix string)) TypeChecker {
return &typeChecker{
checkFunc: checkFunc,
}
}
here to drill down to the built-in type of the field we are checking without having to write this additional logic to do the unwrapping?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we need to determine if the field is also a slice, we could also use:

// IsArrayTypeOrAlias checks if the field type is an array type or an alias to an array type.
func IsArrayTypeOrAlias(pass *analysis.Pass, field *ast.Field) bool {
if _, ok := field.Type.(*ast.ArrayType); ok {
return true
}
if ident, ok := field.Type.(*ast.Ident); ok {
typeOf := pass.TypesInfo.TypeOf(ident)
if typeOf == nil {
return false
}
return isArrayType(typeOf)
}
return false
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TypeChecker sounds like a good idea

// Extract the actual type name from typeDesc (e.g., "array element type of int32" -> "int32")
typeName := extractTypeName(typeDesc)

switch typeName {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing int64 and float64?

Comment on lines 240 to 245
if minimum < minInt32 || minimum > maxInt32 {
pass.Reportf(field.Pos(), "field %s %s has minimum bound %v that is outside the valid int32 range [%d, %d]", fieldName, typeDesc, minimum, minInt32, maxInt32)
}
if maximum < minInt32 || maximum > maxInt32 {
pass.Reportf(field.Pos(), "field %s %s has maximum bound %v that is outside the valid int32 range [%d, %d]", fieldName, typeDesc, maximum, minInt32, maxInt32)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like you might be comparing float64 values to int64 values here? Is that what we want?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding cases for int64 and float64 would prevent comparing values of float64 values to int64 right?

}

// checkJavaScriptSafeBounds checks if int64 bounds are within JavaScript safe integer range.
func checkJavaScriptSafeBounds(pass *analysis.Pass, field *ast.Field, fieldName, typeDesc string, minimum, maximum float64) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually care about "JavaScript safe bounds"? In my eyes, this is just the bounds Kubernetes API conventions are enforcing - not sure why we need 2 separate functions for doing the bound checks?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The javascript safe bounds is a limitation of JSON. If you go outside of the 2^53 then you have to change the value to a string. So we do care about limiting the bounds here, code layout is a different question

docs/linters.md Outdated
Comment on lines 455 to 470
### Configuration

This linter is **not enabled by default** as it is primarily focused on CRD validation with kubebuilder markers. It can be enabled in your configuration.

To enable in `.golangci.yml`:
```yaml
linters-settings:
custom:
kubeapilinter:
settings:
linters:
enable:
- numericbounds
```

See the [package documentation](https://pkg.go.dev/sigs.k8s.io/kube-api-linter/pkg/analysis/numericbounds) for detailed examples and usage.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have this for other linters so can probably skip unless there is any specific configuration for this rule

}

// checkJavaScriptSafeBounds checks if int64 bounds are within JavaScript safe integer range.
func checkJavaScriptSafeBounds(pass *analysis.Pass, field *ast.Field, fieldName, typeDesc string, minimum, maximum float64) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The javascript safe bounds is a limitation of JSON. If you go outside of the 2^53 then you have to change the value to a string. So we do care about limiting the bounds here, code layout is a different question

Comment on lines 47 to 48
maxSafeInt32 = 2147483647 // 2^31 - 1 (fits in JS Number)
minSafeInt32 = -2147483648 // -2^31 (fits in JS Number)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the same as maxInt32/minInt32, not sure why we would duplicate?

Comment on lines 41 to 42
maxFloat32 = 3.40282346638528859811704183484516925440e+38 // max float32
minFloat32 = -3.40282346638528859811704183484516925440e+38 // min float32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No flato64 bounds?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hadn't committed changes for float64 and int64. It's now incorporated in the latest commit

Comment on lines 83 to 90
// Unwrap pointers and slices to get the underlying type
fieldType, isSlice := unwrapType(field.Type)

// Get the underlying numeric type identifier (int32, int64, float32, float64)
ident := getNumericTypeIdent(pass, fieldType)
if ident == nil {
return
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TypeChecker sounds like a good idea

Comment on lines 137 to 141
// Validate bounds are within the type's range
checkBoundsWithinTypeRange(pass, field, fieldName, typeDesc, minimum, maximum)

// For int64 fields, check if bounds are within JavaScript safe integer range
checkJavaScriptSafeBounds(pass, field, fieldName, typeDesc, minimum, maximum)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably combine bounds within range type checks

@k8s-ci-robot k8s-ci-robot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Nov 6, 2025
@k8s-ci-robot k8s-ci-robot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Nov 6, 2025
const (
maxInt32 = 2147483647 // 2^31 - 1
minInt32 = -2147483648 // -2^31
maxInt64 = 9223372036854775807 // 2^63 - 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxInt64 and minInt64 are not used anywhere, so let's remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. Removed, since we already have maxSafeInt64 and minSafeInt64 👍🏻


// containsArrayElement checks if the prefix string contains "array element"
// which indicates we're checking a slice element type.
func containsArrayElement(prefix string) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use utilis.IsArrayType which is better than checking whether the prefix has specific strings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used IsArrayTypeOrAlias() as it's already exported 👍🏻

@krishagarwal278 krishagarwal278 force-pushed the numericbounds branch 2 times, most recently from 8260a81 to 9487576 Compare November 7, 2025 15:37
docs/linters.md Outdated
- Bounds values are within the type's valid range:
- int32: full int32 range (±2^31-1)
- int64: JavaScript-safe range (±2^53-1) per Kubernetes API conventions
- float32/float64: within their respective ranges
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any limitation in JSON for the size of a float64?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, we don't have limitation in json for float64. however, i'm still validating bounds to enforce k8s convention to have numeric fields bounded


// InvalidInt32NoBounds should have bounds markers
type InvalidInt32NoBounds struct {
NoBounds int32 // want "field NoBounds is missing minimum bounds validation marker" "field NoBounds is missing maximum bounds validation marker"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, probably minimum bound and maximum bound. i.e removing the s (I think this is appropriate english at least)

// compatibility with JavaScript clients.
//
//nolint:cyclop
func checkBoundsWithinTypeRange(pass *analysis.Pass, field *ast.Field, prefix, typeName string, minimum, maximum float64) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you were to use generics for these bounds checks, you might be able to avoid int to float to int conversions on the integer bounds checks

I suspect a bounds checking function that takes a boundary, a message, an upper and lower boundary could be leveraged here

@krishagarwal278 krishagarwal278 force-pushed the numericbounds branch 2 times, most recently from 2aa9995 to 36f5a50 Compare November 10, 2025 19:12
@k8s-ci-robot
Copy link
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: krishagarwal278
Once this PR has been reviewed and has the lgtm label, please assign joelspeed for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

docs/linters.md Outdated
Comment on lines 488 to 489
- Numeric fields have both `+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum` markers
- K8s declarative validation markers (`+k8s:minimum` and `+k8s:maximum`) are also supported
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would swap the way we talk about these as we expect the k8s versions to take precedence in the long term


**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.

This linter is **not enabled by default** as it is primarily focused on CRD validation with kubebuilder markers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now, I expect we will flip this in the future

return nil, kalerrors.ErrCouldNotGetInspector
}

inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers, _ string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final argument in this function is called qualifiedName. Please use this argument instead of working out your own field names here. This provides consistent naming across all linters

Or is there an issue making this work with the typechecker?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using qualifiedFieldName just like it's being used in arrayofstruct, commentstart, conflictingmarkers, and other linters 👍🏻

Comment on lines 102 to 106
// For array elements, update prefix to include type information
// e.g., "field Ports array element pointer" -> "field Ports array element type of int32"
if isSlice {
prefix = buildArrayElementPrefix(prefix, ident.Name)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought at least some of this was handled by the type checker 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed buildArrayElementPrefix entirely 👍🏻
now using qualifiedFieldName directly from inspector and utils.IsArrayTypeOrAlias() to detect arrays

Comment on lines 157 to 167
// buildArrayElementPrefix updates the prefix for array elements to include type information.
// Replaces TypeChecker's suffix (like "pointer" or "type X") with "type of {typeName}".
// Example: "field Ports array element pointer" -> "field Ports array element type of int32".
func buildArrayElementPrefix(prefix, typeName string) string {
idx := strings.Index(prefix, "array element")
if idx == -1 {
return prefix
}

return prefix[:idx+len("array element")] + " type of " + typeName
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed 👍🏻

Copy link
Contributor

@JoelSpeed JoelSpeed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One suggestion for a generics improvement, but then I think this is good to go, thanks @krishagarwal278

Comment on lines 200 to 218
// checkBoundInRange checks if an integer bound value is within the valid range.
// Uses generics to avoid type conversions.
func checkBoundInRange[T constraints.Integer](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string, extraMsg ...string) {
if value < float64(minBound) || value > float64(maxBound) {
msg := fmt.Sprintf("%s has %s bound %%v that is outside the %s range [%%d, %%d]", prefix, boundType, typeName)
if len(extraMsg) > 0 {
msg += ". " + extraMsg[0]
}

pass.Reportf(field.Pos(), msg, value, minBound, maxBound)
}
}

// checkFloatBoundInRange checks if a float bound value is within the valid range.
func checkFloatBoundInRange[T constraints.Float](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string) {
if value < float64(minBound) || value > float64(maxBound) {
pass.Reportf(field.Pos(), "%s has %s bound %v that is outside the valid %s range", prefix, boundType, value, typeName)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check how we did the GetMarkerNumericValue function implementation to work for both floats and integers, we should unify these using the same pattern (a shared number interface that accepts both floats and integers)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. Latest commit now has the same pattern 👍🏻 i.e. one function for both int and float bounds

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. tide/merge-method-squash Denotes a PR that should be squashed by tide when it merges.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Linter: numericbounds

5 participants