Add linter to report on unsorted feature gates

Signed-off-by: Davanum Srinivas <davanum@gmail.com>
This commit is contained in:
Davanum Srinivas
2025-06-25 08:20:23 -04:00
parent b38dd716fe
commit f2d8b7ec2c
16 changed files with 1559 additions and 30 deletions

View File

@@ -131,6 +131,7 @@ linters:
- kubeapilinter
- logcheck
- revive
- sortedfeatures
- staticcheck
- testifylint
- unused
@@ -210,6 +211,21 @@ linters:
# this restriction. Whether we then do a global search/replace remains
# to be decided.
with-helpers .*
sortedfeatures:
# Installed there by hack/verify-golangci-lint.sh.
path: _output/local/bin/sortedfeatures.so
description: check if feature gates are sorted
original-url: k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures
settings:
files:
- cmd/kubeadm/app/features/features.go
- pkg/features/kube_features.go
- staging/src/k8s.io/apiserver/pkg/features/kube_features.go
- staging/src/k8s.io/client-go/features/known_features.go
- staging/src/k8s.io/controller-manager/pkg/features/kube_features.go
- staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go
- test/e2e/feature/feature.go
- test/e2e/environment/environment.go
kubeapilinter:
path: _output/local/bin/kube-api-linter.so
description: kube-api-linter and lints Kube like APIs based on API conventions and best practices.

View File

@@ -146,6 +146,7 @@ linters:
- kubeapilinter
- logcheck
- revive
- sortedfeatures
- staticcheck
- testifylint
- unused
@@ -224,6 +225,21 @@ linters:
# this restriction. Whether we then do a global search/replace remains
# to be decided.
with-helpers .*
sortedfeatures:
# Installed there by hack/verify-golangci-lint.sh.
path: _output/local/bin/sortedfeatures.so
description: check if feature gates are sorted
original-url: k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures
settings:
files:
- cmd/kubeadm/app/features/features.go
- pkg/features/kube_features.go
- staging/src/k8s.io/apiserver/pkg/features/kube_features.go
- staging/src/k8s.io/client-go/features/known_features.go
- staging/src/k8s.io/controller-manager/pkg/features/kube_features.go
- staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go
- test/e2e/feature/feature.go
- test/e2e/environment/environment.go
kubeapilinter:
path: _output/local/bin/kube-api-linter.so
description: kube-api-linter and lints Kube like APIs based on API conventions and best practices.

View File

@@ -165,6 +165,7 @@ linters:
- kubeapilinter
- logcheck
- revive
- sortedfeatures
- staticcheck
- testifylint
- unused
@@ -182,6 +183,13 @@ linters:
settings:
config: |
{{include "hack/logcheck.conf" | indent 12 | trim}}
sortedfeatures:
# Installed there by hack/verify-golangci-lint.sh.
path: _output/local/bin/sortedfeatures.so
description: check if feature gates are sorted
original-url: k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures
settings:
{{include "hack/tools/golangci-lint/sortedfeatures/config.yaml" | indent 10 | trim}}
kubeapilinter:
path: _output/local/bin/kube-api-linter.so
description: kube-api-linter and lints Kube like APIs based on API conventions and best practices.

View File

@@ -8,6 +8,11 @@ tool (
sigs.k8s.io/logtools/logcheck
)
require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
golang.org/x/tools v0.34.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
@@ -21,7 +26,7 @@ require (
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.16.0 // indirect
github.com/alecthomas/chroma/v2 v2.17.2 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
@@ -78,7 +83,7 @@ require (
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.1.5 // indirect
github.com/golangci/golangci-lint/v2 v2.1.6 // indirect
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
github.com/golangci/misspell v0.6.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect
@@ -135,7 +140,6 @@ require (
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
@@ -172,7 +176,7 @@ require (
github.com/stretchr/testify v1.10.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tetafro/godot v1.5.0 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
@@ -187,7 +191,7 @@ require (
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect
go-simpler.org/musttag v0.13.1 // indirect
go-simpler.org/sloglint v0.11.0 // indirect
go.augendre.info/fatcontext v0.8.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
@@ -196,11 +200,10 @@ require (
go.uber.org/zap v1.24.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@@ -59,8 +59,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA=
github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI=
github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
@@ -252,8 +252,8 @@ github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUP
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.1.5 h1:zDcxV8s7kgQW3cpQiVA633CZJnKN/0iEXibPDWO8sZo=
github.com/golangci/golangci-lint/v2 v2.1.5/go.mod h1:RGcjZLyl9fSVLqxdKMrknPlspC3TYETLoKXyRG06RDo=
github.com/golangci/golangci-lint/v2 v2.1.6 h1:LXqShFfAGM5BDzEOWD2SL1IzJAgUOqES/HRBsfKjI+w=
github.com/golangci/golangci-lint/v2 v2.1.6/go.mod h1:EPj+fgv4TeeBq3TcqaKZb3vkiV5dP4hHHKhXhEhzci8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs=
@@ -569,8 +569,8 @@ github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw=
github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio=
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
@@ -608,8 +608,8 @@ gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE=
go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM=
go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU=
go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k=
go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco=
go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw=
go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk=
@@ -683,8 +683,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -725,8 +725,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -748,8 +748,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -802,8 +802,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -885,8 +885,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,137 @@
# SortedFeatures Linter
This linter checks if feature gates in Kubernetes code are sorted alphabetically in const and var blocks.
## Purpose
In Kubernetes, feature gates should be listed in alphabetical, case-sensitive (upper before any lower case character)
order to reduce the risk of code conflicts and improve readability. This linter enforces this convention by checking
if feature gates are properly sorted.
## How It Works
The linter analyzes const and var blocks in specified files, extracts feature declarations, and checks if they are
sorted alphabetically by name. If they are not sorted, it reports an error with a detailed diff showing the current
order versus the expected order.
NOTE: the linter only works for the following scenario where a `const` or a `var` block contains feature gates:
```go
const (
FeatureA featuregate.Feature = "FeatureA"
FeatureB featuregate.Feature = "FeatureB"
)
```
it will not work for cases where feature gates are defined in a different way, such as:
```go
const FeatureA featuregate.Feature = "FeatureA"
const FeatureB featuregate.Feature = "FeatureB"
```
## Installation
### As a golangci-lint plugin
1. Build the plugin:
```bash
cd hack/tools/golangci-lint/sortedfeatures
go build -buildmode=plugin -o sortedfeatures.so ./plugin/example.go
```
2. Add the plugin to your `.golangci.yml` configuration:
```yaml
linters:
settings:
custom:
sortedfeatures:
path: /path/to/sortedfeatures.so
description: Checks if feature gates are sorted alphabetically
original-url: k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures
settings:
debug: false
files:
- path/to/additional/file.go
```
## Configuration Options
The linter supports the following configuration options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| debug | bool | false | Enable debug logging |
| files | []string | [] | Files to check for feature gate sorting. If specified, only these files will be checked (default files will be ignored) |
You can specify files to check in your configuration:
```yaml
# Using files
settings:
files:
- path/to/file.go
- another/path/file.go
```
Note: If `files` is specified, only those files will be checked and the default files will be ignored. If no files are
specified, the default set of Kubernetes feature gate files will be checked.
## Usage
The linter will check all const and var blocks in your code to ensure that feature gates are sorted alphabetically.
If they are not sorted, it will report an error with a detailed diff showing the current order versus the expected
order.
### Enabling the linter
Custom linters are enabled by default, but abide by the same rules as other linters.
If the disable all option is specified either on command line or in `.golangci.yml` files `linters.disable-all: true`,
custom linters will be disabled; they can be re-enabled by adding them to the `linters.enable` list,
or providing the enabled option on the command line, `golangci-lint run -Esortedfeatures`.
## Example
```go
const (
// These are properly sorted
FeatureA featuregate.Feature = "FeatureA"
FeatureB featuregate.Feature = "FeatureB"
FeatureC featuregate.Feature = "FeatureC"
)
const (
// These are NOT properly sorted and will trigger a linter error
FeatureB featuregate.Feature = "FeatureB"
FeatureA featuregate.Feature = "FeatureA"
FeatureC featuregate.Feature = "FeatureC"
)
```
## Files Checked
By default, this linter checks the following files in the Kubernetes codebase:
- `pkg/features/kube_features.go`
- `staging/src/k8s.io/apiserver/pkg/features/kube_features.go`
- `staging/src/k8s.io/client-go/features/known_features.go`
- `staging/src/k8s.io/controller-manager/pkg/features/kube_features.go`
- `staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go`
- `test/e2e/feature/feature.go`
- `test/e2e/environment/environment.go`
## Integration with CI
This linter is part of the Kubernetes CI pipeline and helps ensure that all feature gates are properly sorted
across the codebase. It's recommended to run this linter locally before submitting pull requests that modify
feature gates.
## Troubleshooting
If you encounter issues with the linter:
1. Enable debug mode in your configuration
2. Check that the plugin is correctly built and referenced in your golangci-lint configuration
3. Verify that the files you want to check are either in the default list or explicitly specified in your configuration
For more information on custom linters in golangci-lint, refer to the [official documentation](https://golangci-lint.run/contributing/new-linters/).

View File

@@ -0,0 +1,9 @@
files:
- cmd/kubeadm/app/features/features.go
- pkg/features/kube_features.go
- staging/src/k8s.io/apiserver/pkg/features/kube_features.go
- staging/src/k8s.io/client-go/features/known_features.go
- staging/src/k8s.io/controller-manager/pkg/features/kube_features.go
- staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go
- test/e2e/feature/feature.go
- test/e2e/environment/environment.go

View File

@@ -0,0 +1,25 @@
linters:
settings:
custom:
sortedfeatures:
path: /path/to/sortedfeatures.so
description: Checks if feature gates are sorted alphabetically
original-url: k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures
settings:
debug: false
# Specify files to check (will ignore default files)
files:
- path/to/feature_gates.go
- another/path/to/feature_gates.go
run:
# These paths control which files golangci-lint will analyze
# This is separate from the sortedfeatures plugin's files setting
paths:
- staging/src/k8s.io/apiserver/pkg/features/kube_features.go
- pkg/features/kube_features.go
- staging/src/k8s.io/client-go/features/known_features.go
- staging/src/k8s.io/controller-manager/pkg/features/kube_features.go
- staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go
- test/e2e/feature/feature.go
- test/e2e/environment/environment.go

View File

@@ -0,0 +1,27 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"golang.org/x/tools/go/analysis/singlechecker"
"k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures/pkg"
)
func main() {
singlechecker.Main(pkg.NewAnalyzer())
}

View File

@@ -0,0 +1,395 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package sortedfeatures implements a linter that checks if feature gates are sorted alphabetically.
package pkg
import (
"bufio"
"fmt"
"go/ast"
"go/token"
"sort"
"strings"
"github.com/pmezard/go-difflib/difflib"
"golang.org/x/tools/go/analysis"
)
// Config holds the configuration for the sortedfeatures analyzer
type Config struct {
// Files contains files to check. If specified, only these files will be checked.
Files []string
// Debug enables debug logging
Debug bool
}
// NewAnalyzer returns a new sortedfeatures analyzer.
func NewAnalyzer() *analysis.Analyzer {
return NewAnalyzerWithConfig(Config{Debug: true})
}
// NewAnalyzerWithConfig returns a new sortedfeatures analyzer with the given configuration.
func NewAnalyzerWithConfig(config Config) *analysis.Analyzer {
return &analysis.Analyzer{
Name: "sortedfeatures",
Doc: "Checks if feature gates are sorted alphabetically in const and var blocks",
Run: func(pass *analysis.Pass) (interface{}, error) {
if config.Debug {
fmt.Printf("Processing...\n")
}
return run(pass, config)
},
}
}
func isTargetFile(configFiles []string, filename string) bool {
for _, item := range configFiles {
if strings.HasSuffix(filename, item) {
return true
}
}
return false
}
func run(pass *analysis.Pass, config Config) (interface{}, error) {
for _, file := range pass.Files {
filename := pass.Fset.File(file.Pos()).Name()
if !isTargetFile(config.Files, filename) {
continue
}
// Check all declarations in the file
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
// Process var and const blocks for regular feature gates
if (genDecl.Tok == token.VAR || genDecl.Tok == token.CONST) && len(genDecl.Specs) > 1 {
// Extract features with their comments
features := extractFeatures(genDecl, file.Comments)
// Skip if no features were found
if len(features) > 1 {
// Sort features
sortedFeatures := sortFeatures(features)
// Check if the order has changed
orderChanged := hasOrderChanged(features, sortedFeatures)
if orderChanged {
// Generate a diff to show what's wrong
reportSortingIssue(pass, genDecl, features, sortedFeatures)
}
}
}
// Check for maps with feature gates as keys
if genDecl.Tok == token.VAR {
checkFeatureGateMaps(pass, genDecl)
}
}
}
return nil, nil
}
// checkFeatureGateMaps checks if maps with feature gates as keys have their keys sorted alphabetically
func checkFeatureGateMaps(pass *analysis.Pass, genDecl *ast.GenDecl) {
for _, spec := range genDecl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok || len(valueSpec.Names) == 0 || len(valueSpec.Values) == 0 {
continue
}
// Check each value to see if it's a map with feature gates
for _, value := range valueSpec.Values {
compositeLit, ok := value.(*ast.CompositeLit)
if !ok {
continue
}
// Check if this is a map type
mapType, ok := compositeLit.Type.(*ast.MapType)
if !ok {
continue
}
// Check if the key type is featuregate.Feature or contains "Feature"
isFeatureGateMap := false
// Check for SelectorExpr (e.g., featuregate.Feature)
if selectorExpr, ok := mapType.Key.(*ast.SelectorExpr); ok {
if selectorExpr.Sel.Name == "Feature" {
isFeatureGateMap = true
}
}
// Check for Ident (e.g., Feature)
if ident, ok := mapType.Key.(*ast.Ident); ok {
if ident.Name == "Feature" {
isFeatureGateMap = true
}
}
if !isFeatureGateMap {
continue
}
// This is a map with feature gates as keys
var features []Feature
for _, elt := range compositeLit.Elts {
keyValueExpr, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
// Get the key, which should be a feature gate identifier
var featureName string
// Handle different types of keys
switch key := keyValueExpr.Key.(type) {
case *ast.Ident:
featureName = key.Name
case *ast.SelectorExpr:
// For selector expressions like genericfeatures.APIServerIdentity
if x, ok := key.X.(*ast.Ident); ok {
featureName = x.Name + "." + key.Sel.Name
} else {
continue
}
default:
continue
}
features = append(features, Feature{
Name: featureName,
Comments: []string{}, // No comments for map keys
})
}
if len(features) <= 1 {
continue
}
// Sort features
sortedFeatures := sortFeatures(features)
// Check if the order has changed
orderChanged := hasOrderChanged(features, sortedFeatures)
if orderChanged {
// Generate a diff to show what's wrong
reportMapSortingIssue(pass, genDecl, valueSpec.Names[0].Name, features, sortedFeatures)
}
}
}
}
// Feature represents a feature declaration with its associated comments
type Feature struct {
Name string // Name of the feature
Comments []string // Comments associated with the feature
}
// extractFeatures extracts features from a GenDecl
func extractFeatures(decl *ast.GenDecl, comments []*ast.CommentGroup) []Feature {
var features []Feature
for _, spec := range decl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok || len(valueSpec.Names) == 0 {
continue
}
// Get the name of the feature
name := valueSpec.Names[0].Name
// Get comments for this feature
var featureComments []string
// Check for doc comments directly on the value spec
if valueSpec.Doc != nil {
for _, comment := range valueSpec.Doc.List {
featureComments = append(featureComments, comment.Text)
}
} else {
// Look for comments before this spec
for _, cg := range comments {
if cg.End()+1 == valueSpec.Pos() {
for _, comment := range cg.List {
featureComments = append(featureComments, comment.Text)
}
}
}
}
features = append(features, Feature{
Name: name,
Comments: featureComments,
})
}
return features
}
// sortFeatures sorts features alphabetically by name
func sortFeatures(features []Feature) []Feature {
sorted := make([]Feature, len(features))
copy(sorted, features)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Name < sorted[j].Name
})
return sorted
}
// hasOrderChanged checks if the order of features has changed
func hasOrderChanged(original, sorted []Feature) bool {
if len(original) != len(sorted) {
return true
}
for i := range original {
if original[i].Name != sorted[i].Name {
return true
}
}
return false
}
// reportSortingIssue reports a linting issue with a diff showing the correct order
func reportSortingIssue(pass *analysis.Pass, decl *ast.GenDecl, current, sorted []Feature) {
// Generate the original source code
originalSource := generateSourceCode(decl.Tok, current)
// Generate the sorted source code
sortedSource := generateSourceCode(decl.Tok, sorted)
// Create a unified diff between the original and sorted source
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(originalSource),
B: difflib.SplitLines(sortedSource),
FromFile: "Current",
ToFile: "Expected",
Context: 3,
}
diffText, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
pass.Reportf(decl.Pos(), "not sorted alphabetically (error creating diff: %v)", err)
return
}
// Report the issue with the diff
pass.Reportf(decl.Pos(), "not sorted alphabetically:\n%s\n", stripHeader(diffText, 3))
}
func stripHeader(input string, n int) string {
scanner := bufio.NewScanner(strings.NewReader(input))
var result strings.Builder
lineCount := 0
for scanner.Scan() {
lineCount++
if lineCount > n {
result.WriteString(scanner.Text() + "\n")
}
}
return strings.TrimSuffix(result.String(), "\n")
}
// reportMapSortingIssue reports a linting issue for unsorted map keys
func reportMapSortingIssue(pass *analysis.Pass, decl *ast.GenDecl, mapName string, current, sorted []Feature) {
// Generate the original source code
originalSource := generateMapSourceCode(current)
// Generate the sorted source code
sortedSource := generateMapSourceCode(sorted)
// Create a unified diff between the original and sorted source
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(originalSource),
B: difflib.SplitLines(sortedSource),
FromFile: "Current Map Keys",
ToFile: "Expected Map Keys",
Context: 3,
}
diffText, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
pass.Reportf(decl.Pos(), "map '%s' keys not sorted alphabetically (error creating diff: %v)", mapName, err)
return
}
// Report the issue with the diff
pass.Reportf(decl.Pos(), "map '%s' keys not sorted alphabetically:\n%s\n", mapName, stripHeader(diffText, 3))
}
// generateMapSourceCode recreates the source code for map keys
func generateMapSourceCode(features []Feature) string {
var sb strings.Builder
sb.WriteString("map[featuregate.Feature]featuregate.VersionedSpecs{\n")
// Add each feature key
for _, feature := range features {
sb.WriteString("\t")
sb.WriteString(feature.Name)
sb.WriteString(": ...,\n")
}
sb.WriteString("}")
return sb.String()
}
// generateSourceCode recreates the source code from features
func generateSourceCode(tokenType token.Token, features []Feature) string {
var sb strings.Builder
// Start the block with the token type (var or const)
sb.WriteString(tokenType.String())
sb.WriteString(" (\n")
// Add each feature with its comments
for _, feature := range features {
// Add comments
for _, comment := range feature.Comments {
sb.WriteString("\t")
sb.WriteString(comment)
sb.WriteString("\n")
}
// Add the feature declaration
sb.WriteString("\t")
sb.WriteString(feature.Name)
sb.WriteString(" = ")
// Since we don't have the actual value, we'll use a placeholder
sb.WriteString("value")
sb.WriteString("\n\n")
}
// Close the block
sb.WriteString(")")
return sb.String()
}

View File

@@ -0,0 +1,762 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package pkg
import (
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
"github.com/pmezard/go-difflib/difflib"
"golang.org/x/tools/go/analysis"
)
func TestNewAnalyzer(t *testing.T) {
analyzer := NewAnalyzer()
if analyzer.Name != "sortedfeatures" {
t.Errorf("Expected analyzer name to be 'sortedfeatures', got '%s'", analyzer.Name)
}
}
func TestNewAnalyzerWithConfig(t *testing.T) {
config := Config{
Debug: true,
Files: []string{"test.go"},
}
analyzer := NewAnalyzerWithConfig(config)
if analyzer.Name != "sortedfeatures" {
t.Errorf("Expected analyzer name to be 'sortedfeatures', got '%s'", analyzer.Name)
}
}
func TestRunWithEmptyFileList(t *testing.T) {
// Test that the analyzer handles empty file list gracefully
pass := &analysis.Pass{
Fset: token.NewFileSet(),
Files: []*ast.File{},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) { t.Errorf("Report should not be called") },
}
// Run the analyzer's run function directly
result, err := run(pass, Config{})
if err != nil {
t.Errorf("run returned an error: %v", err)
}
if result != nil {
t.Errorf("Expected nil result, got: %v", result)
}
}
func TestRunWithDefaultFiles(t *testing.T) {
// Test that the analyzer works with default files
// Create a mock analysis.Pass
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "pkg/features/kube_features.go", `
package features
type Feature string
const (
FeatureA Feature = "FeatureA"
FeatureB Feature = "FeatureB"
)
`, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) {},
}
// Run the analyzer's run function directly
_, err = run(pass, Config{})
if err != nil {
t.Errorf("run returned an error: %v", err)
}
}
func TestRunWithNonTargetFile(t *testing.T) {
// Test that the analyzer ignores non-target files
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "some/random/file.go", `
package features
type Feature string
const (
FeatureB Feature = "FeatureB"
FeatureA Feature = "FeatureA" // Unsorted, but should be ignored
)
`, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var reportCalled bool
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) { reportCalled = true },
}
// Run the analyzer's run function directly
_, err = run(pass, Config{})
if err != nil {
t.Errorf("run returned an error: %v", err)
}
// Check that Report was NOT called for non-target files
if reportCalled {
t.Errorf("Report should not be called for non-target files")
}
}
func TestRunWithSpecifiedFiles(t *testing.T) {
// Create a temporary file for testing
tmpFile, err := os.CreateTemp("", "custom_file_*.go")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer os.Remove(tmpFile.Name())
// Write test content to the file
testContent := `
package features
type Feature string
const (
FeatureA Feature = "FeatureA"
FeatureB Feature = "FeatureB"
)
`
if _, err := tmpFile.Write([]byte(testContent)); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Failed to close temporary file: %v", err)
}
// Test that the analyzer works with specified files
config := Config{
Files: []string{tmpFile.Name()},
}
// Create a mock analysis.Pass
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, tmpFile.Name(), testContent, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) {},
}
// Run the analyzer's run function directly
_, err = run(pass, config)
if err != nil {
t.Errorf("run returned an error: %v", err)
}
}
func TestExtractFeatures(t *testing.T) {
src := `
package test
type Feature string
const (
// Comment for FeatureA
FeatureA Feature = "FeatureA"
// Comment for FeatureB
FeatureB Feature = "FeatureB"
)
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find const declaration")
}
features := extractFeatures(foundDecl, f.Comments)
if len(features) != 2 {
t.Errorf("Expected 2 features, got %d", len(features))
}
if features[0].Name != "FeatureA" {
t.Errorf("Expected first feature to be FeatureA, got %s", features[0].Name)
}
if features[1].Name != "FeatureB" {
t.Errorf("Expected second feature to be FeatureB, got %s", features[1].Name)
}
if len(features[0].Comments) == 0 {
t.Errorf("Expected comments for FeatureA, got none")
}
}
func TestSortFeatures(t *testing.T) {
features := []Feature{
{Name: "FeatureB"},
{Name: "FeatureA"},
{Name: "FeatureC"},
}
sorted := sortFeatures(features)
if sorted[0].Name != "FeatureA" {
t.Errorf("Expected first feature to be FeatureA, got %s", sorted[0].Name)
}
if sorted[1].Name != "FeatureB" {
t.Errorf("Expected second feature to be FeatureB, got %s", sorted[1].Name)
}
if sorted[2].Name != "FeatureC" {
t.Errorf("Expected third feature to be FeatureC, got %s", sorted[2].Name)
}
}
func TestHasOrderChanged(t *testing.T) {
original := []Feature{
{Name: "FeatureB"},
{Name: "FeatureA"},
{Name: "FeatureC"},
}
sorted := []Feature{
{Name: "FeatureA"},
{Name: "FeatureB"},
{Name: "FeatureC"},
}
if !hasOrderChanged(original, sorted) {
t.Errorf("Expected hasOrderChanged to return true for different orders")
}
if hasOrderChanged(sorted, sorted) {
t.Errorf("Expected hasOrderChanged to return false for same order")
}
}
func TestReportSortingIssue(t *testing.T) {
current := []Feature{
{Name: "FeatureB", Comments: []string{"// Comment for FeatureB"}},
{Name: "FeatureA", Comments: []string{"// Comment for FeatureA"}},
{Name: "FeatureC", Comments: []string{"// Comment for FeatureC"}},
}
sorted := []Feature{
{Name: "FeatureA", Comments: []string{"// Comment for FeatureA"}},
{Name: "FeatureB", Comments: []string{"// Comment for FeatureB"}},
{Name: "FeatureC", Comments: []string{"// Comment for FeatureC"}},
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", `
package test
const (
FeatureB = "FeatureB"
FeatureA = "FeatureA"
FeatureC = "FeatureC"
)
`, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find const declaration")
}
var reportMessage string
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) { reportMessage = d.Message },
}
reportSortingIssue(pass, foundDecl, current, sorted)
if reportMessage == "" {
t.Errorf("Expected Report to be called")
}
// Check that the diff shows the features in the correct order
if !strings.Contains(reportMessage, "FeatureB") || !strings.Contains(reportMessage, "FeatureA") || !strings.Contains(reportMessage, "FeatureC") {
t.Errorf("Expected diff to contain all feature names, got: %s", reportMessage)
}
// Check that the diff contains the expected content
expectedContent := "const ("
if !strings.Contains(reportMessage, expectedContent) {
t.Errorf("Expected diff to contain %q, got: %s", expectedContent, reportMessage)
}
}
func TestSortedFeatures(t *testing.T) {
// Test with properly sorted features
src := `
package test
type Feature string
const (
// These are properly sorted
FeatureA Feature = "FeatureA"
FeatureB Feature = "FeatureB"
FeatureC Feature = "FeatureC"
)
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find const declaration")
}
// Extract features
features := extractFeatures(foundDecl, f.Comments)
// Sort features
sortedFeatures := sortFeatures(features)
// Check if order changed
if hasOrderChanged(features, sortedFeatures) {
t.Errorf("Expected features to be already sorted")
}
}
func TestUnsortedFeatures(t *testing.T) {
// Test with unsorted features
src := `
package test
type Feature string
const (
// These are NOT properly sorted
FeatureB Feature = "FeatureB"
FeatureA Feature = "FeatureA"
FeatureC Feature = "FeatureC"
)
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find const declaration")
}
// Extract features
features := extractFeatures(foundDecl, f.Comments)
// Sort features
sortedFeatures := sortFeatures(features)
// Check if order changed
if !hasOrderChanged(features, sortedFeatures) {
t.Errorf("Expected features to be unsorted")
}
}
func TestVarBlockFeatures(t *testing.T) {
// Test with var block features (like in test/e2e/feature/feature.go)
src := `
package test
import "k8s.io/kubernetes/test/e2e/framework"
var (
// These are properly sorted
FeatureA = framework.WithFeature(framework.ValidFeatures.Add("FeatureA"))
FeatureB = framework.WithFeature(framework.ValidFeatures.Add("FeatureB"))
FeatureC = framework.WithFeature(framework.ValidFeatures.Add("FeatureC"))
)
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.VAR {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find var declaration")
}
// Extract features
features := extractFeatures(foundDecl, f.Comments)
// Sort features
sortedFeatures := sortFeatures(features)
// Check if order changed
if hasOrderChanged(features, sortedFeatures) {
t.Errorf("Expected features to be already sorted")
}
}
func TestSingleItemBlock(t *testing.T) {
// Test with a block containing only one item
src := `
package test
type Feature string
const (
// Single item, should be skipped
FeatureA Feature = "FeatureA"
)
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
var foundDecl *ast.GenDecl
ast.Inspect(f, func(n ast.Node) bool {
if decl, ok := n.(*ast.GenDecl); ok && decl.Tok == token.CONST {
foundDecl = decl
return false
}
return true
})
if foundDecl == nil {
t.Fatalf("Failed to find const declaration")
}
// Extract features
features := extractFeatures(foundDecl, f.Comments)
if len(features) != 1 {
t.Errorf("Expected 1 feature, got %d", len(features))
}
}
func TestNonParenthesizedDeclarationsNotProcessed(t *testing.T) {
// Test with non-parenthesized declarations
src := `
package test
type Feature string
// First feature
const FeatureA Feature = "FeatureA"
// Second feature
const FeatureB Feature = "FeatureB"
// Third feature
const FeatureC Feature = "FeatureC"
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "test.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
// Create a mock analysis.Pass
var reportCalled bool
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) { reportCalled = true },
}
// Configure the analyzer to treat test.go as a target file
config := Config{
Files: []string{"test.go"},
}
// Run the analyzer
_, err = run(pass, config)
if err != nil {
t.Errorf("run returned an error: %v", err)
}
// Check that Report was NOT called, since non-parenthesized declarations are skipped
if reportCalled {
t.Errorf("Expected Report not to be called for non-parenthesized declarations")
}
}
// TestAnalyzerRunSimulatingGolangciLint is a test that simulates how golangci-lint would run the analyzer
// by creating a mock analysis.Pass and calling the Run method directly. If you run this test from the root
// of the repository, it will check all default target files defined in the analyzer's config without needing
// to run golangci-lint itself. If run from anywhere else, it will skip the test as the files won't be found.
func TestAnalyzerRunSimulatingGolangciLint(t *testing.T) {
defaultTargetFiles := []string{
"pkg/features/kube_features.go",
"staging/src/k8s.io/apiserver/pkg/features/kube_features.go",
"staging/src/k8s.io/client-go/features/known_features.go",
"staging/src/k8s.io/controller-manager/pkg/features/kube_features.go",
"staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go",
"test/e2e/feature/feature.go",
"test/e2e/environment/environment.go",
}
for _, filename := range defaultTargetFiles {
t.Run(filename, func(t *testing.T) {
// Check if file exists
if _, err := os.Stat(filename); os.IsNotExist(err) {
t.Skip("File not found; skipping test")
}
// Create a file set and parse a simple source
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
// Create a mock analysis.Pass similar to what golangci-lint would create
var diagnostics []analysis.Diagnostic
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) {
diagnostics = append(diagnostics, d)
},
}
// Get the analyzer
analyzer := NewAnalyzerWithConfig(Config{Debug: true, Files: defaultTargetFiles})
// Call the Run method directly as golangci-lint would
result, err := analyzer.Run(pass)
// Check that no error was returned
if err != nil {
t.Errorf("Analyzer Run returned an error: %v", err)
}
// Check that result is nil (our analyzer doesn't return any result)
if result != nil {
t.Errorf("Expected nil result for %s, got: %v", filename, result)
}
// Check that no diagnostics were reported for properly sorted features
if len(diagnostics) > 0 {
t.Errorf("Expected no diagnostics for %s, got: %v", filename, diagnostics)
}
})
}
}
func TestDebugLogging(t *testing.T) {
// This test doesn't actually verify the debug output, but ensures the code path is covered
config := Config{
Debug: true,
}
// Create a mock analysis.Pass
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "pkg/features/kube_features.go", `
package features
type Feature string
const (
FeatureA Feature = "FeatureA"
FeatureB Feature = "FeatureB"
)
`, parser.ParseComments)
if err != nil {
t.Fatalf("Failed to parse test file: %v", err)
}
pass := &analysis.Pass{
Fset: fset,
Files: []*ast.File{f},
ResultOf: make(map[*analysis.Analyzer]interface{}),
Report: func(d analysis.Diagnostic) {},
}
// Run the analyzer's run function directly with debug enabled
_, err = run(pass, config)
if err != nil {
t.Errorf("run returned an error: %v", err)
}
}
func TestGenerateSourceCode(t *testing.T) {
features := []Feature{
{Name: "FeatureB", Comments: []string{"// Comment for FeatureB"}},
{Name: "FeatureA", Comments: []string{"// Comment for FeatureA"}},
}
// Test const block generation
constSource := generateSourceCode(token.CONST, features)
expectedConstSource := `const (
// Comment for FeatureB
FeatureB = value
// Comment for FeatureA
FeatureA = value
)`
if constSource != expectedConstSource {
t.Errorf("Expected const source:\n%s\n\nGot:\n%s", expectedConstSource, constSource)
}
// Test var block generation
varSource := generateSourceCode(token.VAR, features)
expectedVarSource := `var (
// Comment for FeatureB
FeatureB = value
// Comment for FeatureA
FeatureA = value
)`
if varSource != expectedVarSource {
t.Errorf("Expected var source:\n%s\n\nGot:\n%s", expectedVarSource, varSource)
}
}
func TestDiffGeneration(t *testing.T) {
// Create unsorted features
unsorted := []Feature{
{Name: "FeatureC", Comments: []string{"// Comment for FeatureC"}},
{Name: "FeatureA", Comments: []string{"// Comment for FeatureA"}},
{Name: "FeatureB", Comments: []string{"// Comment for FeatureB"}},
}
// Create sorted features
sorted := []Feature{
{Name: "FeatureA", Comments: []string{"// Comment for FeatureA"}},
{Name: "FeatureB", Comments: []string{"// Comment for FeatureB"}},
{Name: "FeatureC", Comments: []string{"// Comment for FeatureC"}},
}
// Generate source code for both
originalSource := generateSourceCode(token.CONST, unsorted)
sortedSource := generateSourceCode(token.CONST, sorted)
// Create a unified diff
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(originalSource),
B: difflib.SplitLines(sortedSource),
FromFile: "Current",
ToFile: "Expected",
Context: 3,
}
diffText, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
t.Fatalf("Failed to generate diff: %v", err)
}
// Strip header to match the actual implementation
diffText = stripHeader(diffText, 3)
// Check that the diff contains expected content
expectedLines := []string{
"-\t// Comment for FeatureC",
"-\tFeatureC = value",
}
for _, line := range expectedLines {
if !strings.Contains(diffText, line) {
t.Errorf("Expected diff to contain line: %q, but it was not found in:\n%s", line, diffText)
}
}
// Check that the diff shows the correct order change
if !strings.Contains(diffText, "FeatureA") || !strings.Contains(diffText, "FeatureB") || !strings.Contains(diffText, "FeatureC") {
t.Errorf("Expected diff to contain all feature names, got: %s", diffText)
}
}

View File

@@ -0,0 +1,96 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This must be package main
package main
import (
"bytes"
"encoding/json"
"fmt"
"golang.org/x/tools/go/analysis"
"k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures/pkg"
)
type analyzerPlugin struct{}
func (*analyzerPlugin) GetAnalyzers() []*analysis.Analyzer {
return []*analysis.Analyzer{pkg.NewAnalyzer()}
}
// AnalyzerPlugin is the entry point for golangci-lint.
var AnalyzerPlugin analyzerPlugin
// settings defines the configuration options for the sortedfeatures linter
type settings struct {
// Debug enables debug logging
Debug bool `json:"debug"`
// Files specifies which files to check
Files []string `json:"files"`
}
// List of default files to check for feature gate sorting
var defaultTargetFiles = []string{
"pkg/features/kube_features.go",
"staging/src/k8s.io/apiserver/pkg/features/kube_features.go",
"staging/src/k8s.io/client-go/features/known_features.go",
"staging/src/k8s.io/controller-manager/pkg/features/kube_features.go",
"staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go",
"test/e2e/feature/feature.go",
"test/e2e/environment/environment.go",
}
// New is the entry point for golangci-lint plugin system
func New(pluginSettings interface{}) ([]*analysis.Analyzer, error) {
// Create default config
config := pkg.Config{}
// Parse settings if provided
if pluginSettings != nil {
var s settings
// Convert settings to JSON and back to our struct for easier handling
var buffer bytes.Buffer
if err := json.NewEncoder(&buffer).Encode(pluginSettings); err != nil {
return nil, fmt.Errorf("encoding settings as internal JSON buffer: %v", err)
}
decoder := json.NewDecoder(&buffer)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&s); err != nil {
return nil, fmt.Errorf("decoding settings from internal JSON buffer: %v", err)
}
// Apply settings to config
config.Debug = s.Debug
config.Files = append(config.Files, s.Files...)
if len(config.Files) == 0 {
// If no files are specified, use the default target files
config.Files = defaultTargetFiles
}
if config.Debug {
fmt.Printf("sortedfeatures settings: %+v\n", s)
fmt.Printf("final config: %+v\n", config)
}
}
// Get the analyzer with config
analyzer := pkg.NewAnalyzerWithConfig(config)
// Return the analyzer
return []*analysis.Analyzer{analyzer}, nil
}

View File

@@ -0,0 +1,17 @@
package testdata
type Feature string
const (
// These are properly sorted
FeatureA Feature = "FeatureA"
FeatureB Feature = "FeatureB"
FeatureC Feature = "FeatureC"
)
var (
// These are properly sorted
VarFeatureA Feature = "VarFeatureA"
VarFeatureB Feature = "VarFeatureB"
VarFeatureC Feature = "VarFeatureC"
)

View File

@@ -0,0 +1,17 @@
package testdata
type UnsortedFeature string
const (
// These are NOT properly sorted and will trigger a linter error
UnsortedFeatureB UnsortedFeature = "UnsortedFeatureB"
UnsortedFeatureA UnsortedFeature = "UnsortedFeatureA"
UnsortedFeatureC UnsortedFeature = "UnsortedFeatureC"
)
var (
// These are NOT properly sorted and will trigger a linter error
UnsortedVarFeatureC UnsortedFeature = "VarUnsortedFeatureC"
UnsortedVarFeatureA UnsortedFeature = "VarUnsortedFeatureA"
UnsortedVarFeatureB UnsortedFeature = "VarUnsortedFeatureB"
)

View File

@@ -129,7 +129,7 @@ kube::util::ensure-temp-dir
# Installing from source (https://golangci-lint.run/welcome/install/#install-from-sources)
# is not recommended, but for Kubernetes we prefer it because it avoids the need for
# pre-built binaries for different platforms and gives more insights on dependencies.
echo "installing golangci-lint, logcheck and kube-api-linter plugins from hack/tools/golangci-lint into ${GOBIN}"
echo "installing golangci-lint, logcheck kube-api-linter and sortedfeatures plugins from hack/tools/golangci-lint into ${GOBIN}"
GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" install github.com/golangci/golangci-lint/v2/cmd/golangci-lint
if [ "${golangci_config}" ]; then
# Plugins cannot be used without a config.
@@ -137,6 +137,7 @@ if [ "${golangci_config}" ]; then
# (on purpose: https://github.com/golang/go/issues/64964).
GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/logcheck.so" -buildmode=plugin sigs.k8s.io/logtools/logcheck/plugin
GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/kube-api-linter.so" -buildmode=plugin sigs.k8s.io/kube-api-linter/pkg/plugin
GOTOOLCHAIN="$(kube::golang::hack_tools_gotoolchain)" go -C "${KUBE_ROOT}/hack/tools/golangci-lint" build -o "${GOBIN}/sortedfeatures.so" -buildmode=plugin k8s.io/kubernetes/hack/tools/golangci-lint/sortedfeatures/plugin
fi
# Verify that the given config is valid. "golangci-lint run" does not

View File

@@ -41,10 +41,10 @@ if [[ -n "${direct_sets}" ]]; then
rc=1
fi
export LC_ALL=C
# ensure all generic features are added in alphabetic order
lines=$(git grep 'genericfeatures[.].*:' -- pkg/features/kube_features.go)
sorted_lines=$(echo "$lines" | sort -f)
sorted_lines=$(echo "$lines" | sort)
if [[ "$lines" != "$sorted_lines" ]]; then
echo "Generic features in pkg/features/kube_features.go not sorted" >&2
echo >&2