diff --git a/bundle/regal/config/config.rego b/bundle/regal/config/config.rego index 7b8d0dcc..277e24fc 100644 --- a/bundle/regal/config/config.rego +++ b/bundle/regal/config/config.rego @@ -28,6 +28,10 @@ docs["resolve_url"](url, category) := replace( # description: the default configuration with user config merged on top (if provided) merged_config := data.internal.combined_config +# METADATA +# description: the config the user manually configured without defaults +user_config := data.internal.user_config + # ensure that config.rules can be referenced in tests even without mocking default rules := {} diff --git a/bundle/regal/main/main.rego b/bundle/regal/main/main.rego index f5defc97..8137397e 100644 --- a/bundle/regal/main/main.rego +++ b/bundle/regal/main/main.rego @@ -10,11 +10,20 @@ package regal.main import data.regal.ast import data.regal.config +import data.regal.notices import data.regal.util # METADATA # description: set of all notices returned from linter rules -lint.notices contains _grouped_notices[_][_][_] if "lint" in input.regal.operations +lint.notices contains notice if { + "lint" in input.regal.operations + + some category, title + _rules_to_run[category][title] + + rule_notices := notices.promoted_notices[category][title] + some notice in rule_notices +} # METADATA # description: map of all ignore directives encountered when linting @@ -57,13 +66,6 @@ _rules_to_run[category] contains title if { not config.excluded_file(category, title, relative_filename) } -_grouped_notices[category][title] contains notice if { - some category, title - _rules_to_run[category][title] - - some notice in data.regal.rules[category][title].notices -} - # METADATA # title: report # description: | @@ -94,7 +96,7 @@ report contains violation if { some category, title _rules_to_run[category][title] - count(object.get(_grouped_notices, [category, title], [])) == 0 + count(object.get(notices.promoted_notices, [category, title], [])) == 0 some violation in data.regal.rules[category][title].report diff --git a/bundle/regal/main/main_test.rego b/bundle/regal/main/main_test.rego index b28c7d7e..0f1e1eed 100644 --- a/bundle/regal/main/main_test.rego +++ b/bundle/regal/main/main_test.rego @@ -358,22 +358,6 @@ test_rules_to_run_not_excluded if { rules_to_run == {"testing": {"test"}} } -test_notices if { - notice := { - "category": "idiomatic", - "description": "here's a notice", - "level": "notice", - "title": "testme notice", - "severity": "none", - } - - notices := main.lint.notices with main._rules_to_run as {"idiomatic": {"testme"}} - with data.regal.rules.idiomatic.testme.notices as {notice} # regal ignore:unresolved-reference - with input.regal.operations as ["lint"] - - notices == {notice} -} - test_main_fail_when_input_not_object if { violation := { "category": "error", diff --git a/bundle/regal/notices/notices.rego b/bundle/regal/notices/notices.rego new file mode 100644 index 00000000..bc332432 --- /dev/null +++ b/bundle/regal/notices/notices.rego @@ -0,0 +1,46 @@ +# METADATA +# description: | +# package with functionality for post-processing notices +# to ensure they are presented correctly as errors when relevant. +package regal.notices + +import data.regal.config + +# METADATA +# scope: rule +# description: | +# promoted_notices maps notices from rules, potentially changing their severity +# based on user configuration +promoted_notices[category][title] contains original_notice if { + some category, title + notices := data.regal.rules[category][title].notices + + some original_notice in notices + + not config.user_config.rules[category][title] +} + +promoted_notices[category][title] contains notice if { + some category, title + notices := data.regal.rules[category][title].notices + + some notice in notices + + rule_config := config.user_config.rules[category][title] + object.get(rule_config, "level", "") == "ignore" +} + +promoted_notices[category][title] contains notice if { + some category, title + notices := data.regal.rules[category][title].notices + + some original_notice in notices + + rule_config := config.user_config.rules[category][title] + object.get(rule_config, "level", "") != "ignore" + + # Use configured level as severity, or default to "error" + new_severity := object.get(rule_config, "level", "error") + + notice := object.union(original_notice, {"severity": new_severity}) +} diff --git a/bundle/regal/notices/notices_test.rego b/bundle/regal/notices/notices_test.rego new file mode 100644 index 00000000..e473c47b --- /dev/null +++ b/bundle/regal/notices/notices_test.rego @@ -0,0 +1,72 @@ +package regal.notices_test + +import data.regal.notices + +test_promoted_notices_with_level_error if { + result := notices.promoted_notices with data.regal.rules.imports["use-rego-v1"].notices as {_example_notice} + with data.internal.user_config as {"rules": {"imports": {"use-rego-v1": {"level": "error"}}}} + + # With user config level set to error, the severity should be promoted to error + result.imports["use-rego-v1"] == {object.union(_example_notice, {"severity": "error"})} +} + +test_promoted_notices_with_level_ignore if { + result := notices.promoted_notices with data.regal.rules.imports["use-rego-v1"].notices as {_example_notice} + with data.internal.user_config as {"rules": {"imports": {"use-rego-v1": {"level": "ignore"}}}} + + # With user config level set to ignore, the severity should stay none + result.imports["use-rego-v1"] == {_example_notice} +} + +test_promoted_notices_with_level_warning if { + result := notices.promoted_notices with data.regal.rules.imports["use-rego-v1"].notices as {_example_notice} + with data.internal.user_config as {"rules": {"imports": {"use-rego-v1": {"level": "warning"}}}} + + # With user config level set to warning, the severity should be promoted to warning + result.imports["use-rego-v1"] == {object.union(_example_notice, {"severity": "warning"})} +} + +test_promoted_notices_configured_without_level if { + # Rule is configured but level field is not present + result := notices.promoted_notices with data.regal.rules.imports["use-rego-v1"].notices as {_example_notice} + with data.internal.user_config as {"rules": {"imports": {"use-rego-v1": {}}}} + + # When configured without level, should default to error + result.imports["use-rego-v1"] == {object.union(_example_notice, {"severity": "error"})} +} + +test_promoted_notices_mixed_configured_and_unconfigured if { + notice_configured_rule := { + "category": "imports", + "description": "Configured rule", + "level": "notice", + "title": "use-rego-v1", + "severity": "none", + } + + notice_unconfigured_rule := { + "category": "bugs", + "description": "Unconfigured rule", + "level": "notice", + "title": "deprecated-builtin", + "severity": "none", + } + + result := notices.promoted_notices with data.regal.rules.imports["use-rego-v1"].notices as {notice_configured_rule} + with data.regal.rules.bugs["deprecated-builtin"].notices as {notice_unconfigured_rule} + with data.internal.user_config as {"rules": {"imports": {"use-rego-v1": {"level": "error"}}}} + + # Configured rule should have severity promoted to error + result.imports["use-rego-v1"] == {object.union(notice_configured_rule, {"severity": "error"})} + + # Unconfigured rule should keep original severity: none + result.bugs["deprecated-builtin"] == {notice_unconfigured_rule} +} + +_example_notice := { + "category": "imports", + "description": "Test rule description", + "level": "notice", + "title": "use-rego-v1", + "severity": "none", +} diff --git a/internal/lsp/eval.go b/internal/lsp/eval.go index 37ad9107..dbdfa3b5 100644 --- a/internal/lsp/eval.go +++ b/internal/lsp/eval.go @@ -119,6 +119,11 @@ func prepareRegoArgs( evalConfig = *cfg } + userConfigMap := map[string]any{} + if cfg != nil { + userConfigMap = config.ToMap(*cfg) + } + internalBundle := &bundle.Bundle{ Manifest: bundle.Manifest{ Roots: &[]string{"internal"}, @@ -127,6 +132,7 @@ func prepareRegoArgs( Data: map[string]any{ "internal": map[string]any{ "combined_config": config.ToMap(evalConfig), + "user_config": userConfigMap, "capabilities": caps, }, }, diff --git a/internal/lsp/eval_test.go b/internal/lsp/eval_test.go index bb2d6ba5..528664ba 100644 --- a/internal/lsp/eval_test.go +++ b/internal/lsp/eval_test.go @@ -76,7 +76,7 @@ func TestEvalWorkspacePathInternalData(t *testing.T) { val := testutil.MustBe[[]any](t, res.Value) act := util.Sorted(testutil.Must(util.AnySliceTo[string](val))(t)) - if exp := []string{"capabilities", "combined_config"}; !slices.Equal(exp, act) { + if exp := []string{"capabilities", "combined_config", "user_config"}; !slices.Equal(exp, act) { t.Fatalf("expected %v, got %v", exp, act) } } diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 6ad960cf..26f1a34c 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -648,6 +648,11 @@ func (l Linter) createDataBundle(conf config.Config) *bundle.Bundle { "ignore_files": util.NilSliceToEmpty(l.ignoreFiles), } + userConfigMap := map[string]any{} + if l.userConfig != nil { + userConfigMap = config.ToMap(*l.userConfig) + } + return &bundle.Bundle{ Manifest: bundle.Manifest{ Roots: &[]string{"internal", "eval"}, @@ -659,6 +664,7 @@ func (l Linter) createDataBundle(conf config.Config) *bundle.Bundle { }, "internal": map[string]any{ "combined_config": config.ToMap(conf), + "user_config": userConfigMap, "capabilities": rio.ToMap(config.CapabilitiesForThisVersion()), "path_prefix": l.pathPrefix, },