Skip to content

Commit fcbf6de

Browse files
committed
linters: add namingconventions linter
that allows for defining custom namingconventions to be enforced on Go and serialized field names Signed-off-by: Bryce Palmer <bpalmer@redhat.com>
1 parent 65a570b commit fcbf6de

File tree

10 files changed

+978
-0
lines changed

10 files changed

+978
-0
lines changed

docs/linters.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- [Integers](#integers) - Validates usage of supported integer types
99
- [JSONTags](#jsontags) - Ensures proper JSON tag formatting
1010
- [MaxLength](#maxlength) - Checks for maximum length constraints on strings and arrays
11+
- [NamingConventions](#namingconventions) - Ensures field names adhere to user-defined naming conventions
1112
- [NoBools](#nobools) - Prevents usage of boolean types
1213
- [NoDurations](#nodurations) - Prevents usage of duration types
1314
- [NoFloats](#nofloats) - Prevents usage of floating-point types
@@ -302,6 +303,85 @@ or `+kubebuilder:validation:items:MaxLenth` if the array is an element of the bu
302303
Adding maximum lengths to strings and arrays not only ensures that the API is not abused (used to store overly large data, reduces DDOS etc.),
303304
but also allows CEL validation cost estimations to be kept within reasonable bounds.
304305

306+
## NamingConventions
307+
308+
The `namingconventions` linter ensures that field names adhere to a set of defined naming conventions.
309+
310+
By default, `namingconventions` is not enabled.
311+
312+
When enabled, it must be configured with at least one naming convention.
313+
314+
### Configuration
315+
316+
Naming conventions must have:
317+
- A unique human-readable name.
318+
- A human-readable message to be included in violation errors.
319+
- A regular expression that will match text within the field name that violates the convention.
320+
- A defined "operation". Allowed operations are `Inform`, `Drop`, `DropField`, and `Replace`.
321+
The `Inform` operation will simply inform when a field name violates the naming convention.
322+
The `Drop` operation will suggest a fix that drops violating text from the field name.
323+
The `DropField` operation will suggest a fix that removes the field in it's entirety.
324+
The `Replace` operation will suggest a fix that replaces the violating text in the field name with a defined replacement value.
325+
326+
High-level configuration overview:
327+
```yaml
328+
linterConfig:
329+
namingconventions:
330+
conventions:
331+
- name: {human readable string} # must be unique
332+
violationMatcher: {regular expression}
333+
operation: Inform | Drop | DropField | Replace
334+
replace: { replacement string } # required when operation is 'Replace', forbidden otherwise
335+
message: {human readable string}
336+
```
337+
338+
Some example configurations:
339+
340+
**Scenario:** Inform that any variations of the word 'fruit' in field names is not allowed
341+
```yaml
342+
linterConfig:
343+
namingconventions:
344+
conventions:
345+
- name: nofruit
346+
violationMatcher: (?i)fruit
347+
operation: Inform
348+
message: fields should not contain any variation of the word 'fruit' in their names
349+
```
350+
351+
**Scenario:** Drop any variations of the word 'fruit' in field names
352+
```yaml
353+
linterConfig:
354+
namingconventions:
355+
conventions:
356+
- name: nofruit
357+
violationMatcher: (?i)fruit
358+
operation: Drop
359+
message: fields should not contain any variation of the word 'fruit' in their names
360+
```
361+
362+
**Scenario:** Do not allow fields with any variations of the word 'fruit' in their name
363+
```yaml
364+
linterConfig:
365+
namingconventions:
366+
conventions:
367+
- name: nofruit
368+
violationMatcher: (?i)fruit
369+
operation: DropField
370+
message: fields should not contain any variation of the word 'fruit' in their names
371+
```
372+
373+
**Scenario:** Replace any variations of the word 'color' with 'colour' in field names
374+
```yaml
375+
linterConfig:
376+
namingconventions:
377+
conventions:
378+
- name: englishcolour
379+
violationMatcher: (?i)color
380+
operation: Replace
381+
replace: colour
382+
message: prefer 'colour' over 'color' when referring to colours in field names
383+
```
384+
305385
## NoBools
306386

307387
The `nobools` linter checks that fields in the API types do not contain a `bool` type.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package namingconventions
17+
18+
import (
19+
"fmt"
20+
"go/ast"
21+
"go/token"
22+
"regexp"
23+
"strings"
24+
25+
"golang.org/x/tools/go/analysis"
26+
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
27+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
28+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
29+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
30+
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
31+
)
32+
33+
const name = "namingconventions"
34+
35+
type analyzer struct {
36+
conventions []Convention
37+
}
38+
39+
// newAnalyzer creates a new analysis.Analyzer for the namingconventions
40+
// linter based on the provided config.
41+
func newAnalyzer(cfg *Config) *analysis.Analyzer {
42+
a := &analyzer{
43+
conventions: cfg.Conventions,
44+
}
45+
46+
analyzer := &analysis.Analyzer{
47+
Name: name,
48+
Doc: "Enforces naming conventions on fields",
49+
Run: a.run,
50+
Requires: []*analysis.Analyzer{inspector.Analyzer, extractjsontags.Analyzer},
51+
}
52+
53+
return analyzer
54+
}
55+
56+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
57+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
58+
if !ok {
59+
return nil, kalerrors.ErrCouldNotGetInspector
60+
}
61+
62+
inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTags extractjsontags.FieldTagInfo, markersAccess markers.Markers) {
63+
checkField(pass, field, jsonTags, a.conventions...)
64+
})
65+
66+
return nil, nil //nolint:nilnil
67+
}
68+
69+
func checkField(pass *analysis.Pass, field *ast.Field, tagInfo extractjsontags.FieldTagInfo, conventions ...Convention) {
70+
if field == nil || len(field.Names) == 0 {
71+
return
72+
}
73+
74+
fieldName := utils.FieldName(field)
75+
76+
for _, convention := range conventions {
77+
// regexp.MustCompile will panic if the regular expression doesn't compile.
78+
// This should be reasonable as any regular expressions in naming conventions
79+
// will have already been validated to compile during the configuration validation stage.
80+
matcher := regexp.MustCompile(convention.ViolationMatcher)
81+
82+
if !matcher.MatchString(fieldName) && !matcher.MatchString(tagInfo.Name) {
83+
continue
84+
}
85+
86+
switch convention.Operation {
87+
case OperationInform:
88+
reportConventionWithSuggestedFixes(pass, field, convention)
89+
90+
case OperationDropField:
91+
reportDropField(pass, field, convention)
92+
93+
case OperationDrop:
94+
reportDrop(pass, field, tagInfo, convention, matcher)
95+
96+
case OperationReplace:
97+
reportReplace(pass, field, tagInfo, convention, matcher)
98+
}
99+
}
100+
}
101+
102+
func reportConventionWithSuggestedFixes(pass *analysis.Pass, field *ast.Field, convention Convention, suggestedFixes ...analysis.SuggestedFix) {
103+
pass.Report(analysis.Diagnostic{
104+
Pos: field.Pos(),
105+
Message: fmt.Sprintf("naming convention %q: %s", convention.Name, convention.Message),
106+
SuggestedFixes: suggestedFixes,
107+
})
108+
}
109+
110+
func reportDropField(pass *analysis.Pass, field *ast.Field, convention Convention) {
111+
suggestedFixes := []analysis.SuggestedFix{}
112+
suggestedFixes = append(suggestedFixes, analysis.SuggestedFix{
113+
Message: "remove the field",
114+
TextEdits: []analysis.TextEdit{
115+
{
116+
Pos: field.Pos(),
117+
NewText: []byte(""),
118+
End: field.End(),
119+
},
120+
},
121+
})
122+
123+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
124+
}
125+
126+
func reportDrop(pass *analysis.Pass, field *ast.Field, tagInfo extractjsontags.FieldTagInfo, convention Convention, matcher *regexp.Regexp) {
127+
suggestedFixes := suggestedFixesForReplacement(field, tagInfo, matcher, "")
128+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
129+
}
130+
131+
func reportReplace(pass *analysis.Pass, field *ast.Field, tagInfo extractjsontags.FieldTagInfo, convention Convention, matcher *regexp.Regexp) {
132+
suggestedFixes := suggestedFixesForReplacement(field, tagInfo, matcher, convention.Replace)
133+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
134+
}
135+
136+
func suggestedFixesForReplacement(field *ast.Field, tagInfo extractjsontags.FieldTagInfo, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
137+
suggestedFixes := []analysis.SuggestedFix{}
138+
139+
suggestedFixes = append(suggestedFixes, suggestFixesForGoFieldName(field, matcher, replacementStr)...)
140+
suggestedFixes = append(suggestedFixes, suggestFixesForSerializedFieldName(tagInfo, matcher, replacementStr)...)
141+
142+
return suggestedFixes
143+
}
144+
145+
func suggestFixesForSerializedFieldName(tagInfo extractjsontags.FieldTagInfo, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
146+
replacement := matcher.ReplaceAllString(tagInfo.Name, replacementStr)
147+
148+
// If dropping the offending text from the field name would result in an empty
149+
// field name, just issue the failure with no suggested fix.
150+
if len(replacement) == 0 {
151+
return nil
152+
}
153+
154+
suggestedFixes := []analysis.SuggestedFix{}
155+
156+
// This should prevent panics from slice access when the replacement
157+
// string ends up being a length of 1 and still result in a technically
158+
// correct JSON tag name value.
159+
tagNameReplacement := strings.ToLower(replacement)
160+
if len(replacement) > 1 {
161+
tagNameReplacement = fmt.Sprintf("%s%s", strings.ToLower(replacement[:1]), replacement[1:])
162+
}
163+
164+
tagReplacement := strings.ReplaceAll(tagInfo.RawValue, tagInfo.Name, tagNameReplacement)
165+
166+
tagReplacementMessage := fmt.Sprintf("replace offending text in serialized field name with %q", replacementStr)
167+
168+
if len(replacementStr) == 0 {
169+
tagReplacementMessage = "remove offending text from serialized field name"
170+
}
171+
172+
suggestedFixes = append(suggestedFixes, analysis.SuggestedFix{
173+
Message: tagReplacementMessage,
174+
TextEdits: []analysis.TextEdit{
175+
{
176+
Pos: tagInfo.Pos,
177+
NewText: []byte(tagReplacement),
178+
End: tagInfo.End,
179+
},
180+
},
181+
})
182+
183+
return suggestedFixes
184+
}
185+
186+
func suggestFixesForGoFieldName(field *ast.Field, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
187+
fieldName := utils.FieldName(field)
188+
replacement := matcher.ReplaceAllString(fieldName, replacementStr)
189+
190+
// If dropping the offending text from the field name would result in an empty
191+
// field name, just issue the failure with no suggested fix.
192+
if len(replacement) == 0 {
193+
return nil
194+
}
195+
196+
suggestedFixes := []analysis.SuggestedFix{}
197+
198+
replacementMessage := fmt.Sprintf("replace offending text in Go type with %q", replacementStr)
199+
200+
if len(replacementStr) == 0 {
201+
replacementMessage = "remove offending text from Go type field"
202+
}
203+
204+
suggestedFixes = append(suggestedFixes, analysis.SuggestedFix{
205+
Message: replacementMessage,
206+
TextEdits: []analysis.TextEdit{
207+
{
208+
Pos: field.Pos(),
209+
NewText: []byte(replacement),
210+
End: field.Pos() + token.Pos(len(fieldName)),
211+
},
212+
},
213+
})
214+
215+
return suggestedFixes
216+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package namingconventions_test
17+
18+
import (
19+
"testing"
20+
21+
"golang.org/x/tools/go/analysis/analysistest"
22+
"sigs.k8s.io/kube-api-linter/pkg/analysis/namingconventions"
23+
)
24+
25+
func Test(t *testing.T) {
26+
testdata := analysistest.TestData()
27+
28+
cfg := &namingconventions.Config{
29+
Conventions: []namingconventions.Convention{
30+
{
31+
Name: "nofruit",
32+
ViolationMatcher: "(?i)fruit",
33+
Operation: namingconventions.OperationDrop,
34+
Message: "no fields should contain any variations of the word 'fruit' in their name.",
35+
},
36+
{
37+
Name: "preferbehaviour",
38+
ViolationMatcher: "(?i)behavior",
39+
Operation: namingconventions.OperationReplace,
40+
Message: "prefer the use of the word 'behaviour' instead of 'behavior'.",
41+
Replace: "Behaviour",
42+
},
43+
{
44+
Name: "nounsupported",
45+
ViolationMatcher: "(?i)unsupported",
46+
Operation: namingconventions.OperationDropField,
47+
Message: "no fields allowing for unsupported behaviors allowed",
48+
},
49+
{
50+
Name: "notest",
51+
ViolationMatcher: "(?i)test",
52+
Operation: namingconventions.OperationInform,
53+
Message: "no temporary test fields",
54+
},
55+
},
56+
}
57+
58+
analyzer, err := namingconventions.Initializer().Init(cfg)
59+
if err != nil {
60+
t.Fatalf("initializing namingconventions linter: %v", err)
61+
}
62+
63+
analysistest.RunWithSuggestedFixes(t, testdata, analyzer, "a")
64+
}

0 commit comments

Comments
 (0)