Skip to content

Commit 77262c4

Browse files
committed
feat(linter): add dependenttags linter
Introduces a new configurable linter named `dependenttags` to enforce dependencies between markers. This linter ensures that if a main marker is present on a field, a set of dependent markers are also present, preventing API inconsistencies. The linter supports two dependency types: - `All`: Requires all dependent markers to be present. - `Any`: Requires at least one of the dependent markers to be present. This addresses issue #146.
1 parent 33e0e43 commit 77262c4

File tree

9 files changed

+572
-0
lines changed

9 files changed

+572
-0
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 dependenttags
18+
19+
import (
20+
"fmt"
21+
"go/ast"
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+
"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+
// analyzer implements the dependenttags linter.
34+
type analyzer struct {
35+
cfg Config
36+
}
37+
38+
// newAnalyzer creates a new analyzer.
39+
func newAnalyzer(cfg Config) *analysis.Analyzer {
40+
// Register markers from configuration
41+
for _, rule := range cfg.Rules {
42+
markers.DefaultRegistry().Register(rule.Identifier)
43+
44+
for _, dep := range rule.Dependents {
45+
markers.DefaultRegistry().Register(dep)
46+
}
47+
}
48+
49+
a := &analyzer{
50+
cfg: cfg,
51+
}
52+
53+
return &analysis.Analyzer{
54+
Name: name,
55+
Doc: "Enforces dependencies between markers.",
56+
Run: a.run,
57+
Requires: []*analysis.Analyzer{inspector.Analyzer, markers.Analyzer},
58+
}
59+
}
60+
61+
// run is the main function for the analyzer.
62+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
63+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
64+
if !ok {
65+
return nil, kalerrors.ErrCouldNotGetInspector
66+
}
67+
68+
inspect.InspectFields(func(field *ast.Field, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markers.Markers) {
69+
if field.Doc == nil {
70+
return
71+
}
72+
73+
fieldMarkers := utils.TypeAwareMarkerCollectionForField(pass, markersAccess, field)
74+
75+
for _, rule := range a.cfg.Rules {
76+
if _, ok := fieldMarkers[rule.Identifier]; ok {
77+
switch rule.Type {
78+
case DependencyTypeAny:
79+
handleAny(pass, field, rule, fieldMarkers)
80+
case DependencyTypeAll:
81+
handleAll(pass, field, rule, fieldMarkers)
82+
default:
83+
panic(fmt.Sprintf("unknown dependency type %s", rule.Type))
84+
}
85+
}
86+
}
87+
})
88+
89+
return nil, nil //nolint:nilnil
90+
}
91+
func handleAll(pass *analysis.Pass, field *ast.Field, rule Rule, fieldMarkers markers.MarkerSet) {
92+
missing := make([]string, 0, len(rule.Dependents))
93+
94+
for _, dependent := range rule.Dependents {
95+
if _, depOk := fieldMarkers[dependent]; !depOk {
96+
missing = append(missing, fmt.Sprintf("+%s", dependent))
97+
}
98+
}
99+
100+
if len(missing) > 0 {
101+
pass.Reportf(field.Pos(), "field with marker +%s is missing required marker(s): %s", rule.Identifier, strings.Join(missing, ", "))
102+
}
103+
}
104+
105+
func handleAny(pass *analysis.Pass, field *ast.Field, rule Rule, fieldMarkers markers.MarkerSet) {
106+
found := false
107+
108+
for _, dependent := range rule.Dependents {
109+
if _, depOk := fieldMarkers[dependent]; depOk {
110+
found = true
111+
break
112+
}
113+
}
114+
115+
if !found {
116+
dependents := make([]string, len(rule.Dependents))
117+
for i, d := range rule.Dependents {
118+
dependents[i] = fmt.Sprintf("+%s", d)
119+
}
120+
121+
pass.Reportf(field.Pos(), "field with marker +%s requires at least one of the following markers, but none were found: %s", rule.Identifier, strings.Join(dependents, ", "))
122+
}
123+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 dependenttags_test
18+
19+
import (
20+
"testing"
21+
22+
"golang.org/x/tools/go/analysis/analysistest"
23+
"sigs.k8s.io/kube-api-linter/pkg/analysis/dependenttags"
24+
)
25+
26+
func TestAnalyzer(t *testing.T) {
27+
testdata := analysistest.TestData()
28+
cfg := dependenttags.Config{
29+
Rules: []dependenttags.Rule{
30+
{
31+
Identifier: "k8s:unionMember",
32+
Type: dependenttags.DependencyTypeAll,
33+
Dependents: []string{"k8s:optional"},
34+
},
35+
{
36+
Identifier: "listType",
37+
Type: dependenttags.DependencyTypeAll,
38+
Dependents: []string{"k8s:listType"},
39+
},
40+
{
41+
Identifier: "example:any",
42+
Type: dependenttags.DependencyTypeAny,
43+
Dependents: []string{"dep1", "dep2"},
44+
},
45+
},
46+
}
47+
analyzer, err := dependenttags.Initializer().Init(&cfg)
48+
49+
if err != nil {
50+
t.Fatalf("failed to initialize analyzer: %v", err)
51+
}
52+
53+
analysistest.Run(t, testdata, analyzer, "a")
54+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 dependenttags
18+
19+
// DependencyType defines the type of dependency rule.
20+
type DependencyType string
21+
22+
const (
23+
// DependencyTypeAll indicates that all dependent markers are required.
24+
DependencyTypeAll DependencyType = "all"
25+
// DependencyTypeAny indicates that at least one of the dependent markers is required.
26+
DependencyTypeAny DependencyType = "any"
27+
)
28+
29+
// Config defines the configuration for the dependenttags linter.
30+
type Config struct {
31+
// Rules defines the dependency rules between markers.
32+
Rules []Rule `mapstructure:"rules"`
33+
}
34+
35+
// Rule defines a dependency rule where a main marker requires a set of dependent markers.
36+
type Rule struct {
37+
// Identifier is the marker that requires other markers.
38+
Identifier string `mapstructure:"identifier"`
39+
// Dependents are the markers that are required by Main.
40+
Dependents []string `mapstructure:"dependents"`
41+
// Type defines how to interpret the dependents list.
42+
// Can be 'all' (default) or 'any'.
43+
Type DependencyType `mapstructure:"type,omitempty"`
44+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 dependenttags_test
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
)
25+
26+
func TestDependentTags(t *testing.T) {
27+
RegisterFailHandler(Fail)
28+
RunSpecs(t, "DependentTags Suite")
29+
}

pkg/analysis/dependenttags/doc.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 dependenttags enforces dependencies between markers.
17+
//
18+
// # Analyzer dependenttags
19+
//
20+
// The dependenttags analyzer validates that if a specific marker (identifier) is present on a field,
21+
// a set of other markers (dependent tags) are also present. This is useful for enforcing API
22+
// contracts where certain markers imply the presence of others.
23+
//
24+
// For example, a field marked with `+k-8s:unionMember` must also be marked with `+k8s:optional`.
25+
//
26+
// # Configuration
27+
//
28+
// The linter is configured with a list of rules. Each rule specifies an identifier marker and a list of
29+
// dependent markers. The `type` field is required and specifies how to interpret the dependents list:
30+
// - `all`: all dependent markers are required.
31+
// - `any`: at least one of the dependent markers is required.
32+
//
33+
// This linter only checks for the presence or absence of markers; it does not inspect or enforce specific values within those markers. It also does not provide automatic fixes.
34+
//
35+
// linters:
36+
// dependenttags:
37+
// rules:
38+
// - identifier: "k8s:unionMember"
39+
// type: "all"
40+
// dependents:
41+
// - "k8s:optional"
42+
// - identifier: "listType"
43+
// type: "all"
44+
// dependents:
45+
// - "k8s:listType"
46+
// - identifier: "example:any"
47+
// type: "any"
48+
// dependents:
49+
// - "dep1"
50+
// - "dep2"
51+
package dependenttags
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 dependenttags
18+
19+
import (
20+
"golang.org/x/tools/go/analysis"
21+
"k8s.io/apimachinery/pkg/util/validation/field"
22+
"sigs.k8s.io/kube-api-linter/pkg/analysis/initializer"
23+
"sigs.k8s.io/kube-api-linter/pkg/analysis/registry"
24+
)
25+
26+
const (
27+
name = "dependenttags"
28+
)
29+
30+
func init() {
31+
registry.DefaultRegistry().RegisterLinter(Initializer())
32+
}
33+
34+
// Initializer returns the AnalyzerInitializer for this Analyzer so that it can be added to the registry.
35+
func Initializer() initializer.ConfigurableAnalyzerInitializer {
36+
return initializer.NewConfigurableInitializer(
37+
name,
38+
initAnalyzer,
39+
false,
40+
validateConfig,
41+
)
42+
}
43+
44+
// initAnalyzer returns the initialized Analyzer.
45+
func initAnalyzer(cfg *Config) (*analysis.Analyzer, error) {
46+
if cfg == nil {
47+
cfg = &Config{}
48+
}
49+
50+
return newAnalyzer(*cfg), nil
51+
}
52+
53+
// validateConfig validates the linter configuration.
54+
func validateConfig(cfg *Config, fldPath *field.Path) field.ErrorList {
55+
var errs field.ErrorList
56+
if cfg == nil {
57+
return errs
58+
}
59+
60+
rulesPath := fldPath.Child("rules")
61+
62+
if len(cfg.Rules) == 0 {
63+
errs = append(errs, field.Invalid(rulesPath, cfg.Rules, "rules cannot be empty"))
64+
}
65+
66+
for i, rule := range cfg.Rules {
67+
if rule.Identifier == "" {
68+
errs = append(errs, field.Invalid(rulesPath.Index(i).Child("identifier"), rule.Identifier, "identifier marker cannot be empty"))
69+
}
70+
71+
if len(rule.Dependents) == 0 {
72+
errs = append(errs, field.Invalid(rulesPath.Index(i).Child("dependents"), rule.Dependents, "dependents list cannot be empty"))
73+
}
74+
75+
if rule.Type == "" {
76+
errs = append(errs, field.Required(rulesPath.Index(i).Child("type"), "type must be explicitly set to 'all' or 'any'"))
77+
} else {
78+
switch rule.Type {
79+
case DependencyTypeAll, DependencyTypeAny:
80+
// valid
81+
default:
82+
errs = append(errs, field.Invalid(rulesPath.Index(i).Child("type"), rule.Type, "type must be 'all' or 'any'"))
83+
}
84+
}
85+
}
86+
87+
return errs
88+
}

0 commit comments

Comments
 (0)