diff --git a/README.md b/README.md index 0d428979..17dfb107 100644 --- a/README.md +++ b/README.md @@ -361,13 +361,34 @@ provided will be used to override the default configuration. ## Ignoring Rules -If some rule doesn't align with your team's preferences, don't worry! Regal is not meant to be the law, and some rules -may not make sense for your project, or parts of it. Regal provides several different ways to ignore rules, either -entirely, or with more granularity. +If one of Regal's rules doesn't align with your team's preferences, don't worry! Regal is not meant to be the law, +and some rules may not make sense for your project, or parts of it. +Regal provides several different methods to ignore rules with varying precedence. +The available methods are (ranked highest to lowest precedence): -### Ignoring a Rule Entirely +- [Inline Ignore Directives](#inline-ignore-directives) cannot be overridden by any other method. +- Enabling or Disabling Rules with CLI flags. + - Enabling or Disabling Rules with `--enable` and `--disable` CLI flags. + - Enabling or Disabling Rules with `--enable-category` and `--disable-category` CLI flags. + - Enabling or Disabling All Rules with `--enable-all` and `--disable-all` CLI flags. + - See [Ignoring Rules via CLI Flags](#ignoring-rules-via-cli-flags) for more details. +- [Ignoring a Rule In Config](#ignoring-a-rule-in-config) +- [Ignoring a Category In Config](#ignoring-a-category-in-config) +- [Ignoring All Rules In Config](#ignoring-all-rules-in-config) -If you want to ignore a rule entirely, set its level to `ignore` in the configuration file: +In summary, the CLI flags will override any configuration provided in the file, and inline ignore directives for a +specific line will override any other method. + +It's also possible to ignore messages on a per-file basis. The available methods are (ranked High to Lowest precedence): + +- Using the `--ignore-files` CLI flag. + See [Ignoring Rules via CLI Flags](#ignoring-rules-via-cli-flags). +- [Ignoring Files Globally](#ignoring-files-globally) or + [Ignoring a Rule in Some Files](#ignoring-a-rule-in-some-files). + +### Ignoring a Rule In Config + +If you want to ignore a rule, set its level to `ignore` in the configuration file: ```yaml rules: @@ -377,6 +398,34 @@ rules: level: ignore ``` +### Ignoring a Category In Config + +If you want to ignore a category of rules, set its default level to `ignore` in the configuration file: + +```yaml +rules: + style: + default: + level: ignore +``` + +### Ignoring All Rules In Config + +If you want to ignore all rules, set the default level to `ignore` in the configuration file: + +```yaml +rules: + default: + level: ignore + # then you can re-enable specific rules or categories + testing: + default: + level: error + style: + opa-fmt: + level: error +``` + **Tip**: providing a comment on ignored rules is a good way to communicate why the decision was made. ### Ignoring a Rule in Some Files diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 90141f1b..388a8f69 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -577,6 +577,80 @@ func TestMergeRuleConfigWithoutLevel(t *testing.T) { expectExitCode(t, err, 3, &stdout, &stderr) } +func TestConfigDefaultingWithDisableDirective(t *testing.T) { + t.Parallel() + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cwd := testutil.Must(os.Getwd())(t) + + err := regal(&stdout, &stderr)( + "lint", + "--disable-category=testing", + "--config-file", + cwd+filepath.FromSlash("/testdata/configs/defaulting.yaml"), + cwd+filepath.FromSlash("/testdata/defaulting"), + ) + + // ignored by flag ignore directive + if strings.Contains(stdout.String(), "print-or-trace-call") { + t.Errorf("expected stdout to not contain print-or-trace-call") + t.Log("stdout:\n", stdout.String()) + } + + // ignored by config + if strings.Contains(stdout.String(), "opa-fmt") { + t.Errorf("expected stdout to not contain print-or-trace-call") + t.Log("stdout:\n", stdout.String()) + } + + // this error should not be ignored + if !strings.Contains(stdout.String(), "top-level-iteration") { + t.Errorf("expected stdout to contain top-level-iteration") + t.Log("stdout:\n", stdout.String()) + } + + expectExitCode(t, err, 3, &stdout, &stderr) +} + +func TestConfigDefaultingWithEnableDirective(t *testing.T) { + t.Parallel() + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cwd := testutil.Must(os.Getwd())(t) + + err := regal(&stdout, &stderr)( + "lint", + "--enable-all", + "--config-file", + cwd+filepath.FromSlash("/testdata/configs/defaulting.yaml"), + cwd+filepath.FromSlash("/testdata/defaulting"), + ) + + // re-enabled by flag enable directive + if !strings.Contains(stdout.String(), "print-or-trace-call") { + t.Errorf("expected stdout to contain print-or-trace-call") + t.Log("stdout:\n", stdout.String()) + } + + // re-enabled by flag enable directive + if !strings.Contains(stdout.String(), "opa-fmt") { + t.Errorf("expected stdout to contain opa-fmt") + t.Log("stdout:\n", stdout.String()) + } + + // this error should not be ignored + if !strings.Contains(stdout.String(), "top-level-iteration") { + t.Errorf("expected stdout to contain top-level-iteration") + t.Log("stdout:\n", stdout.String()) + } + + expectExitCode(t, err, 3, &stdout, &stderr) +} + func TestLintWithCustomCapabilitiesAndUnmetRequirement(t *testing.T) { t.Parallel() diff --git a/e2e/testdata/configs/defaulting.yaml b/e2e/testdata/configs/defaulting.yaml new file mode 100644 index 00000000..a98245a9 --- /dev/null +++ b/e2e/testdata/configs/defaulting.yaml @@ -0,0 +1,11 @@ +rules: + default: + level: ignore + bugs: + default: + level: error + rule-shadows-builtin: + level: ignore + testing: + print-or-trace-call: + level: error diff --git a/e2e/testdata/defaulting/example.rego b/e2e/testdata/defaulting/example.rego new file mode 100644 index 00000000..da172354 --- /dev/null +++ b/e2e/testdata/defaulting/example.rego @@ -0,0 +1,9 @@ +package p +import rego.v1 +boo := input.hoo[_] + opa_fmt := "fail" +or := 1 + +allow if { + print("hello") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 28c2d724..64795fb8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,16 +15,51 @@ import ( rio "github.com/styrainc/regal/internal/io" ) -const capabilitiesEngineOPA = "opa" +const ( + capabilitiesEngineOPA = "opa" + keyIgnore = "ignore" + keyLevel = "level" +) type Config struct { Rules map[string]Category `json:"rules" yaml:"rules"` Ignore Ignore `json:"ignore,omitempty" yaml:"ignore,omitempty"` Capabilities *Capabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + + // Defaults state is loaded from configuration under rules and so is not (un)marshalled + // in the same way. + Defaults Defaults `json:"-" yaml:"-"` } type Category map[string]Rule +// Defaults is used to store information about global and category +// defaults for rules. +type Defaults struct { + Global Default + Categories map[string]Default +} + +// Default represents global or category settings for rules, +// currently only the level is supported. +type Default struct { + Level string `json:"level" yaml:"level"` +} + +func (d *Default) mapToConfig(result any) error { + resultMap, ok := result.(map[string]any) + if !ok { + return errors.New("result was not a map") + } + + level, ok := resultMap[keyLevel].(string) + if ok { + d.Level = level + } + + return nil +} + type Ignore struct { Files []string `json:"files,omitempty" yaml:"files,omitempty"` } @@ -128,32 +163,88 @@ func FromMap(confMap map[string]any) (Config, error) { return conf, nil } -func (config *Config) UnmarshalYAML(value *yaml.Node) error { - var result struct { - Rules map[string]Category `yaml:"rules"` - Ignore Ignore `yaml:"ignore"` - Capabilities struct { - From struct { - Engine string `yaml:"engine"` - Version string `yaml:"version"` - File string `yaml:"file"` - } `yaml:"from"` - Plus struct { - Builtins []*ast.Builtin `yaml:"builtins"` - } `yaml:"plus"` - Minus struct { - Builtins []struct { - Name string `yaml:"name"` - } `yaml:"builtins"` - } `yaml:"minus"` - } `yaml:"capabilities"` +func (config Config) MarshalYAML() (any, error) { + var unstructuredConfig map[string]any + + err := rio.JSONRoundTrip(config, &unstructuredConfig) + if err != nil { + return nil, fmt.Errorf("failed to created unstructured config: %w", err) + } + + // place the global defaults at the top level under rules + if config.Defaults.Global.Level != "" { + r, ok := unstructuredConfig["rules"].(map[string]any) + if !ok { + return nil, errors.New("rules in config were not a map") + } + + r["default"] = config.Defaults.Global } + // place the category defaults under the respective category + for categoryName, categoryDefault := range config.Defaults.Categories { + rawRuleMap, ok := unstructuredConfig["rules"].(map[string]any) + if !ok { + return nil, errors.New("rules in config were not a map") + } + + rawCategoryMap, ok := rawRuleMap[categoryName].(map[string]any) + if !ok { + return nil, fmt.Errorf("category %s was not a map", categoryName) + } + + rawCategoryMap["default"] = categoryDefault + } + + if len(config.Ignore.Files) == 0 { + delete(unstructuredConfig, keyIgnore) + } + + return unstructuredConfig, nil +} + +// unmarshallingIntermediary is used to contain config data in a format that is used during unmarshalling. +// The internally loaded config data layout differs from the user-defined YAML. +type marshallingIntermediary struct { + // rules are unmarshalled as any since the defaulting needs to be extracted from here + // and configured elsewhere in the struct. + Rules map[string]any `yaml:"rules"` + Ignore Ignore `yaml:"ignore"` + Capabilities struct { + From struct { + Engine string `yaml:"engine"` + Version string `yaml:"version"` + File string `yaml:"file"` + } `yaml:"from"` + Plus struct { + Builtins []*ast.Builtin `yaml:"builtins"` + } `yaml:"plus"` + Minus struct { + Builtins []struct { + Name string `yaml:"name"` + } `yaml:"builtins"` + } `yaml:"minus"` + } `yaml:"capabilities"` +} + +func (config *Config) UnmarshalYAML(value *yaml.Node) error { + var result marshallingIntermediary + if err := value.Decode(&result); err != nil { return fmt.Errorf("unmarshalling config failed %w", err) } - config.Rules = result.Rules + // this call will walk the rule config and load and defaults into the config + err := extractDefaults(config, &result) + if err != nil { + return fmt.Errorf("extracting defaults failed: %w", err) + } + + err = extractRules(config, &result) + if err != nil { + return fmt.Errorf("extracting rules failed: %w", err) + } + config.Ignore = result.Ignore capabilitiesFile := result.Capabilities.From.File @@ -211,6 +302,81 @@ func (config *Config) UnmarshalYAML(value *yaml.Node) error { return nil } +// extractRules is a helper to load rules from the raw config data. +func extractRules(config *Config, result *marshallingIntermediary) error { + // in order to support wildcard 'default' configs, we + // have some hooks in this unmarshalling process to load these. + categoryMap := make(map[string]Category) + + for key, val := range result.Rules { + if key == "default" { + continue + } + + rawRuleMap, ok := val.(map[string]any) + if !ok { + return fmt.Errorf("rules for category %s were not a map", key) + } + + ruleMap := make(map[string]Rule) + + for ruleName, ruleData := range rawRuleMap { + if ruleName == "default" { + continue + } + + var r Rule + + err := r.mapToConfig(ruleData) + if err != nil { + return fmt.Errorf("unmarshalling rule failed: %w", err) + } + + ruleMap[ruleName] = r + } + + categoryMap[key] = ruleMap + } + + config.Rules = categoryMap + + return nil +} + +// extractDefaults is a helper to load both global and category defaults from the raw config data. +func extractDefaults(c *Config, result *marshallingIntermediary) error { + c.Defaults.Categories = make(map[string]Default) + + rawGlobalDefault, ok := result.Rules["default"] + if ok { + err := c.Defaults.Global.mapToConfig(rawGlobalDefault) + if err != nil { + return fmt.Errorf("unmarshalling global defaults failed: %w", err) + } + } + + for key, val := range result.Rules { + rawRuleMap, ok := val.(map[string]any) + if !ok { + return fmt.Errorf("rules for category %s were not a map", key) + } + + rawCategoryDefault, ok := rawRuleMap["default"] + if ok { + var categoryDefault Default + + err := categoryDefault.mapToConfig(rawCategoryDefault) + if err != nil { + return fmt.Errorf("unmarshalling category defaults failed: %w", err) + } + + c.Defaults.Categories[key] = categoryDefault + } + } + + return nil +} + // CapabilitiesForThisVersion returns the capabilities for the current OPA version Regal depends on. func CapabilitiesForThisVersion() *Capabilities { return fromOPACapabilities(*ast.CapabilitiesForThisVersion()) @@ -270,12 +436,9 @@ func ToMap(config Config) map[string]any { } func (rule Rule) MarshalJSON() ([]byte, error) { - result := make(map[string]any) - result["level"] = rule.Level - result["ignore"] = rule.Ignore - - for key, val := range rule.Extra { - result[key] = val + result, err := rule.MarshalYAML() + if err != nil { + return nil, fmt.Errorf("marshalling rule failed %w", err) } return json.Marshal(&result) //nolint:wrapcheck @@ -292,14 +455,14 @@ func (rule *Rule) UnmarshalJSON(data []byte) error { func (rule Rule) MarshalYAML() (interface{}, error) { result := make(map[string]any) - result["level"] = rule.Level + result[keyLevel] = rule.Level if rule.Ignore != nil && len(rule.Ignore.Files) != 0 { - result["ignore"] = rule.Ignore + result[keyIgnore] = rule.Ignore } for key, val := range rule.Extra { - if key != "ignore" && key != "level" { + if key != keyIgnore && key != keyLevel { result[key] = val } } @@ -319,13 +482,18 @@ func (rule *Rule) UnmarshalYAML(value *yaml.Node) error { // Note that this function will mutate the result map. This isn't a problem right now // as we only use this after unmarshalling, but if we use this for other purposes later // we need to make a copy of the map first. -func (rule *Rule) mapToConfig(result map[string]any) error { - level, ok := result["level"].(string) +func (rule *Rule) mapToConfig(result any) error { + ruleMap, ok := result.(map[string]any) + if !ok { + return errors.New("result was not a map") + } + + level, ok := ruleMap[keyLevel].(string) if ok { rule.Level = level } - if ignore, ok := result["ignore"]; ok { + if ignore, ok := ruleMap[keyIgnore]; ok { var dst Ignore err := rio.JSONRoundTrip(ignore, &dst) @@ -336,10 +504,10 @@ func (rule *Rule) mapToConfig(result map[string]any) error { rule.Ignore = &dst } - rule.Extra = result + rule.Extra = ruleMap - delete(rule.Extra, "level") - delete(rule.Extra, "ignore") + delete(rule.Extra, keyLevel) + delete(rule.Extra, keyIgnore) return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 628427d6..6b63d627 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -14,6 +14,8 @@ import ( "github.com/styrainc/regal/internal/testutil" ) +const levelError = "error" + func TestFindRegalDirectory(t *testing.T) { t.Parallel() @@ -83,6 +85,10 @@ func TestMarshalConfig(t *testing.T) { t.Parallel() conf := Config{ + // ignore is empty and so should not be marshalled + Ignore: Ignore{ + Files: []string{}, + }, Rules: map[string]Category{ "testing": { "foo": Rule{ @@ -112,7 +118,64 @@ func TestMarshalConfig(t *testing.T) { ` if string(bs) != expect { - t.Errorf("expected %s, got %s", expect, string(bs)) + t.Errorf("expected:\n%sgot:\n%s", expect, string(bs)) + } +} + +func TestUnmarshalMarshalConfigWithDefaultRuleConfigs(t *testing.T) { + t.Parallel() + + bs := []byte(` +rules: + default: + level: ignore + bugs: + default: + level: error + constant-condition: + level: ignore + testing: + print-or-trace-call: + level: error +`) + + var originalConfig Config + + if err := yaml.Unmarshal(bs, &originalConfig); err != nil { + t.Fatal(err) + } + + if originalConfig.Defaults.Global.Level != "ignore" { + t.Errorf("expected global default to be level ignore") + } + + if _, unexpected := originalConfig.Rules["bugs"]["default"]; unexpected { + t.Errorf("erroneous rule parsed, bugs.default should not exist") + } + + if originalConfig.Defaults.Categories["bugs"].Level != levelError { + t.Errorf("expected bugs default to be level error") + } + + if originalConfig.Rules["testing"]["print-or-trace-call"].Level != levelError { + t.Errorf("expected for testing.print-or-trace-call to be level error") + } + + originalConfig.Capabilities = nil + + marshalledConfigBs := testutil.Must(yaml.Marshal(originalConfig))(t) + + var roundTrippedConfig Config + if err := yaml.Unmarshal(marshalledConfigBs, &roundTrippedConfig); err != nil { + t.Fatal(err) + } + + if roundTrippedConfig.Defaults.Global.Level != "ignore" { + t.Errorf("expected global default to be level ignore") + } + + if roundTrippedConfig.Defaults.Categories["bugs"].Level != levelError { + t.Errorf("expected bugs default to be level error") } } diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index c5e74f08..4e85b50f 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -817,25 +817,17 @@ func (l Linter) mergedConfig() (config.Config, error) { return config.Config{}, fmt.Errorf("failed to read provided config: %w", err) } - ruleLevels := providedConfLevels(mergedConf) + providedRuleLevels := providedConfLevels(mergedConf) if l.userConfig != nil { err = mergo.Merge(&mergedConf, l.userConfig, mergo.WithOverride) if err != nil { return config.Config{}, fmt.Errorf("failed to merge config: %w", err) } - } - // If the user configuration contains rules with the level unset, copy the level from the provided configuration - for categoryName, rulesByCategory := range mergedConf.Rules { - for ruleName, rule := range rulesByCategory { - if rule.Level == "" { - if providedLevel, ok := ruleLevels[ruleName]; ok { - rule.Level = providedLevel - mergedConf.Rules[categoryName][ruleName] = rule - } - } - } + // adopt user rule levels based on config and defaults + // If the user configuration contains rules with the level unset, copy the level from the provided configuration + extractUserRuleLevels(l.userConfig, &mergedConf, providedRuleLevels) } if mergedConf.Capabilities == nil { @@ -855,6 +847,47 @@ func (l Linter) mergedConfig() (config.Config, error) { return mergedConf, nil } +// extractUserRuleLevels uses defaulting config and per-rule levels from user configuration to set the level for each +// rule. +func extractUserRuleLevels(userConfig *config.Config, mergedConf *config.Config, providedRuleLevels map[string]string) { + for categoryName, rulesByCategory := range mergedConf.Rules { + for ruleName, rule := range rulesByCategory { + var providedLevel string + + var ok bool + + if providedLevel, ok = providedRuleLevels[ruleName]; !ok { + continue + } + + // use the level from the provided configuration as the fallback + selectedRuleLevel := providedLevel + + var userHasConfiguredRule bool + + if _, ok := userConfig.Rules[categoryName]; ok { + _, userHasConfiguredRule = userConfig.Rules[categoryName][ruleName] + } + + if userHasConfiguredRule && userConfig.Rules[categoryName][ruleName].Level != "" { + // if the user config has a level for the rule, use that + selectedRuleLevel = userConfig.Rules[categoryName][ruleName].Level + } else if categoryDefault, ok := mergedConf.Defaults.Categories[categoryName]; ok { + // if the config has a default level for the category, use that + if categoryDefault.Level != "" { + selectedRuleLevel = categoryDefault.Level + } + } else if mergedConf.Defaults.Global.Level != "" { + // if the config has a global default level, use that + selectedRuleLevel = mergedConf.Defaults.Global.Level + } + + rule.Level = selectedRuleLevel + mergedConf.Rules[categoryName][ruleName] = rule + } + } +} + // Copy the level of each rule from the provided configuration. func providedConfLevels(conf config.Config) map[string]string { ruleLevels := make(map[string]string) diff --git a/pkg/linter/linter_test.go b/pkg/linter/linter_test.go index fc51a1cf..591a119a 100644 --- a/pkg/linter/linter_test.go +++ b/pkg/linter/linter_test.go @@ -122,6 +122,7 @@ or := 1 userConfig *config.Config filename string expViolations []string + expLevels []string ignoreFilesFlag []string }{ { @@ -138,6 +139,69 @@ or := 1 filename: "p.rego", expViolations: []string{"top-level-iteration"}, }, + { + name: "ignore all", + userConfig: &config.Config{ + Defaults: config.Defaults{ + Global: config.Default{ + Level: "ignore", + }, + }, + }, + filename: "p.rego", + expViolations: []string{}, + }, + { + name: "ignore all but bugs", + userConfig: &config.Config{ + Defaults: config.Defaults{ + Global: config.Default{ + Level: "ignore", + }, + Categories: map[string]config.Default{ + "bugs": {Level: "error"}, + }, + }, + Rules: map[string]config.Category{ + "bugs": {"rule-shadows-builtin": config.Rule{Level: "ignore"}}, + }, + }, + filename: "p.rego", + expViolations: []string{"top-level-iteration"}, + }, + { + name: "ignore style, no global default", + userConfig: &config.Config{ + Defaults: config.Defaults{ + Categories: map[string]config.Default{ + "bugs": {Level: "error"}, + "style": {Level: "ignore"}, + }, + }, + Rules: map[string]config.Category{ + "bugs": {"rule-shadows-builtin": config.Rule{Level: "ignore"}}, + }, + }, + filename: "p.rego", + expViolations: []string{"top-level-iteration"}, + }, + { + name: "set level to warning", + userConfig: &config.Config{ + Defaults: config.Defaults{ + Global: config.Default{ + Level: "warning", // will apply to all but style + }, + Categories: map[string]config.Default{ + "style": {Level: "error"}, + }, + }, + Rules: map[string]config.Category{}, + }, + filename: "p.rego", + expViolations: []string{"opa-fmt", "top-level-iteration", "rule-shadows-builtin"}, + expLevels: []string{"error", "warning", "warning"}, + }, { name: "rule level ignore files", userConfig: &config.Config{Rules: map[string]config.Category{ @@ -209,6 +273,14 @@ or := 1 t.Errorf("expected first violation to be '%s', got %s", tt.expViolations[idx], result.Violations[0].Title) } } + + if len(tt.expLevels) > 0 { + for idx, violation := range result.Violations { + if violation.Level != tt.expLevels[idx] { + t.Errorf("expected first violation to be '%s', got %s", tt.expLevels[idx], result.Violations[0].Level) + } + } + } }) } } @@ -357,6 +429,53 @@ func TestLintMergedConfigInheritsLevelFromProvided(t *testing.T) { } } +func TestLintMergedConfigUsesProvidedDefaults(t *testing.T) { + t.Parallel() + + userConfig := config.Config{ + Defaults: config.Defaults{ + Global: config.Default{ + Level: "ignore", + }, + Categories: map[string]config.Default{ + "style": {Level: "error"}, + "bugs": {Level: "warning"}, + }, + }, + Rules: map[string]config.Category{ + "style": {"opa-fmt": config.Rule{Level: "warning"}}, + }, + } + + input := test.InputPolicy("p.rego", `package p`) + + linter := NewLinter(). + WithUserConfig(userConfig). + WithInputModules(&input) + + mergedConfig := testutil.Must(linter.mergedConfig())(t) + + // specifically configured rule should not be affected by the default + if mergedConfig.Rules["style"]["opa-fmt"].Level != "warning" { + t.Errorf("expected level to be 'warning', got %q", mergedConfig.Rules["style"]["opa-fmt"].Level) + } + + // other rule in style should have the default level for the category + if mergedConfig.Rules["style"]["chained-rule-body"].Level != "error" { + t.Errorf("expected level to be 'error', got %q", mergedConfig.Rules["style"]["chained-rule-body"].Level) + } + + // rule in bugs should have the default level for the category + if mergedConfig.Rules["bugs"]["constant-condition"].Level != "warning" { + t.Errorf("expected level to be 'warning', got %q", mergedConfig.Rules["bugs"]["constant-condition"].Level) + } + + // rule in unconfigured category should have the global default level + if mergedConfig.Rules["imports"]["avoid-importing-input"].Level != "ignore" { + t.Errorf("expected level to be 'ignore', got %q", mergedConfig.Rules["imports"]["avoid-importing-input"].Level) + } +} + func TestLintWithPrintHook(t *testing.T) { t.Parallel()