-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Prefer default assignment over catch-all `else`. This rule was originally meant to endorse the new default function construct, but having tested it out thoroughly for this rule, I'm now more inclined to have it flagged. I find that counter-intuitive, and as such more likely to cause harm than good. Those who disagree may configure the `prefer-default-functions` option. Either way, default assignment over the equivalent `else` feels more idiomatic to me, so the rule feels relevant despite not doing what originally intended. Fixes #211 Signed-off-by: Anders Eknert <anders@styra.com>
- Loading branch information
1 parent
c937e34
commit 52c57d3
Showing
12 changed files
with
291 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
# METADATA | ||
# description: Prefer default assignment over fallback else | ||
package regal.rules.style["default-over-else"] | ||
|
||
import future.keywords.contains | ||
import future.keywords.if | ||
import future.keywords.in | ||
|
||
import data.regal.ast | ||
import data.regal.config | ||
import data.regal.result | ||
|
||
cfg := config.for_rule("style", "default-over-else") | ||
|
||
report contains violation if { | ||
some rule in considered_rules | ||
|
||
# walking is expensive but necessary here, since there could be | ||
# any number of `else` clauses nested below. no need to traverse | ||
# the rule if there isn't a single `else` present though! | ||
rule["else"] | ||
|
||
walk(rule, [_, value]) | ||
|
||
# quoting is needed as `else` is a keyword | ||
else_body := value["else"].body | ||
else_head := value["else"].head | ||
|
||
# we don't know for sure, but if all that's in the body is an empty | ||
# `true`, it's likely an implicit body (i.e. one not printed) | ||
count(else_body) == 1 | ||
else_body[0].terms.type == "boolean" | ||
else_body[0].terms.value == true | ||
|
||
ast.is_constant(else_head.value) | ||
|
||
violation := result.fail(rego.metadata.chain(), result.location(else_head)) | ||
} | ||
|
||
considered_rules := input.rules if cfg["prefer-default-functions"] == true | ||
|
||
considered_rules := [rule | some rule in input.rules; not rule.head.args] if not cfg["prefer-default-functions"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
package regal.rules.style["default-over-else_test"] | ||
|
||
import future.keywords.if | ||
import future.keywords.in | ||
|
||
import data.regal.ast | ||
import data.regal.config | ||
|
||
import data.regal.rules.style["default-over-else"] as rule | ||
|
||
test_fail_conditionless_else_simple_rule if { | ||
module := ast.policy(` | ||
x := 1 { | ||
input.x | ||
} else := 2 { | ||
input.y | ||
} else := 3 | ||
`) | ||
|
||
r := rule.report with input as module | ||
r == with_location({"col": 4, "file": "policy.rego", "row": 8, "text": "\t} else := 3"}) | ||
} | ||
|
||
test_fail_conditionless_else_object_assignment if { | ||
module := ast.policy(` | ||
x := {"foo": "bar"} { | ||
input.x | ||
} else := {"bar": "foo"} | ||
`) | ||
|
||
r := rule.report with input as module | ||
r == with_location({"col": 4, "file": "policy.rego", "row": 6, "text": "\t} else := {\"bar\": \"foo\"}"}) | ||
} | ||
|
||
test_success_conditionless_else_not_constant if { | ||
module := ast.policy(` | ||
y := input.y | ||
x := {"foo": "bar"} { | ||
input.x | ||
} else := {"bar": y} | ||
`) | ||
|
||
r := rule.report with input as module | ||
r == set() | ||
} | ||
|
||
test_success_conditionless_else_input_ref if { | ||
module := ast.policy(` | ||
x := {"foo": "bar"} { | ||
input.x | ||
} else := input.foo | ||
`) | ||
|
||
r := rule.report with input as module | ||
r == set() | ||
} | ||
|
||
test_success_conditionless_else_custom_function if { | ||
module := ast.policy(` | ||
x(y) := y { | ||
input.foo | ||
} else := 1 | ||
`) | ||
|
||
r := rule.report with input as module | ||
r == set() | ||
} | ||
|
||
test_fail_conditionless_else_custom_function_prefer_default_functions if { | ||
module := ast.policy(` | ||
x(y) := y { | ||
input.foo | ||
} else := 1 | ||
`) | ||
|
||
r := rule.report with input as module with config.for_rule as { | ||
"level": "error", | ||
"prefer-default-functions": true, | ||
} | ||
r == with_location({"col": 4, "file": "policy.rego", "row": 6, "text": "\t} else := 1"}) | ||
} | ||
|
||
test_success_conditionless_else_custom_function_not_constant if { | ||
module := ast.policy(` | ||
x(y) := y + 1 { | ||
input.foo | ||
} else := y | ||
`) | ||
|
||
r := rule.report with input as module with config.for_rule as { | ||
"level": "error", | ||
"prefer-default-functions": true, | ||
} | ||
r == set() | ||
} | ||
|
||
with_location(location) := {{ | ||
"category": "style", | ||
"description": "Prefer default assignment over fallback else", | ||
"level": "error", | ||
"location": location, | ||
"related_resources": [{ | ||
"description": "documentation", | ||
"ref": config.docs.resolve_url("$baseUrl/$category/default-over-else", "style"), | ||
}], | ||
"title": "default-over-else", | ||
}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
# default-over-else | ||
|
||
**Summary**: Prefer default assignment over fallback `else` | ||
|
||
**Category**: Style | ||
|
||
**Avoid** | ||
```rego | ||
package policy | ||
import future.keywords.if | ||
permisisions := ["read", "write"] if { | ||
input.user == "admin" | ||
} else := ["read"] | ||
``` | ||
|
||
**Prefer** | ||
```rego | ||
package policy | ||
import future.keywords.if | ||
default permisisions := ["read"] | ||
permisisions := ["read", "write"] if { | ||
input.user == "admin" | ||
} | ||
``` | ||
|
||
## Rationale | ||
|
||
The `else` keyword has a single purpose in Rego — to allow a policy author to control the order of evaluation. Whether | ||
several `else`-clauses are chained or not, it's common to use a last "fallback" `else` to cover all cases not covered by | ||
the conditions in the preceding `else`-bodies. A kind of "catch all", or "default" condition. This is useful, but Rego | ||
arguably provides a more idiomatic construct for default assignment: the | ||
[default keyword](https://www.openpolicyagent.org/docs/latest/policy-language/#default-keyword). | ||
|
||
While the end result is the same, default assignment has the benefit of more clearly — and **before** the conditional | ||
assignments — communicating what the *safe* option is. This is particularly important for | ||
[entrypoint](https://docs.styra.com/regal/rules/idiomatic/no-defined-entrypoint) rules, where the | ||
default value of a rule is a part of the rule's contract. | ||
|
||
## Exceptions | ||
|
||
OPA [v0.55.0](https://github.com/open-policy-agent/opa/releases/tag/v0.55.0) introduced support for the default keyword | ||
for custom functions. This means that `else` fallbacks in functions may now be rewritten to use default assignment too: | ||
|
||
```rego | ||
package policy | ||
first_name(full_name) := split(full_name, " ")[0] if { | ||
full_name != "" | ||
} else := "Unknown" | ||
``` | ||
|
||
Could now be written as: | ||
|
||
```rego | ||
package policy | ||
default first_name(_) := "Unknown" | ||
first_name(full_name) := split(full_name, " ")[0] if { | ||
full_name != "" | ||
} | ||
``` | ||
|
||
Default value assignment for functions however come with a big caveat — the default case will only be triggered if all | ||
arguments passed to the function evaluate to a *defined value*. Thus, calling the `first_name` function from our above | ||
example is **not** guaranteed to return a value of `"Unknown"`: | ||
|
||
```rego | ||
# undefined if `input.name` is undefined | ||
fname := first_name(input.name) | ||
``` | ||
|
||
Whether deemed acceptable or not, this differs enough from default assignment of rules to make this preference opt-in | ||
rather than opt-out. Use the `prefer-default-functions` configuration option to control whether `default` assignment | ||
should be preferred over `else` fallbacks also for custom functions. The default value (no pun intended!) of this config | ||
option is `false`. | ||
|
||
## Configuration Options | ||
|
||
This linter rule provides the following configuration options: | ||
|
||
```yaml | ||
rules: | ||
style: | ||
default-over-else: | ||
# one of "error", "warning", "ignore" | ||
level: error | ||
# whether to prefer default assignment over | ||
# `else` fallbacks for custom functions | ||
prefer-default-functions: false | ||
``` | ||
|
||
## Related Resources | ||
|
||
- OPA Docs: [Default Keyword](https://www.openpolicyagent.org/docs/latest/policy-language/#default-keyword) | ||
|
||
## Community | ||
|
||
If you think you've found a problem with this rule or its documentation, would like to suggest improvements, new rules, | ||
or just talk about Regal in general, please join us in the `#regal` channel in the Styra Community | ||
[Slack](https://communityinviter.com/apps/styracommunity/signup)! |
Oops, something went wrong.