Skip to content

Commit 502783c

Browse files
authored
Merge pull request #106 from everettraven/linter/fieldnaming
linter: add `namingconventions` linter
2 parents 65a570b + 8d19d9e commit 502783c

File tree

19 files changed

+1080
-169
lines changed

19 files changed

+1080
-169
lines changed

docs/linters.md

Lines changed: 81 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,86 @@ 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+
322+
The `Inform` operation will simply inform when a field name violates the naming convention.
323+
The `Drop` operation will suggest a fix that drops violating text from the field name.
324+
The `DropField` operation will suggest a fix that removes the field in it's entirety.
325+
The `Replace` operation will suggest a fix that replaces the violating text in the field name with a defined replacement value.
326+
327+
High-level configuration overview:
328+
```yaml
329+
linterConfig:
330+
namingconventions:
331+
conventions:
332+
- name: {human readable string} # must be unique
333+
violationMatcher: {regular expression}
334+
operation: Inform | Drop | DropField | Replace
335+
replace: { replacement string } # required when operation is 'Replace', forbidden otherwise
336+
message: {human readable string}
337+
```
338+
339+
Some example configurations:
340+
341+
**Scenario:** Inform that any variations of the word 'fruit' in field names is not allowed
342+
```yaml
343+
linterConfig:
344+
namingconventions:
345+
conventions:
346+
- name: nofruit
347+
violationMatcher: (?i)fruit
348+
operation: Inform
349+
message: fields should not contain any variation of the word 'fruit' in their names
350+
```
351+
352+
**Scenario:** Drop any variations of the word 'fruit' in field names
353+
```yaml
354+
linterConfig:
355+
namingconventions:
356+
conventions:
357+
- name: nofruit
358+
violationMatcher: (?i)fruit
359+
operation: Drop
360+
message: fields should not contain any variation of the word 'fruit' in their names
361+
```
362+
363+
**Scenario:** Do not allow fields with any variations of the word 'fruit' in their name
364+
```yaml
365+
linterConfig:
366+
namingconventions:
367+
conventions:
368+
- name: nofruit
369+
violationMatcher: (?i)fruit
370+
operation: DropField
371+
message: fields should not contain any variation of the word 'fruit' in their names
372+
```
373+
374+
**Scenario:** Replace any variations of the word 'color' with 'colour' in field names
375+
```yaml
376+
linterConfig:
377+
namingconventions:
378+
conventions:
379+
- name: BritishEnglishColour
380+
violationMatcher: (?i)color
381+
operation: Replace
382+
replace: colour
383+
message: prefer 'colour' over 'color' when referring to colours in field names
384+
```
385+
305386
## NoBools
306387

307388
The `nobools` linter checks that fields in the API types do not contain a `bool` type.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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 OperationReplacement:
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: field %s: %s", convention.Name, utils.FieldName(field), convention.Message),
106+
SuggestedFixes: suggestedFixes,
107+
})
108+
}
109+
110+
func reportDropField(pass *analysis.Pass, field *ast.Field, convention Convention) {
111+
suggestedFixes := []analysis.SuggestedFix{
112+
{
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+
124+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
125+
}
126+
127+
func reportDrop(pass *analysis.Pass, field *ast.Field, tagInfo extractjsontags.FieldTagInfo, convention Convention, matcher *regexp.Regexp) {
128+
suggestedFixes := suggestedFixesForReplacement(field, tagInfo, matcher, "")
129+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
130+
}
131+
132+
func reportReplace(pass *analysis.Pass, field *ast.Field, tagInfo extractjsontags.FieldTagInfo, convention Convention, matcher *regexp.Regexp) {
133+
suggestedFixes := suggestedFixesForReplacement(field, tagInfo, matcher, convention.Replacement)
134+
reportConventionWithSuggestedFixes(pass, field, convention, suggestedFixes...)
135+
}
136+
137+
func suggestedFixesForReplacement(field *ast.Field, tagInfo extractjsontags.FieldTagInfo, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
138+
suggestedFixes := []analysis.SuggestedFix{}
139+
140+
suggestedFixes = append(suggestedFixes, suggestFixesForGoFieldName(field, matcher, replacementStr)...)
141+
suggestedFixes = append(suggestedFixes, suggestFixesForSerializedFieldName(tagInfo, matcher, replacementStr)...)
142+
143+
return suggestedFixes
144+
}
145+
146+
func suggestFixesForSerializedFieldName(tagInfo extractjsontags.FieldTagInfo, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
147+
replacement := matcher.ReplaceAllString(tagInfo.Name, replacementStr)
148+
149+
// If dropping the offending text from the field name would result in an empty
150+
// field name, just issue the failure with no suggested fix.
151+
if len(replacement) == 0 {
152+
return nil
153+
}
154+
155+
// This should prevent panics from slice access when the replacement
156+
// string ends up being a length of 1 and still result in a technically
157+
// correct JSON tag name value.
158+
tagNameReplacement := strings.ToLower(replacement)
159+
if len(replacement) > 1 {
160+
tagNameReplacement = fmt.Sprintf("%s%s", strings.ToLower(replacement[:1]), replacement[1:])
161+
}
162+
163+
tagReplacement := strings.ReplaceAll(tagInfo.RawValue, tagInfo.Name, tagNameReplacement)
164+
165+
tagReplacementMessage := fmt.Sprintf("replace offending text in serialized field name with %q", replacementStr)
166+
167+
if len(replacementStr) == 0 {
168+
tagReplacementMessage = "remove offending text from serialized field name"
169+
}
170+
171+
return []analysis.SuggestedFix{
172+
{
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+
}
184+
185+
func suggestFixesForGoFieldName(field *ast.Field, matcher *regexp.Regexp, replacementStr string) []analysis.SuggestedFix {
186+
fieldName := utils.FieldName(field)
187+
replacement := matcher.ReplaceAllString(fieldName, replacementStr)
188+
189+
// If dropping the offending text from the field name would result in an empty
190+
// field name, just issue the failure with no suggested fix.
191+
if len(replacement) == 0 {
192+
return nil
193+
}
194+
195+
replacementMessage := fmt.Sprintf("replace offending text in Go type with %q", replacementStr)
196+
197+
if len(replacementStr) == 0 {
198+
replacementMessage = "remove offending text from Go type field"
199+
}
200+
201+
return []analysis.SuggestedFix{
202+
{
203+
Message: replacementMessage,
204+
TextEdits: []analysis.TextEdit{
205+
{
206+
Pos: field.Pos(),
207+
NewText: []byte(replacement),
208+
End: field.Pos() + token.Pos(len(fieldName)),
209+
},
210+
},
211+
},
212+
}
213+
}
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.OperationReplacement,
40+
Message: "prefer the use of the word 'behaviour' instead of 'behavior'.",
41+
Replacement: "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)