Skip to content

Commit cb6425b

Browse files
committed
Add discriminated unions structure linter
Signed-off-by: Matteo Fari <matteofari06@gmail.com>
1 parent 39e3d06 commit cb6425b

15 files changed

Lines changed: 782 additions & 0 deletions

File tree

.DS_Store

8 KB
Binary file not shown.

docs/linters.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
| [DefaultOrRequired](#defaultorrequired) | Ensures fields marked as required do not have default values | True | Native, CRD |
1010
| [Defaults](#defaults) | Checks that fields with default markers are configured correctly | True | Native, CRD |
1111
| [DependentTags](#dependenttags) | Enforces dependencies between markers | False | Native, CRD |
12+
| [DiscriminatedUnions](#discriminatedunions) | Validates discriminated union marker structure | False | Native, CRD |
1213
| [DuplicateMarkers](#duplicatemarkers) | Checks for exact duplicates of markers | True | Native, CRD |
1314
| [ForbiddenMarkers](#forbiddenmarkers) | Checks that no forbidden markers are present on types/fields. | False | Native, CRD |
1415
| [Integers](#integers) | Validates usage of supported integer types | True | Native, CRD |
@@ -138,6 +139,33 @@ This linter only checks for the presence or absence of markers; it does not insp
138139
- **Fixes:** This linter does not provide automatic fixes. It only reports violations.
139140
- **Same/Different Values:** Whether you want the same or different values between dependent markers is outside the scope of this linter. You would need other validation mechanisms (e.g., CEL validation) to enforce value-based dependencies.
140141

142+
## DiscriminatedUnions
143+
144+
The `discriminatedunions` linter validates discriminated union definitions across legacy markers (`+union`, `+unionDiscriminator`, `+unionMember`) and declarative markers (`+k8s:unionDiscriminator`, `+k8s:unionMember`).
145+
Union detection is triggered when a struct has either:
146+
- A type-level `+union` marker.
147+
- One or more union field markers (`+unionDiscriminator`/`+unionMember` or `+k8s:unionDiscriminator`/`+k8s:unionMember`).
148+
149+
The linter enforces:
150+
151+
- Exactly one discriminator field.
152+
- A required discriminator field.
153+
- Optional member fields (including support for `+unionMember,optional`).
154+
- Optional forbidding of non-member fields.
155+
156+
### Configuration
157+
158+
```yaml
159+
lintersConfig:
160+
discriminatedunions:
161+
nonMemberFields: Forbid | Allow # Defaults to `Forbid`.
162+
```
163+
164+
### Behavior
165+
166+
- **Default:** Disabled by default; enable explicitly.
167+
- **Scope:** Structure-only validation in this linter implementation.
168+
141169
## CommentStart
142170

143171
The `commentstart` linter checks that all comments in the API types start with the serialized form of the type they are commenting on.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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+
17+
package discriminatedunions
18+
19+
import (
20+
"go/ast"
21+
"slices"
22+
"strings"
23+
24+
"golang.org/x/tools/go/analysis"
25+
26+
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
27+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
28+
inspectorhelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
29+
markershelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
30+
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
31+
markersconsts "sigs.k8s.io/kube-api-linter/pkg/markers"
32+
)
33+
34+
const name = "discriminatedunions"
35+
36+
func init() {
37+
markershelper.DefaultRegistry().Register(
38+
markersconsts.UnionMarker,
39+
markersconsts.UnionDiscriminatorMarker,
40+
markersconsts.UnionMemberMarker,
41+
markersconsts.K8sUnionDiscriminatorMarker,
42+
markersconsts.K8sUnionMemberMarker,
43+
markersconsts.OptionalMarker,
44+
markersconsts.KubebuilderOptionalMarker,
45+
markersconsts.K8sOptionalMarker,
46+
markersconsts.RequiredMarker,
47+
markersconsts.KubebuilderRequiredMarker,
48+
markersconsts.K8sRequiredMarker,
49+
)
50+
}
51+
52+
type analyzer struct {
53+
nonMemberFields NonMemberFieldsPolicy
54+
}
55+
56+
type unionType struct {
57+
typeSpec *ast.TypeSpec
58+
name string
59+
60+
hasUnionMarker bool
61+
62+
discriminatorFields []*unionField
63+
memberFields []*unionField
64+
nonMemberFields []*unionField
65+
}
66+
67+
type unionField struct {
68+
field *ast.Field
69+
qualifiedName string
70+
71+
required bool
72+
optional bool
73+
74+
isDiscriminator bool
75+
isMember bool
76+
memberOptionalMarker bool
77+
}
78+
79+
type unionFieldClassification struct {
80+
isDiscriminator bool
81+
isMember bool
82+
isMemberOptional bool
83+
}
84+
85+
func newAnalyzer(cfg *Config) *analysis.Analyzer {
86+
if cfg == nil {
87+
cfg = &Config{}
88+
}
89+
90+
defaultConfig(cfg)
91+
92+
a := &analyzer{
93+
nonMemberFields: cfg.NonMemberFields,
94+
}
95+
96+
return &analysis.Analyzer{
97+
Name: name,
98+
Doc: "Validates discriminated-union marker structure.",
99+
Run: a.run,
100+
Requires: []*analysis.Analyzer{inspectorhelper.Analyzer},
101+
}
102+
}
103+
104+
func defaultConfig(cfg *Config) {
105+
if cfg.NonMemberFields == "" {
106+
cfg.NonMemberFields = NonMemberFieldsForbid
107+
}
108+
}
109+
110+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
111+
inspect, ok := pass.ResultOf[inspectorhelper.Analyzer].(inspectorhelper.Inspector)
112+
if !ok {
113+
return nil, kalerrors.ErrCouldNotGetInspector
114+
}
115+
116+
fieldInfos := make(map[*ast.Field]*unionField)
117+
118+
inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers, qualifiedFieldName string) {
119+
fieldInfos[field] = buildUnionFieldInfo(field, markersAccess, qualifiedFieldName)
120+
})
121+
122+
inspect.InspectTypeSpec(func(typeSpec *ast.TypeSpec, markersAccess markershelper.Markers) {
123+
union := buildUnionType(typeSpec, markersAccess, fieldInfos)
124+
if union == nil {
125+
return
126+
}
127+
128+
a.reportStructureViolations(pass, union)
129+
})
130+
131+
return nil, nil //nolint:nilnil
132+
}
133+
134+
func buildUnionType(typeSpec *ast.TypeSpec, markersAccess markershelper.Markers, fieldInfos map[*ast.Field]*unionField) *unionType {
135+
if typeSpec == nil || typeSpec.Name == nil {
136+
return nil
137+
}
138+
139+
structType, ok := typeSpec.Type.(*ast.StructType)
140+
if !ok || structType.Fields == nil {
141+
return nil
142+
}
143+
144+
structMarkers := markersAccess.StructMarkers(structType)
145+
146+
union := &unionType{
147+
typeSpec: typeSpec,
148+
name: typeSpec.Name.Name,
149+
hasUnionMarker: structMarkers.Has(markersconsts.UnionMarker),
150+
}
151+
152+
for _, field := range structType.Fields.List {
153+
unionFieldInfo, ok := fieldInfos[field]
154+
if !ok {
155+
continue
156+
}
157+
158+
addUnionField(union, unionFieldInfo)
159+
}
160+
161+
if !union.hasUnionMarker && len(union.discriminatorFields) == 0 && len(union.memberFields) == 0 {
162+
return nil
163+
}
164+
165+
return union
166+
}
167+
168+
func (a *analyzer) reportStructureViolations(pass *analysis.Pass, union *unionType) {
169+
if union == nil {
170+
return
171+
}
172+
173+
reportDiscriminatorViolations(pass, union)
174+
reportMissingMemberViolations(pass, union)
175+
reportMemberOptionalityViolations(pass, union)
176+
a.reportNonMemberFieldViolations(pass, union)
177+
}
178+
179+
func buildUnionFieldInfo(field *ast.Field, markersAccess markershelper.Markers, qualifiedFieldName string) *unionField {
180+
if field == nil {
181+
return nil
182+
}
183+
184+
fieldMarkers := markersAccess.FieldMarkers(field)
185+
classification := classifyUnionField(fieldMarkers)
186+
187+
return &unionField{
188+
field: field,
189+
qualifiedName: qualifiedFieldName,
190+
required: utils.IsFieldRequired(field, markersAccess),
191+
optional: utils.IsFieldOptional(field, markersAccess),
192+
isDiscriminator: classification.isDiscriminator,
193+
isMember: classification.isMember,
194+
memberOptionalMarker: classification.isMemberOptional,
195+
}
196+
}
197+
198+
func addUnionField(union *unionType, field *unionField) {
199+
if union == nil || field == nil {
200+
return
201+
}
202+
203+
if field.isDiscriminator {
204+
union.discriminatorFields = append(union.discriminatorFields, field)
205+
}
206+
207+
if field.isMember {
208+
union.memberFields = append(union.memberFields, field)
209+
}
210+
211+
if !field.isDiscriminator && !field.isMember {
212+
union.nonMemberFields = append(union.nonMemberFields, field)
213+
}
214+
}
215+
216+
func reportDiscriminatorViolations(pass *analysis.Pass, union *unionType) {
217+
switch len(union.discriminatorFields) {
218+
case 0:
219+
pass.Reportf(
220+
union.typeSpec.Pos(),
221+
"type %s is marked as a discriminated union but has no discriminator field; expected exactly one field with +%s or +%s",
222+
union.name,
223+
markersconsts.UnionDiscriminatorMarker,
224+
markersconsts.K8sUnionDiscriminatorMarker,
225+
)
226+
case 1:
227+
discriminator := union.discriminatorFields[0]
228+
if !discriminator.required {
229+
pass.Reportf(discriminator.field.Pos(), "discriminator field %s must be marked as required", discriminator.qualifiedName)
230+
}
231+
default:
232+
discriminatorNames := make([]string, 0, len(union.discriminatorFields))
233+
for _, field := range union.discriminatorFields {
234+
discriminatorNames = append(discriminatorNames, field.qualifiedName)
235+
}
236+
237+
pass.Reportf(
238+
union.typeSpec.Pos(),
239+
"type %s is marked as a discriminated union but has %d discriminator fields; expected exactly one: %s",
240+
union.name,
241+
len(union.discriminatorFields),
242+
strings.Join(discriminatorNames, ", "),
243+
)
244+
}
245+
}
246+
247+
func reportMissingMemberViolations(pass *analysis.Pass, union *unionType) {
248+
if len(union.memberFields) == 0 {
249+
pass.Reportf(union.typeSpec.Pos(), "type %s is marked as a discriminated union but has no union member fields", union.name)
250+
}
251+
}
252+
253+
func reportMemberOptionalityViolations(pass *analysis.Pass, union *unionType) {
254+
for _, member := range union.memberFields {
255+
if member.optional || member.memberOptionalMarker {
256+
continue
257+
}
258+
259+
pass.Reportf(
260+
member.field.Pos(),
261+
"union member field %s must be marked as optional (use +optional/+k8s:optional or +%s,optional)",
262+
member.qualifiedName,
263+
markersconsts.UnionMemberMarker,
264+
)
265+
}
266+
}
267+
268+
func (a *analyzer) reportNonMemberFieldViolations(pass *analysis.Pass, union *unionType) {
269+
if a.nonMemberFields != NonMemberFieldsForbid {
270+
return
271+
}
272+
273+
for _, field := range union.nonMemberFields {
274+
pass.Reportf(
275+
field.field.Pos(),
276+
"field %s is not a union discriminator/member in union type %s (non-member fields are forbidden)",
277+
field.qualifiedName,
278+
union.name,
279+
)
280+
}
281+
}
282+
283+
func classifyUnionField(fieldMarkers markershelper.MarkerSet) unionFieldClassification {
284+
classification := unionFieldClassification{
285+
isDiscriminator: fieldMarkers.Has(markersconsts.UnionDiscriminatorMarker) ||
286+
fieldMarkers.Has(markersconsts.K8sUnionDiscriminatorMarker),
287+
isMember: fieldMarkers.Has(markersconsts.UnionMemberMarker) || fieldMarkers.Has(markersconsts.K8sUnionMemberMarker),
288+
}
289+
290+
if !classification.isMember {
291+
return classification
292+
}
293+
294+
classification.isMemberOptional = slices.ContainsFunc(fieldMarkers.Get(markersconsts.UnionMemberMarker), markerSpecifiesOptionalMember) ||
295+
slices.ContainsFunc(fieldMarkers.Get(markersconsts.K8sUnionMemberMarker), markerSpecifiesOptionalMember)
296+
297+
return classification
298+
}
299+
300+
func markerSpecifiesOptionalMember(marker markershelper.Marker) bool {
301+
value, ok := marker.Arguments[markershelper.UnnamedArgument]
302+
if !ok {
303+
return false
304+
}
305+
306+
return strings.TrimSpace(strings.Trim(value, `"'`)) == markersconsts.OptionalMarker
307+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
17+
package discriminatedunions_test
18+
19+
import (
20+
"testing"
21+
22+
"golang.org/x/tools/go/analysis/analysistest"
23+
"sigs.k8s.io/kube-api-linter/pkg/analysis/discriminatedunions"
24+
)
25+
26+
func TestAnalyzer(t *testing.T) {
27+
testdata := analysistest.TestData()
28+
29+
analyzer, err := discriminatedunions.Initializer().Init(&discriminatedunions.Config{})
30+
if err != nil {
31+
t.Fatalf("failed to initialize analyzer: %v", err)
32+
}
33+
34+
analysistest.Run(t, testdata, analyzer, "a")
35+
}
36+
37+
func TestAnalyzerAllowNonMemberFields(t *testing.T) {
38+
testdata := analysistest.TestData()
39+
40+
analyzer, err := discriminatedunions.Initializer().Init(&discriminatedunions.Config{NonMemberFields: discriminatedunions.NonMemberFieldsAllow})
41+
if err != nil {
42+
t.Fatalf("failed to initialize analyzer: %v", err)
43+
}
44+
45+
analysistest.Run(t, testdata, analyzer, "b")
46+
}

0 commit comments

Comments
 (0)