From 9350236ddd11a2a6808c52ad5d2ea3fb8834cd55 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Sat, 17 Oct 2026 23:46:54 +0200 Subject: [PATCH] core: call the word after as an alias, in messages, the spec and the docs An alias is a word that stands for something longer: the word after `as` on a `for` line, the `each_as` or `as` name in the old syntax and the `$name` that reads it, and a bare word after `named by`. Requirement, check and subject names stay names. The messages, the spec rule `sentence.for.alias`, the ABNF rule `alias` and the docs now use the word. A `$word` in a sentence isn't an alias, so `unknown name $foo` stays as it was. Changes the report: `invalid name` is now `invalid alias`, `name given twice` is `alias given twice`, `unknown name ` is `unknown alias ` for an old-syntax `$name` or a bare word after `named by`, `` is `x can't be an alias`, and the expression text `` and `` are `` and ``. Co-Authored-By: Claude Sonnet 5.5 --- REFERENCE.md | 30 ++++++++-------- ergo.rego | 20 +++++------ ergo_test.rego | 67 ++++++++++++++++++++++------------- spec/cases/any_of/cases.json | 2 +- spec/cases/derived/cases.json | 18 +++++----- spec/cases/for/cases.json | 22 ++++++------ spec/cases/lists/cases.json | 16 ++++----- spec/check_grammar.py | 3 +- spec/semantics.md | 18 +++++----- spec/sentences.abnf | 8 ++--- spec/sentences.md | 20 +++++------ spec/syntax.md | 6 ++-- 12 files changed, 126 insertions(+), 104 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index a57a4f2..50d2461 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -658,7 +658,9 @@ The check's `expression` is `approved_by is not empty and reviewed_at is before - `every` and `some` paths can be ten steps long. - `contains` and the other list phrases can't follow `every` or `some`, because the last list is walked too, so `every pull_requests.labels does not contain "hotfix"` would ask each label. It's written wrong, and the message says to put the walk on a `for` line. -A `for` line names the items of one or two lists, so the sentences under `assert` can read an item and the subject together. Each item is `every` or `some`, a path, an optional `whose`, and `as` a name. The second item can walk a list inside the first, by its name. With the input above: +A `for` line names the items of one or two lists, so the sentences under `assert` can read an item and the subject together. Each item is `every` or `some`, a path, an optional `whose`, and `as` an alias. The second item can walk a list inside the first, by its alias. + +An alias is a short word that stands for something longer: `as pr` makes `pr` an alias for each pull request in turn. With the input above: ```yaml peer_approved: @@ -706,7 +708,7 @@ reads "for every pull request, some approver approved it and isn't its author". - In `assert`, a bare first key is the subject's field, and a name reaches its item. Inside an item's own `whose`, a bare key is that item's field. A name hides a field of the subject with the same name. - Each list fails as a one-line `every` or `some` does: empty, missing, `null` or not a list, and `, if any,` lets an empty one pass. - Two items at most. -- `named by` a name from the `for` line reads the key its item holds. With `for: every personas_dispatched as persona`, `persona_blob_shas named by persona exists` reads `persona_blob_shas.security`, then `persona_blob_shas.style`, and so on. An item that's `null` fails as `null`, and any other item that isn't a string, a number included, as `unusable`, since it can't be a key. The row's inputs show `persona_blob_shas` itself. +- `named by` an alias from the `for` line reads the key its item holds. With `for: every personas_dispatched as persona`, `persona_blob_shas named by persona exists` reads `persona_blob_shas.security`, then `persona_blob_shas.style`, and so on. An item that's `null` fails as `null`, and any other item that isn't a string, a number included, as `unusable`, since it can't be a key. The row's inputs show `persona_blob_shas` itself. `count of`, `sum of` and `plus` work out a number to compare, as the [derived values](#derived-values) of `compare` do. With the input of [derived values](#derived-values): @@ -1016,7 +1018,7 @@ The fields of the requirement and its subject get an input of the same form when | `subjects.deployment` | `not an object` | | `subjects.deployment.applies_to` | `not an object`, `holds a key that isn't a string` | | `subjects.deployment.from`, `...identified_by` | `not a list`, `step that can't be a key`, `number out of range`, `holds a set`, `holds a key that isn't a string` | -| `subjects.deployment.from` | `object step before the last`, `object step without each_as`, `invalid name`, `invalid keys`, `unknown field foo in naming step` | +| `subjects.deployment.from` | `object step before the last`, `object step without each_as`, `invalid alias`, `invalid keys`, `unknown field foo in naming step` | | `subjects.deployment.identified_by` | `ref inside where`, `literal inside where` | | `subjects.deployment.description` | `not a string` | | `subjects.deployment.of` | `empty or not a string`, `not in subjects`, `leads to a loop` | @@ -1136,7 +1138,7 @@ ergo fails a check whenever it can't be sure, instead of letting it pass. Rego d - A missing, `null` or wrong-typed field fails every operator, except `missing`, which passes on a missing or `null` field. - `compare` needs both sides to exist and have the same type. In plain Rego, `null < 5` is true, so a missing field would otherwise pass a `lt` check. Ordering objects, lists or booleans fails too. - `every`, `some` and `for` lines need non-empty lists, inner lists included. A policy that's fine with no items says so with `, if any,`. -- A name given twice, or badly written, fails the check, so an inner name can't quietly hide an outer one. +- An alias given twice, or badly written, fails the check, so an inner alias can't quietly hide an outer one. - A check that's written wrong, like an unknown `op` or `cmp`, fails `$well_formed`, and its rows fail with cause `ill_formed`, so a mistake in a subject's conditions can't rule every subject out. - A value of the wrong kind, like a string where `range` needs a number, or a param of the wrong type, fails with cause `unusable`, so a condition can't rule a subject out on it. - With several conditions, one that can't be read fails the requirement, even when another rules the subject out. The only condition that can rule a subject out regardless is an `exists` that found its field missing. @@ -1257,9 +1259,9 @@ A path is a list of keys that ergo follows one step at a time. `["release", "app A key is a string, or a list index written as digits alone, like `0` or `12`. A string only picks a key of an object and a number only picks an item of a list, so `["a", "0"]` reads nothing when `a` is a list, and `["o", 0]` reads nothing when `o` is `{"0": "zero"}`. JavaScript reads both, so an implementation there has to check the type. A path that runs into something that can't hold its next key, like these, or a string, number or boolean where an object or list should be, fails its check with cause `unusable`, so `["pr", "state"]` on `"pr": "none"` isn't reported as a missing field. A parent that isn't there, or is `null`, is different: the field is just missing. A step that can't be a key, like `true`, `null`, a list, `-1`, `1.5`, or even `1.0`, which OPA doesn't read as `1` but JavaScript does, can never read anything. So a check with one is written wrong: it fails `$well_formed`, its rows fail with cause `ill_formed`, even as a `present` filter, and the step is shown as ``. The same goes for the paths in a check's `inputs`. One in `from` or `id` fails `$well_formed`. -In expressions, `inputs`, `$refs` and `failed_items`, a path is named the way a [sentence](#checks) writes it. A name made of keys, positions and `named by` one key can be pasted into a sentence. Names from `each_as` or `as`, like `$pr.author`, selectors, `[]` lists and refs of more than one key can't, because sentences don't have those yet. A key is written as it is when it starts with an ASCII letter (`a` to `z` or `A` to `Z`) or `_`, and the rest is ASCII letters, digits, `_` and `-`. Any other key is quoted, so a key with a dot, a space, a `$` or an accented letter, one that starts with a digit or `-`, and the empty key are all written in quotes. So `["metadata", "labels", "app.kubernetes.io/name"]` is named `metadata.labels."app.kubernetes.io/name"`, and doesn't look like a path four keys deep, and `["x", "$b"]` is named `x."$b"`. A first key that's a word the sentences use, like `empty` or `a`, gets a dot in front: `.empty`. A path into the params or the input starts with `$params` or `$input`, like `$params.allowed`. A list index at the end of a path is written `item of`, counting from 1, and one with more after it in brackets, counting from 0: `["xs", 1]` is `item 2 of xs`, `["xs", 1, "k"]` is `xs[1].k`, and the string key `["xs", "0"]` is `xs."0"`. A first step written as `{"literal": "$schema"}` is named `"$schema"`, so it doesn't look like a [name](#naming-subjects). `from` takes no names and no `$$input`, so there a first key that starts with `$` is always quoted: `["$$input"]` is named `"$$input"`, and can't be mistaken for the whole input. +In expressions, `inputs`, `$refs` and `failed_items`, a path is named the way a [sentence](#checks) writes it. A name made of keys, positions and `named by` one key can be pasted into a sentence. Aliases from `each_as` or `as`, like `$pr.author`, selectors, `[]` lists and refs of more than one key can't, because sentences don't have those yet. A key is written as it is when it starts with an ASCII letter (`a` to `z` or `A` to `Z`) or `_`, and the rest is ASCII letters, digits, `_` and `-`. Any other key is quoted, so a key with a dot, a space, a `$` or an accented letter, one that starts with a digit or `-`, and the empty key are all written in quotes. So `["metadata", "labels", "app.kubernetes.io/name"]` is named `metadata.labels."app.kubernetes.io/name"`, and doesn't look like a path four keys deep, and `["x", "$b"]` is named `x."$b"`. A first key that's a word the sentences use, like `empty` or `a`, gets a dot in front: `.empty`. A path into the params or the input starts with `$params` or `$input`, like `$params.allowed`. A list index at the end of a path is written `item of`, counting from 1, and one with more after it in brackets, counting from 0: `["xs", 1]` is `item 2 of xs`, `["xs", 1, "k"]` is `xs[1].k`, and the string key `["xs", "0"]` is `xs."0"`. A first step written as `{"literal": "$schema"}` is named `"$schema"`, so it doesn't look like an [alias](#naming-subjects). `from` takes no names and no `$$input`, so there a first key that starts with `$` is always quoted: `["$$input"]` is named `"$$input"`, and can't be mistaken for the whole input. -`from` is a path into the input. Every other path (`identified_by`, a check's `path`, `left` and `right`) is a path into one subject, unless it starts with [`$$input`](#reading-from-the-input) or a [name](#naming-subjects). +`from` is a path into the input. Every other path (`identified_by`, a check's `path`, `left` and `right`) is a path into one subject, unless it starts with [`$$input`](#reading-from-the-input) or an [alias](#naming-subjects). An empty path, `[]`, reads the item itself. Use it inside `all` or `any` when the list holds plain values like strings, not objects: @@ -1372,7 +1374,7 @@ A ref step works anywhere in any path: first, in the middle or last, after a nam ### Naming subjects -`from` can end with a step that names each subject: `{"each_as": "name"}`. A path that starts with `$name` then reads that subject. +`from` can end with a step that names each subject: `{"each_as": "name"}`, where `name` is an alias for the subject. A path that starts with `$name` then reads that subject. If `from` leads to an object, the step makes every entry a subject, identified by its key and sorted by key. Without it, the whole object is one subject. If `from` leads to a list, every item is a subject, as without the step. @@ -1427,7 +1429,7 @@ Each key is a subject. A key the object doesn't have is still a subject, so its ] ``` -The name matters inside `all` and `any`, where paths start at each item of the list. `$name` reaches back to the subject, so an item can be compared with it. `as` names the items of a list the same way (see [Nesting](#nesting)): +The alias matters inside `all` and `any`, where paths start at each item of the list. `$name` reaches back to the subject, so an item can be compared with it. `as` gives the items of a list an alias the same way (see [Nesting](#nesting)): ```rego "subjects": {"pull request": { @@ -1512,7 +1514,7 @@ Some things worth knowing: - `values` that isn't a list, an empty `values` for `includes` or `excludes`, both `value` and `values`, a `min` or `max` that isn't a number, a `min` above `max`, or `patterns` that isn't a list of valid regular expressions - a `path`, an `each` or, on other operators than `compare` and `compare_time`, a `left` or `right` that isn't a list, even a string like `"state"` that names one key, or `as` or `each` on an operator other than `all` or `any`. The `$well_formed` row says `path not a list` (or `each not a list`), and the check's row has no `inputs` - a step that can't be a [key](#paths), a number out of range, a badly written [ref](#reading-from-the-input), a ref or `literal` deeper inside a value than ergo reads, or a path that starts with a [name](#naming-subjects) nothing gave - - an `all` or `any` [nested](#nesting) too deep, or a name given twice or badly written + - an `all` or `any` [nested](#nesting) too deep, or an alias given twice or badly written - a check that isn't an object, or one where it can't go, like a custom operator inside `all` - a field its op doesn't use, like `valeu` or `descripton`. Besides its own parameters, any check can have `description`, `meta`, `expression`, `substitute` and `inputs`. A [custom operator](#custom-operators) can have any fields. - a set, or an object key that isn't a string, anywhere in the check or in a selector in `from` or `identified_by`. Rego can write both, but JSON can't hold either. The `$well_formed` row shows `holds a set` or `holds a key that isn't a string`. The expression writes such a key as `""`, so `"value": {10: "a", "10": "b"}` on `x` shows as `x == {"10": "b", "": "a"}`, the same in every runtime. @@ -1605,7 +1607,7 @@ Some things worth knowing: - An item that `where` can't decide about doesn't just go uncounted. If the second finding above had no `source`, ergo couldn't tell whether it came from Claude, so the check would fail as `absent`, with `count(findings where source == "claude") = null`. A `present` condition is the exception, as everywhere: a missing field is its answer. - `where` is one [basic operator](#basic-operators), without a substitute. Its paths start at each item, like the inner check of `all`. It can't be `all`, `any` or `any_of`, and a check inside it that's written wrong makes the whole check written wrong. - Derived values only go in a check of their own, a filter or a substitute, not inside `all`, `any` or `any_of`. A `literal` side can go anywhere a `compare` can. -- Paths in a derived value read like any other: they can start with `$$input`, `$$params` or a [name](#naming-subjects). +- Paths in a derived value read like any other: they can start with `$$input`, `$$params` or an [alias](#naming-subjects). #### `all` and `any` @@ -1657,7 +1659,7 @@ The inner `check` can be a basic operator, an `any_of`, or another `all` or `any ##### Nesting -An `all` or `any` inside another one checks a list for each item of the outer list. `as` names the outer item, so the inner check can still reach it once its paths start somewhere else. Say a pull request needs an approval given after its last commit: +An `all` or `any` inside another one checks a list for each item of the outer list. `as` gives the outer item an alias, so the inner check can still reach it once its paths start somewhere else. Say a pull request needs an approval given after its last commit: ```json { @@ -1734,9 +1736,9 @@ It renders as `some approvers as $approver: one of: peer(state == "APPROVED" and Some things worth knowing: -- `as` takes the same names as a [naming step](#naming-subjects): a string that doesn't start with `$` and isn't `params` or `input`. Without `each`, `$approver` reads the same as a path inside the item, so `as` only matters for a check nested inside. With `each`, it names the inner item. -- A name can only be given once along a chain of checks. `as` with a name that `from` or an outer check already gave is written wrong: it fails `$well_formed`, the check fails with cause `ill_formed`, and it shows as ``. A badly written name fails the same way and shows as ``. The cause isn't `value`, so a filter written like this fails the requirement rather than ruling every subject out. Two separate checks can use the same name. -- A name given by `as` belongs to one item, so the row doesn't show it in its `inputs`. It still decides that item's cause, so an approver with no `timestamp` makes the check fail as `absent`. Paths that start with it are shown as paths inside the item, like `approvers[].timestamp`. That only holds inside the list check that gives the name. Anywhere else, like a neighbouring `any_of` option, nothing gives it, so reading it there is written wrong and fails as `ill_formed`. +- `as` takes the same aliases as a [naming step](#naming-subjects): a string that doesn't start with `$` and isn't `params` or `input`. Without `each`, `$approver` reads the same as a path inside the item, so `as` only matters for a check nested inside. With `each`, it names the inner item. +- An alias can only be given once along a chain of checks. `as` with an alias that `from` or an outer check already gave is written wrong: it fails `$well_formed`, the check fails with cause `ill_formed`, and it shows as ``. A badly written alias fails the same way and shows as ``. The cause isn't `value`, so a filter written like this fails the requirement rather than ruling every subject out. Two separate checks can use the same alias. +- An alias given by `as` belongs to one item, so the row doesn't show it in its `inputs`. It still decides that item's cause, so an approver with no `timestamp` makes the check fail as `absent`. Paths that start with it are shown as paths inside the item, like `approvers[].timestamp`. That only holds inside the list check that gives the alias. Anywhere else, like a neighbouring `any_of` option, nothing gives it, so reading it there is written wrong and fails as `ill_formed`. - Inner lists follow the same rules as outer ones. If an approver is tried against an empty or missing list of commits, that try fails. - One level of nesting is as deep as it goes, because Rego doesn't allow recursion. An `any_of` doesn't count as a level, but an `all` or `any` in one of its options does. A third `all` or `any` is written wrong: it fails `$well_formed`, the check fails with cause `ill_formed`, and its expression shows ``. diff --git a/ergo.rego b/ergo.rego index ef55bb7..6f85ff0 100644 --- a/ergo.rego +++ b/ergo.rego @@ -400,7 +400,7 @@ _object_step_problems(step, true) := {"object step without each_as"} if not "eac _object_step_problems(step, true) := ({concat("", ["unknown field ", _text(k), " in naming step"]) | some k in object.keys(step) not k in {"each_as", "keys"} -} | {"invalid name" | not _valid_name(step.each_as)}) | {"invalid keys" | not _keys_well_formed(step)} if "each_as" in object.keys(step) +} | {"invalid alias" | not _valid_name(step.each_as)}) | {"invalid keys" | not _keys_well_formed(step)} if "each_as" in object.keys(step) _where_problems(id) := {["identified_by", concat("", [kind, " inside where"])] | some kind in _wrapped_in_where(id)} @@ -2686,19 +2686,19 @@ _input_paths(spec) := [object.get(spec, f, []) | some f in ["path", "each"]] if _name_problems(node, names) := union({_invalid_name_problem(node), _name_given_twice_problem(node, names), _unknown_name_problem(node, names)}) -_invalid_name_problem(node) := {"invalid name" | +_invalid_name_problem(node) := {"invalid alias" | _quantified(node) "as" in object.keys(node) not _valid_name(node.as) } -_name_given_twice_problem(node, names) := {"name given twice" | +_name_given_twice_problem(node, names) := {"alias given twice" | _quantified(node) _valid_name(node.as) node.as in names } -_unknown_name_problem(node, names) := {concat("", ["unknown name ", p[0]]) | +_unknown_name_problem(node, names) := {concat("", ["unknown alias ", p[0]]) | some f in ["path", "left", "right", "each", "inputs"] some p in _own_paths(node, f) _named(p) @@ -3622,12 +3622,12 @@ _as_text(check, given) := concat("", [" as $", check.as]) if { not check.as in given } -_as_text(check, given) := " as " if { +_as_text(check, given) := " as " if { _valid_name(check.as) check.as in given } -_as_text(check, _) := " as " if { +_as_text(check, _) := " as " if { "as" in object.keys(check) not _valid_name(check.as) } @@ -5528,11 +5528,11 @@ _s_sentence_problems(parsed) := (((((((((((((({concat("", [node.bare_word, " is some node in _s_nodes(parsed) is_object(node) "bare_word" in object.keys(node) -} | {concat("", [node.bad_name, " can't be a name"]) | +} | {concat("", [node.bad_name, " can't be an alias"]) | some node in _s_nodes(parsed) is_object(node) "bad_name" in object.keys(node) -}) | {concat("", [node.as, " can't be a name"]) | +}) | {concat("", [node.as, " can't be an alias"]) | some node in _s_nodes(parsed) is_object(node) _s_bad_name(object.get(node, "as", "")) @@ -6239,7 +6239,7 @@ _s_elapsed_unsupported(a) := {"an elapsed time isn't supported yet except alone object.get(a, "terms", []) != [node] } -_s_from_paths_unsupported(a) := _s_paths_unsupported(a) | {concat("", ["unknown name ", ref.steps[0]]) | +_s_from_paths_unsupported(a) := _s_paths_unsupported(a) | {concat("", ["unknown alias ", ref.steps[0]]) | some node in _s_nodes(a) is_object(node) ref := object.get(node, "named_by", {"start": ""}) @@ -6758,7 +6758,7 @@ _s_for_line_unsupported(lines, ftext) := _s_for_unsupported(fl, []) | _s_lines_p _s_for_unsupported(fl, assertions) := ({u | some item in fl.items; some u in _s_item_unsupported(item)} | {u | some a in assertions; some u in _s_assertion_unsupported(a)}) | {u | some i, item in fl.items; some u in _s_names_not_given(item, {later.as | some later in array.slice(fl.items, i, count(fl.items))})} -_s_names_not_given(x, later) := {concat("", ["unknown name ", ref.steps[0]]) | +_s_names_not_given(x, later) := {concat("", ["unknown alias ", ref.steps[0]]) | some node in _s_nodes(x) is_object(node) ref := object.get(node, "named_by", {"start": ""}) diff --git a/ergo_test.rego b/ergo_test.rego index b7e932b..b1bbd79 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -2961,7 +2961,7 @@ badly_written_requirements := { "require_most": [{"require": "most"}, [{"name": "require", "value": ["neither every nor some"]}]], "require_number": [{"require": 5}, [{"name": "require", "value": ["neither every nor some"]}]], "checks_empty": [{"checks": {}}, [{"name": "require", "value": "every"}, {"name": "checks", "value": ["empty"]}]], - "name_with_dollar": [{"from": ["items", {"each_as": "$x"}]}, [{"name": "require", "value": "every"}, {"name": "subjects.subject.from", "value": ["invalid name"]}]], + "name_with_dollar": [{"from": ["items", {"each_as": "$x"}]}, [{"name": "require", "value": "every"}, {"name": "subjects.subject.from", "value": ["invalid alias"]}]], "step_not_last": [{"from": [{"each_as": "x"}, "items"]}, [{"name": "require", "value": "every"}, {"name": "subjects.subject.from", "value": ["object step before the last"]}]], "selector_in_from": [{"from": ["items", {"where": {"id": 1}}]}, [{"name": "require", "value": "every"}, {"name": "subjects.subject.from", "value": ["object step without each_as"]}]], "keys_without_name": [{"from": ["items", {"keys": ["a"]}]}, [{"name": "require", "value": "every"}, {"name": "subjects.subject.from", "value": ["object step without each_as"]}]], @@ -3352,11 +3352,30 @@ test_a_ref_that_reads_the_wrong_kind_of_value_leaves_well_formed_alone_because_p } } +test_an_alias_given_twice_is_reported_as_an_alias if { + rep := report({"prs": [{"n": 1}]}, {"s": {"from": ["prs", {"each_as": "pr"}], "identified_by": ["n"], "checks": {"c": {"op": "all", "path": ["$pr", "xs"], "as": "pr", "check": {"op": "present", "path": ["v"]}}}}}) + problem_inputs(rep) == [{"name": "checks.c", "value": ["alias given twice"]}] +} + +test_an_alias_that_is_not_a_valid_word_is_reported_as_an_invalid_alias if { + rep := report({"prs": [{"n": 1}]}, {"s": {"from": ["prs"], "identified_by": ["n"], "checks": {"c": {"op": "all", "path": ["xs"], "as": "$x", "check": {"op": "present", "path": ["v"]}}}}}) + problem_inputs(rep) == [{"name": "checks.c", "value": ["invalid alias"]}] +} + +test_a_bare_word_after_named_by_that_no_for_line_gives_is_an_unknown_alias if { + _sentence_problems({"for": "every xs named by x as x", "assert": "x is 1"}) == ["unknown alias x"] +} + +test_a_dollar_word_in_a_sentence_is_not_an_alias_so_it_stays_an_unknown_name if { + _sentence_problems({"for": "every xs as x", "assert": "x is $foo"}) == ["unknown name $foo"] + _sentence_problems({"assert": "$foo.a exists"}) == ["unknown name $foo"] +} + test_a_name_given_by_from_is_known_and_any_other_is_not if { good := report({"prs": [{"n": 1}]}, {"s": {"from": ["prs", {"each_as": "pr"}], "identified_by": ["n"], "checks": {"c": {"op": "present", "path": ["$pr", "n"]}}}}) bad := report({"prs": [{"n": 1}]}, {"s": {"from": ["prs", {"each_as": "pr"}], "identified_by": ["n"], "checks": {"c": {"op": "present", "path": ["$p", "n"]}}}}) rows_for(good, "s", "$well_formed")[0].passed == true - problem_inputs(bad) == [{"name": "checks.c", "value": ["unknown name $p"]}] + problem_inputs(bad) == [{"name": "checks.c", "value": ["unknown alias $p"]}] [r.cause | some r in rows_for(bad, "s", "c")] == ["ill_formed"] } @@ -3365,7 +3384,7 @@ test_a_name_given_by_as_is_known_only_inside_its_check if { "a": [{"op": "any", "path": ["approvers"], "as": "approver", "check": {"op": "present", "path": ["$approver", "id"]}}], "b": [{"op": "present", "path": ["$approver", "id"]}], }}}}}) - problem_inputs(rep) == [{"name": "checks.c.options.b.0", "value": ["unknown name $approver"]}] + problem_inputs(rep) == [{"name": "checks.c.options.b.0", "value": ["unknown alias $approver"]}] } test_a_badly_written_filter_fails_well_formed_too if { @@ -3456,7 +3475,7 @@ test_a_missing_path_or_check_inside_a_nested_list_check_shows_in_the_expression test_a_check_reading_a_name_nothing_gives_fails_the_requirement_even_when_no_subject_is_found if { rep := report({"items": []}, {"s": {"from": ["items"], "min_subjects": 0, "checks": {"c": {"op": "present", "path": ["$p", "id"]}}}}) rep.requirements.s.status == "not_met" - problem_inputs(rep) == [{"name": "checks.c", "value": ["unknown name $p"]}] + problem_inputs(rep) == [{"name": "checks.c", "value": ["unknown alias $p"]}] } test_a_field_its_op_does_not_use_fails_well_formed if { @@ -3758,7 +3777,7 @@ test_present_that_cannot_reach_its_field_is_not_a_clean_no if { test_a_path_starting_with_a_reserved_double_dollar_name_is_ill_formed if { rep := report({"items": [{"id": 1}]}, {"s": {"from": ["items"], "identified_by": ["id"], "checks": {"c": {"op": "equals", "path": ["$$foo", "x"], "value": 1}}}}) - problem_inputs(rep) == [{"name": "checks.c", "value": ["unknown name $$foo"]}] + problem_inputs(rep) == [{"name": "checks.c", "value": ["unknown alias $$foo"]}] [r.cause | some r in rows_for(rep, "s", "c")] == ["ill_formed"] } @@ -5783,7 +5802,7 @@ test_a_badly_written_as_fails_the_check if { every name in [1, "", "$a", "$$input", null] { check := {"op": "any", "path": ["approvers"], "as": name, "check": {"op": "present", "path": ["username"]}} [r[1] | some r in review_rows(check)] == [false, false, false, false, false] - report(review_doc, review_req(check)).requirements.s.checks.c.expression == "some approvers as : username is present" + report(review_doc, review_req(check)).requirements.s.checks.c.expression == "some approvers as : username is present" } } @@ -5888,9 +5907,9 @@ test_a_badly_written_list_check_is_ill_formed if { review_expression(check) := report(review_doc, review_req(check)).requirements.s.checks.c.expression test_a_name_given_twice_shows_in_the_expression if { - review_expression({"op": "any", "path": ["approvers"], "as": "pr", "check": {"op": "present", "path": ["username"]}}) == "some approvers as : username is present" - review_expression({"op": "any", "path": ["approvers"], "as": "a", "check": {"op": "all", "path": ["$pr", "commits"], "as": "a", "check": {"op": "present", "path": ["sha"]}}}) == "some approvers as $a: every $pr.commits as : sha is present" - review_expression({"op": "any", "path": ["approvers"], "as": "a", "check": {"op": "all", "path": ["$pr", "commits"], "as": "pr", "check": {"op": "present", "path": ["sha"]}}}) == "some approvers as $a: every $pr.commits as : sha is present" + review_expression({"op": "any", "path": ["approvers"], "as": "pr", "check": {"op": "present", "path": ["username"]}}) == "some approvers as : username is present" + review_expression({"op": "any", "path": ["approvers"], "as": "a", "check": {"op": "all", "path": ["$pr", "commits"], "as": "a", "check": {"op": "present", "path": ["sha"]}}}) == "some approvers as $a: every $pr.commits as : sha is present" + review_expression({"op": "any", "path": ["approvers"], "as": "a", "check": {"op": "all", "path": ["$pr", "commits"], "as": "pr", "check": {"op": "present", "path": ["sha"]}}}) == "some approvers as $a: every $pr.commits as : sha is present" expression_in({}, {"id": 1}, {"op": "any", "path": ["xs"], "as": "items", "check": {"op": "present", "path": []}}) == "some xs as $items: $items is present" } @@ -5971,10 +5990,10 @@ test_a_list_check_two_levels_under_an_any_of_at_the_top_is_too_deep if { test_a_name_given_twice_in_a_list_check_inside_an_any_of_is_ill_formed if { inner := {"op": "any", "path": ["approvers"], "as": "approver", "check": {"op": "any_of", "options": {"peer": [{"op": "all", "path": ["$pr", "commits"], "as": "approver", "check": {"op": "present", "path": ["timestamp"]}}]}}} {r[2] | some r in peer_rows(inner)} == {"ill_formed"} - report(peer_doc, review_req(inner)).requirements.s.checks.c.expression == "some approvers as $approver: one of: peer(every $pr.commits as : timestamp is present)" + report(peer_doc, review_req(inner)).requirements.s.checks.c.expression == "some approvers as $approver: one of: peer(every $pr.commits as : timestamp is present)" top := {"op": "any_of", "options": {"o": [{"op": "all", "path": ["commits"], "as": "pr", "check": {"op": "present", "path": ["timestamp"]}}]}} {r[2] | some r in peer_rows(top)} == {"ill_formed"} - report(peer_doc, review_req(top)).requirements.s.checks.c.expression == "one of: o(every commits as : timestamp is present)" + report(peer_doc, review_req(top)).requirements.s.checks.c.expression == "one of: o(every commits as : timestamp is present)" } test_a_broken_list_check_in_an_any_of_filter_cannot_rule_subjects_out if { @@ -7614,7 +7633,7 @@ test_problems_with_the_subject_built_on_are_named_after_it if { {"name": "subjects.\"SBOM package\".applies_to.bad", "value": ["unknown op nope"]}, ] stepped := object.union(sbom_subjects, {"SBOM package": {"from": ["components", {"each_as": "$p"}]}}) - built_problems(stepped) == [{"name": "subjects.\"SBOM package\".from", "value": ["invalid name"]}] + built_problems(stepped) == [{"name": "subjects.\"SBOM package\".from", "value": ["invalid alias"]}] } test_a_well_formed_row_names_the_from_after_the_subject_it_comes_from if { @@ -8200,8 +8219,8 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _read(`x plus y matches "a"`) == {"problems": ["matches can't follow a count of, a sum of or a plus"]} _read(`5 is 5`) == {"problems": ["a number can't start a sentence"]} _read(`1 plus x is 5`).assertions[0].terms == [1, {"steps": ["x"]}] - _read(`x named by every is 1`) == {"problems": ["every can't be a name"]} - _read(`x[params] is 1`) == {"problems": ["params can't be a name"]} + _read(`x named by every is 1`) == {"problems": ["every can't be an alias"]} + _read(`x[params] is 1`) == {"problems": ["params can't be an alias"]} _read(`commits[01] is "x"`) == {"problems": ["doesn't parse"]} _read(`$foo is 1`) == {"problems": ["unknown name $foo"]} _read(`x is $params`).assertions[0].phrase.terms == [{"start": "params", "steps": []}] @@ -8226,8 +8245,8 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _read(`item 0 of x is 1`) == {"problems": ["doesn't parse"]} _read(`foo$bar is 1`) == {"problems": ["doesn't parse"]} _read(`x is "$schema"`) == {"assertions": [{"path": {"steps": ["x"]}, "phrase": {"op": "is", "value": "$schema"}}]} - ergo._for_line(`every x as every`) == {"problems": ["every can't be a name"]} - ergo._for_line(`every x as params`) == {"problems": ["params can't be a name"]} + ergo._for_line(`every x as every`) == {"problems": ["every can't be an alias"]} + ergo._for_line(`every x as params`) == {"problems": ["params can't be an alias"]} ergo._for_line(`every x as a, every y as b, every z as c`) == {"problems": ["a for line holds two items at most"]} ergo._for_line(`some x, if any, as y`) == {"problems": ["if any can't go with some"]} ergo._from_line(`count whose x is 1`) == {"problems": ["count is a word of the grammar: write .count"]} @@ -8481,7 +8500,7 @@ test_params_and_input_cant_be_names_so_a_name_is_never_read_as_the_params_or_the not r.compliant [row.cause | some row in r.results; row.check == "$well_formed"] == ["value"] } - rendered({}, {"op": "all", "path": ["xs"], "as": "input", "check": {"op": "present", "path": ["v"]}}) == "every xs as : v is present" + rendered({}, {"op": "all", "path": ["xs"], "as": "input", "check": {"op": "present", "path": ["v"]}}) == "every xs as : v is present" } test_an_each_that_isnt_a_list_shows_as_invalid_in_the_expression if { @@ -8925,7 +8944,7 @@ test_a_for_line_written_wrong_says_why if { _sentence_problems({"assert": "x.a exists", "for": "every xs as"}) == [`for: an item ends with "as" and a name, like "every pull_requests as pr"`] _sentence_problems({"assert": "x.a exists", "for": "xs as x"}) == [`for: an item starts with "every" or "some"`] _sentence_problems({"assert": "x.a exists", "for": "every xs as x, every ys as x"}) == ["for: x is given twice"] - _sentence_problems({"assert": "x.a is 1", "for": "every xs as params"}) == ["for: params can't be a name"] + _sentence_problems({"assert": "x.a is 1", "for": "every xs as params"}) == ["for: params can't be an alias"] _sentence_problems({"assert": "x.a is 1", "for": "some xs, if any, as x"}) == ["for: if any can't go with some"] _sentence_problems({"assert": "x is $foo", "for": "every xs as x"}) == ["unknown name $foo"] } @@ -9453,7 +9472,7 @@ test_a_subject_cant_build_on_a_subject_read_from_another_yet if { test_the_parent_is_named_only_in_sentences_and_only_in_its_own_requirement if { rep := _chain_report(_chain_doc, _chain_subjects, {"c": {"op": "present", "path": ["$artifact", "name"]}}, {"name": "web"}) - [i.value | some r in rep.results; r.check == "$well_formed"; some i in r.inputs; i.name == "checks.c"] == [["unknown name $artifact"]] + [i.value | some r in rep.results; r.check == "$well_formed"; some i in r.inputs; i.name == "checks.c"] == [["unknown alias $artifact"]] other := ergo.report({"things": [{"k": 1}], "trail": {"artifacts": {}}}, {"subjects": object.union(_chain_subjects, {"thing": {"from": ["things", {"each_as": "artifact"}]}}), "requirements": {"t": {"subject": "thing", "checks": {"c": {"op": "present", "path": ["$artifact", "k"]}}}}}, {"name": "web"}) other.requirements.t.checks.c.expression == "$artifact.k is present" [r.inputs | some r in other.results; r.check == "c"] == [[{"name": "$artifact.k", "value": 1}]] @@ -9482,7 +9501,7 @@ test_named_by_a_name_from_the_for_line_reads_the_key_each_item_names if { test_named_by_a_bare_word_in_a_from_line_or_an_id_is_an_unknown_name_since_there_is_no_subject_to_read_it_from if { from := ergo.report({"things": [{"id": 1, "k": "a"}]}, {"subjects": {"thing": {"from": "things named by k", "identified_by": "id"}}, "requirements": {"s": {"subject": "thing", "checks": {"c": "id exists"}}}}, {}) - [i.value | some r in from.results; r.check == "$well_formed"; some i in r.inputs; i.name == "subjects.thing.from"] == [["unknown name k"]] + [i.value | some r in from.results; r.check == "$well_formed"; some i in r.inputs; i.name == "subjects.thing.from"] == [["unknown alias k"]] id := ergo.report({"things": [{"id": {"a": 1}, "k": "a"}]}, {"subjects": {"thing": {"from": "things", "identified_by": "id named by k"}}, "requirements": {"s": {"subject": "thing", "checks": {"c": "k exists"}}}}, {}) [i.value | some r in id.results; r.check == "$well_formed"; some i in r.inputs; i.name == "subjects.thing.identified_by"] == [[`identified_by is a path, like "id" or "build.number"`]] } @@ -9580,9 +9599,9 @@ test_named_by_a_for_name_in_the_second_items_path_reads_the_first_items_key if { } test_named_by_a_for_name_before_it_is_given_is_an_unknown_name if { - _sentence_problems({"for": "every xs named by x as x", "assert": "x is 1"}) == ["unknown name x"] - _sentence_problems({"for": "every lists named by q as p, every qs as q", "assert": "p is 1"}) == ["unknown name q"] - _sentence_problems({"for": "every objs whose v named by o is 1 as o", "assert": "o exists"}) == ["unknown name o"] + _sentence_problems({"for": "every xs named by x as x", "assert": "x is 1"}) == ["unknown alias x"] + _sentence_problems({"for": "every lists named by q as p, every qs as q", "assert": "p is 1"}) == ["unknown alias q"] + _sentence_problems({"for": "every objs whose v named by o is 1 as o", "assert": "o exists"}) == ["unknown alias o"] } test_named_by_a_for_name_that_holds_no_text_fails_with_its_cause if { @@ -9602,7 +9621,7 @@ test_named_by_a_for_name_that_holds_no_text_fails_with_its_cause if { } test_a_from_line_named_by_a_bare_word_is_an_unknown_name if { - _from_problems(_from_report({"m": {}}, {"x": {"from": "m named by k"}}, {"c": "id exists"}, {})) == [{"name": "subjects.x.from", "value": ["unknown name k"]}] + _from_problems(_from_report({"m": {}}, {"x": {"from": "m named by k"}}, {"c": "id exists"}, {})) == [{"name": "subjects.x.from", "value": ["unknown alias k"]}] } test_every_and_count_through_a_for_name_arent_listed_as_inputs_since_they_change_with_each_item if { diff --git a/spec/cases/any_of/cases.json b/spec/cases/any_of/cases.json index 432dff5..6e3497b 100644 --- a/spec/cases/any_of/cases.json +++ b/spec/cases/any_of/cases.json @@ -339,7 +339,7 @@ {"name": "checks.any_of_inside.options.a.0", "value": ["any_of can't go here"]}, {"name": "checks.empty_option", "value": ["empty option a"]}, {"name": "checks.empty_options", "value": ["empty options"]}, - {"name": "checks.name_from_sibling.options.b.0", "value": ["unknown name $x"]}, + {"name": "checks.name_from_sibling.options.b.0", "value": ["unknown alias $x"]}, {"name": "checks.no_options", "value": ["missing options"]}, {"name": "checks.option_object", "value": ["option a not a list"]} ] diff --git a/spec/cases/derived/cases.json b/spec/cases/derived/cases.json index 790b8f6..f2ae625 100644 --- a/spec/cases/derived/cases.json +++ b/spec/cases/derived/cases.json @@ -6217,7 +6217,7 @@ "description": "passes when every pull request has enough reviews", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -6309,7 +6309,7 @@ "description": "fails as value when one pull request has too few, and lists it with the sentence that failed", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -6403,7 +6403,7 @@ "description": "counts an empty list as 0, which fails the pull request as value", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -6468,7 +6468,7 @@ "description": "fails as absent when a pull request has no reviews field", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -6552,7 +6552,7 @@ "description": "fails as null when the reviews are null", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -6620,7 +6620,7 @@ "description": "fails as unusable when the reviews are not a list", "rules": [ "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -10679,7 +10679,7 @@ "sentence.derived.elapsed", "tally.elapsed", "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -10746,7 +10746,7 @@ "sentence.derived.elapsed", "tally.elapsed", "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", @@ -10826,7 +10826,7 @@ "sentence.derived.elapsed", "tally.elapsed", "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "for.walk", "for.try", diff --git a/spec/cases/for/cases.json b/spec/cases/for/cases.json index adc9932..82c1f09 100644 --- a/spec/cases/for/cases.json +++ b/spec/cases/for/cases.json @@ -34,7 +34,7 @@ "for.inputs", "for.failed_items", "sentence.for.line", - "sentence.for.name", + "sentence.for.alias", "sentence.phrase.is" ], "input": { @@ -2050,7 +2050,7 @@ { "description": "reads the item's field, not the subject's field of the same name", "rules": [ - "sentence.for.name", + "sentence.for.alias", "for.try", "for.inputs", "for.pass", @@ -2096,7 +2096,7 @@ { "description": "a subject whose field has that name is not read when the item lacks the field", "rules": [ - "sentence.for.name", + "sentence.for.alias", "for.try", "for.failed_items", "sentence.phrase.is" @@ -2564,7 +2564,7 @@ "for.pass", "for.inputs", "for.failed_items", - "sentence.for.name", + "sentence.for.alias", "sentence.for.scope", "sentence.phrase.is" ], @@ -4875,7 +4875,7 @@ { "description": "fails every row as ill_formed with no inputs, and $well_formed lists the problem", "rules": [ - "sentence.for.name", + "sentence.for.alias", "written_wrong", "written_wrong.row", "sentence.written_wrong.slot" @@ -4967,7 +4967,7 @@ { "description": "fails every row as ill_formed with no inputs, and $well_formed lists the problem", "rules": [ - "sentence.for.name", + "sentence.for.alias", "written_wrong", "written_wrong.row", "sentence.written_wrong.slot" @@ -5010,7 +5010,7 @@ { "name": "checks.c", "value": [ - "for: params can't be a name" + "for: params can't be an alias" ] } ] @@ -5059,7 +5059,7 @@ { "description": "fails every row as ill_formed with no inputs, and $well_formed lists the problem", "rules": [ - "sentence.for.name", + "sentence.for.alias", "written_wrong", "written_wrong.row", "sentence.written_wrong.slot" @@ -5102,7 +5102,7 @@ { "name": "checks.c", "value": [ - "for: empty can't be a name" + "for: empty can't be an alias" ] } ] @@ -5151,7 +5151,7 @@ { "description": "fails every row as ill_formed with no inputs, and $well_formed lists the problem", "rules": [ - "sentence.for.name", + "sentence.for.alias", "written_wrong", "written_wrong.row", "sentence.written_wrong.slot" @@ -5194,7 +5194,7 @@ { "name": "checks.c", "value": [ - "for: input can't be a name" + "for: input can't be an alias" ] } ] diff --git a/spec/cases/lists/cases.json b/spec/cases/lists/cases.json index 52f6263..95353c7 100644 --- a/spec/cases/lists/cases.json +++ b/spec/cases/lists/cases.json @@ -509,11 +509,11 @@ {"name": "checks.as_on_equals", "value": ["as can't go here"]}, {"name": "checks.check_not_object.check", "value": ["invalid check"]}, {"name": "checks.each_on_equals", "value": ["each can't go here"]}, - {"name": "checks.invalid_name", "value": ["invalid name"]}, - {"name": "checks.name_twice.check", "value": ["name given twice"]}, + {"name": "checks.invalid_name", "value": ["invalid alias"]}, + {"name": "checks.name_twice.check", "value": ["alias given twice"]}, {"name": "checks.no_check", "value": ["missing check"]}, {"name": "checks.too_deep.check.check", "value": ["nested too deep"]}, - {"name": "checks.unknown_name.check", "value": ["unknown name $q"]} + {"name": "checks.unknown_name.check", "value": ["unknown alias $q"]} ] }, {"requirement": "s", "subject": {"type": "thing", "id": "t1"}, "check": "as_on_equals", "passed": false, "cause": "ill_formed", "inputs": [{"name": "cs", "value": [1]}]}, @@ -576,8 +576,8 @@ "expressions": { "s": { "check_not_object": "every cs: ", - "invalid_name": "every cs as : cs[] is present", - "name_twice": "every cs as $x: every b as : $x is present", + "invalid_name": "every cs as : cs[] is present", + "name_twice": "every cs as $x: every b as : $x is present", "no_check": "every cs: ", "too_deep": "every cs: every b: ", "unknown_name": "every cs: $q.v == 1" @@ -615,14 +615,14 @@ "inputs": [ {"name": "count(checks)", "value": 2}, {"name": "require", "value": "every"}, - {"name": "checks.input", "value": ["invalid name"]}, - {"name": "checks.params", "value": ["invalid name"]} + {"name": "checks.input", "value": ["invalid alias"]}, + {"name": "checks.params", "value": ["invalid alias"]} ] }, {"requirement": "s", "subject": {"type": "thing", "id": "t1"}, "check": "input", "passed": false, "cause": "ill_formed", "inputs": [{"name": "cs[]", "value": [1]}], "failed_items": [{"path": "item 1 of cs", "item": "item 1 of cs", "cause": "ill_formed", "value": 1}]}, {"requirement": "s", "subject": {"type": "thing", "id": "t1"}, "check": "params", "passed": false, "cause": "ill_formed", "inputs": [{"name": "cs[]", "value": [1]}], "failed_items": [{"path": "item 1 of cs", "item": "item 1 of cs", "cause": "ill_formed", "value": 1}]} ], - "expressions": {"s": {"params": "every cs as : cs[] is present"}} + "expressions": {"s": {"params": "every cs as : cs[] is present"}} } ] } diff --git a/spec/check_grammar.py b/spec/check_grammar.py index 0646a24..e5bf3af 100644 --- a/spec/check_grammar.py +++ b/spec/check_grammar.py @@ -10,10 +10,11 @@ re.compile(p) for p in [ r" is a word of the grammar: write \.", - r" can't be a name$", + r" can't be an alias$", r" is given twice$", r"^if any can't go with some$", r"^unknown name ", + r"^unknown alias ", r"^min above max$", r"^number out of range: ", r"^invalid pattern ", diff --git a/spec/semantics.md b/spec/semantics.md index e8912f2..65ce2e1 100644 --- a/spec/semantics.md +++ b/spec/semantics.md @@ -17,7 +17,7 @@ Each rule has a name in brackets, like `[present.missing]`. The cases in [`cases A syntax reads each check into this model. Everything below is said in terms of it. - A **check** has an operator, like `equals` or `all`, and the parameters that operator takes. -- A **path** has a start and a list of steps. It starts at the subject, at the input, at the params, or at a name given by a subject's `from` or by an enclosing list check. +- A **path** has a start and a list of steps. It starts at the subject, at the input, at the params, or at an alias given by a subject's `from` or by an enclosing list check. - A **step** is a key (a string, which reads a key of an object), an index (a whole number of 0 or more, which reads an item of a list), a selector (fields and the values they must equal), or a ref step (a ref whose value is the key). - A **value** is a JSON value or a ref. - A **ref** is a path that starts at the input or the params and whose steps are all keys or indexes. @@ -38,7 +38,7 @@ Reading a path ends in one of these outcomes. Each one except `found` is also a | `unmatched` | A selector found no item in a list that's there. | | `ambiguous` | A selector found more than one item. | -**[path.start]** A path starts at the subject, the input, the params or a name. With no params, a path into the params ends `absent`. Params that are there but aren't an object fail the whole report, as [REFERENCE.md](../REFERENCE.md#params) says. +**[path.start]** A path starts at the subject, the input, the params or an alias. With no params, a path into the params ends `absent`. Params that are there but aren't an object fail the whole report, as [REFERENCE.md](../REFERENCE.md#params) says. **[path.empty]** A path with no steps reads its starting value itself, so on a subject it never ends `not_an_object`. A `null` subject ends `null`. @@ -247,7 +247,7 @@ A side of `compare` can also be `count_of` or `sum_of`, each with a path, `whose ## `all` and `any` -`all` takes a path to a list and an inner check, and asks whether every item passes the check. `any` asks whether at least one does. Both can also take `each`, a path inside each item to an inner list, and `as`, a name for each item. +`all` takes a path to a list and an inner check, and asks whether every item passes the check. `any` asks whether at least one does. Both can also take `each`, a path inside each item to an inner list, and `as`, an alias for each item. **[list.read]** The path must end `found` on a list. A list that's missing fails the check as `absent`, a `null` one as `null`, and anything else, an object included, as `unusable`. An empty list fails as `value`, because no items isn't proof of anything. @@ -259,7 +259,7 @@ A side of `compare` can also be `count_of` or `sum_of`, each with a path, `whose **[list.each]** With `each`, the check runs on every item of every inner list. Every inner list must be readable, a list and not empty, for `all` and `any` alike. One that isn't fails the check with the cause it would give as a list, even when an item elsewhere passes. -**[list.as]** `as` names each item, so a check nested inside can still read it once its own paths start somewhere else. A name can only be given once along a chain of checks, so `from` and an enclosing check can't give the same one. A path can only start with a name that `from` or an enclosing list check gives, not one given in a neighbouring `any_of` option. A check that breaks either rule is [written wrong](#the-model-of-a-check). Only `all` and `any` take `as` and `each`. +**[list.as]** `as` gives each item an alias, so a check nested inside can still read it once its own paths start somewhere else. An alias can only be given once along a chain of checks, so `from` and an enclosing check can't give the same one. A path can only start with an alias that `from` or an enclosing list check gives, not one given in a neighbouring `any_of` option. A check that breaks either rule is [written wrong](#the-model-of-a-check). Only `all` and `any` take `as` and `each`. **[list.inner]** The inner check is a basic operator, an `any_of`, or another `all` or `any`. One `all` or `any` can sit inside another, but not a third, so ergo can be written in a language without recursion. An `any_of` doesn't count as a level, but an `all` or `any` in one of its options does. Anything else is [written wrong](#the-model-of-a-check). @@ -313,17 +313,17 @@ A side of `compare` can also be `count_of` or `sum_of`, each with a path, `whose ## `for` -`for` takes `items`, one or two, each with `every` or `some`, a path, `whose`, `if_any` and a name, and `checks` and `assertions`, as `all_of` does. It's what a check with a `for` line reads into, see [sentence.for.line](sentences.md#the-for-line), and today's syntax can't use it (see [sentence_ops.syntax](syntax.md#policies)). +`for` takes `items`, one or two, each with `every` or `some`, a path, `whose`, `if_any` and an alias, and `checks` and `assertions`, as `all_of` does. It's what a check with a `for` line reads into, see [sentence.for.line](sentences.md#the-for-line), and today's syntax can't use it (see [sentence_ops.syntax](syntax.md#policies)). -**[for.walk]** Each item's path is walked as an `every` or `some` path is, see [walk.lists](#every-and-some), and its name stands for the value the path ends at in each item of the innermost list walked. The `whose` checks read from that item, and an item is undecided or left out as [walk.items](#every-and-some) says. An item `whose` keeps, whose path ends at a value that's missing or that can't be read, fails as `absent` or `unusable` without its checks being run. The second item's path and its `whose` checks can start at the first item's name, and then it's walked again for each item of the first list. +**[for.walk]** Each item's path is walked as an `every` or `some` path is, see [walk.lists](#every-and-some), and its alias stands for the value the path ends at in each item of the innermost list walked. The `whose` checks read from that item, and an item is undecided or left out as [walk.items](#every-and-some) says. An item `whose` keeps, whose path ends at a value that's missing or that can't be read, fails as `absent` or `unusable` without its checks being run. The second item's path and its `whose` checks can start at the first item's alias, and then it's walked again for each item of the first list. -**[for.try]** The checks read the subject, with each name standing for its item. With one item, an item passes when all the checks pass. With two, an item of the first list passes when the second item passes for it, as [walk.every](#every-and-some) or [walk.some](#every-and-some) says, where an item of the second list passes when all the checks pass. A failed item's cause is the first, in the order of [causes](#causes), among the checks that failed, or what failed in the second item's walk. +**[for.try]** The checks read the subject, with each alias standing for its item. With one item, an item passes when all the checks pass. With two, an item of the first list passes when the second item passes for it, as [walk.every](#every-and-some) or [walk.some](#every-and-some) says, where an item of the second list passes when all the checks pass. A failed item's cause is the first, in the order of [causes](#causes), among the checks that failed, or what failed in the second item's walk. -**[for.named_by]** A path with a `named_by` step reads the key that the step's name holds for the item being tried, as a ref step reads a key from the params. When the name holds `null`, the check fails as `null`, and when it holds anything else that isn't a string, a number included, as `unusable`. The name has to be given before it's used: in the `assert` lines, or in a later item's path or `whose`. Only sentences can write such a step, as ` named by `; in today's syntax it's a step that can't be a key. +**[for.named_by]** A path with a `named_by` step reads the key that the step's alias holds for the item being tried, as a ref step reads a key from the params. When the alias holds `null`, the check fails as `null`, and when it holds anything else that isn't a string, a number included, as `unusable`. The alias has to be given before it's used: in the `assert` lines, or in a later item's path or `whose`. Only sentences can write such a step, as ` named by `; in today's syntax it's a step that can't be a key. **[for.pass]** The check passes or fails as the first item does, by [walk.every](#every-and-some) or [walk.some](#every-and-some), with its items judged as [for.try](#for) says. -**[for.inputs]** The row shows each item's list once, named after its path with `[]` after each list walked, holding the values its name stands for, and the inputs the checks would show on their own, leaving out paths that start at a name, once each, even when no check was run, all sorted by name. A second item that starts at the first item's name is named with that name, like `pr.approvers[]`, and holds the items of every list it walked, in order. Paths that start at a name aren't shown. A path that reads through a name further on, like `shas named by persona`, shows the part before the name, `shas`, and an `every`, `some`, `count of` or `sum of` through a name isn't shown. +**[for.inputs]** The row shows each item's list once, named after its path with `[]` after each list walked, holding the values its alias stands for, and the inputs the checks would show on their own, leaving out paths that start at an alias, once each, even when no check was run, all sorted by name. A second item that starts at the first item's alias is named with that alias, like `pr.approvers[]`, and holds the items of every list it walked, in order. Paths that start at an alias aren't shown. A path that reads through an alias further on, like `shas named by persona`, shows the part before the name, `shas`, and an `every`, `some`, `count of` or `sum of` through an alias isn't shown. **[for.failed_items]** When the check fails, `failed_items` lists the first item's failures, in path order, as [walk.failed_items](#every-and-some) does, and it's `[]` when the check passes. A failed item has `path` and `item` both the item, like `item 2 of pull_requests`, and `value` `null`. Each entry also has `failed_assertions`: the sentences that failed for that item, in the order written, each with the first cause it failed with. It's `[]` when no sentence was checked for it, because a `whose` couldn't decide, a list failed while walking, or `whose` left nothing in, and on the entry of a list that failed while walking. diff --git a/spec/sentences.abnf b/spec/sentences.abnf index dcaa4fb..3dac169 100644 --- a/spec/sentences.abnf +++ b/spec/sentences.abnf @@ -16,10 +16,10 @@ if-any = "," WS "if" WS "any," derived-assertion = derived-left WS compare-phrase elapsed-assertion = elapsed WS compare-phrase -; A for: line walks one or two lists and names each item. +; A for: line walks one or two lists and gives each item an alias. for-line = for-item ["," WS for-item] -for-item = quantifier WS path [WS whose] [if-any] WS "as" WS name -name = bare-key +for-item = quantifier WS path [WS whose] [if-any] WS "as" WS alias +alias = bare-key ; A subject's from: line. from-line = path [WS "named" WS "by" WS "each" WS "of" WS right-path] [WS whose] @@ -85,7 +85,7 @@ first-key = bare-key / "." key / quoted-key step = "." key / WS "named" WS "by" WS reference / "[" reference "]" / "[" quoted-key "]" / "[" int "]" key = bare-key / quoted-key -reference = ("$params" / "$input") "." key / name +reference = ("$params" / "$input") "." key / alias bare-key = (ALPHA / "_") *(ALPHA / DIGIT / "_" / "-") quoted-key = dq-string diff --git a/spec/sentences.md b/spec/sentences.md index a355d8d..46d0d1e 100644 --- a/spec/sentences.md +++ b/spec/sentences.md @@ -69,7 +69,7 @@ peer_approval: **[sentence.path.string]** A path is keys joined by `.`, like `git_commit_info.sha1`. A key is written bare when it starts with an ASCII letter or `_` and the rest is ASCII letters, digits, `_` and `-`, so Kosli's `secrets-scan`, `pull-request` and `coverage-verification` need no quotes. Any other key is quoted as a JSON string, like `metadata.labels."app.kubernetes.io/name"`, and so is any key with a `$` in it, like `"$schema"` or `"foo$bar"`. The report prints keys by the same rule, so `$` only ever starts `$params` or `$input`, and a path from a row can be pasted into a policy. -**[sentence.path.start]** A bare first key is a field of the subject. Inside `whose`, and in the argument of a one-line `every` or `some`, it's a field of the item instead (see [sentence.argument.item](#every-some-and-whose)). A name given by `as` on a `for` line is a bare word too, and it shadows a field with the same name. `$params` starts at the params and `$input` at the input. Any other `$` is written wrong, with `unknown name $`. +**[sentence.path.start]** A bare first key is a field of the subject. Inside `whose`, and in the argument of a one-line `every` or `some`, it's a field of the item instead (see [sentence.argument.item](#every-some-and-whose)). An alias given by `as` on a `for` line is a bare word too, and it shadows a field with the same name. `$params` starts at the params and `$input` at the input. Any other `$` is written wrong, with `unknown name $`. **[sentence.path.index]** `item 3 of items` reads the third item of `items`, counting from 1, and `first of items` and `last of items` read the ends. The path after `of` is the whole rest of the path, so `item 3 of pull_request.commits` picks the third commit, and a field of that item has no word form: write `pull_request.commits[2].sha` with brackets, which count from 0 as [sentence.path.brackets](#paths) says. `item 0 of items` is written wrong. The report prints the word form when the position ends the path, so `commits[2]` prints as `item 3 of commits`, and prints the brackets when a key follows them. No policy in the corpus needs a position. @@ -80,7 +80,7 @@ peer_approval: - A field with one of those names is written with a dot in front: `is .empty`, `is .true`. The dot is allowed on any field on the right side, so `equals .total_usd` means `equals total_usd`, and the report prints it only where it's needed. - A key that has to be quoted, like `$schema`, is written with the dot too: `is ."$schema"`. -**[sentence.path.dynamic]** ` named by ` reads the key that the reference holds, as a ref step does today, and the path can go on after it: `artifacts_statuses named by $params.artifact_name.attestations_statuses`. The reference is `$params` or `$input` plus one key, or one bare key. A bare key is a name from a `for` line, like `persona_blob_shas named by persona`, or else a field, read where a bare first key would be read (see [sentence.path.start](#paths)): `$params.max_days_by_severity named by severity` reads the subject's `severity`, and inside a `whose` the item's. Without that limit, `named by $params.a.b` could end the reference after `a` or after `b`, and the tokenizer found 24 lines in the corpus that read two ways because of it. +**[sentence.path.dynamic]** ` named by ` reads the key that the reference holds, as a ref step does today, and the path can go on after it: `artifacts_statuses named by $params.artifact_name.attestations_statuses`. The reference is `$params` or `$input` plus one key, or one bare key. A bare key is an alias from a `for` line, like `persona_blob_shas named by persona`, or else a field, read where a bare first key would be read (see [sentence.path.start](#paths)): `$params.max_days_by_severity named by severity` reads the subject's `severity`, and inside a `whose` the item's. Without that limit, `named by $params.a.b` could end the reference after `a` or after `b`, and the tokenizer found 24 lines in the corpus that read two ways because of it. **[sentence.path.brackets]** Brackets are the other spelling: `artifacts_statuses[$params.artifact_name]`, `persona_blob_shas[persona]`, and `items[2]` for a position. In brackets the type decides, as in JSON: a string picks a key and a number picks a position. The report prints the word form. @@ -176,7 +176,7 @@ It's a number like a `count of`, so it goes where one does: at the start of an a ## The `for` line -**[sentence.for.line]** A check can have a `for` line before its `assert`. It holds one or two items separated by `, `, and each item is `every` or `some`, a path, an optional `whose` with `and`, and `as` a name: +**[sentence.for.line]** A check can have a `for` line before its `assert`. It holds one or two items separated by `, `, and each item is `every` or `some`, a path, an optional `whose` with `and`, and `as` an alias: ```yaml peer_approved: @@ -185,13 +185,13 @@ peer_approved: assert: approver.username does not equal pr.author ``` -Items nest left to right, so a later one can use an earlier name, as `pr.approvers` does. A third item is written wrong, because two is the one level of nesting Rego gives today. +Items nest left to right, so a later one can use an earlier alias, as `pr.approvers` does. A third item is written wrong, because two is the one level of nesting Rego gives today. **[sentence.for.depth]** A check walks three lists at most, from the input to its assertion, counted as list walks and not as subjects. Walks in `from`, in a parent subject's `from`, on the `for` line, in a one-line `every` or `some`, and in a `count of` or `sum of`, in a `whose` too, all count, and a fourth is written wrong. A `count of` or `sum of` counts because it reads every item of its list, as `every` does, so `for: every pull_requests as pr` with `assert: count of pr.reviews is at least 2` walks two lists. sdlc-policies 0007's peer approval reaches the limit: pull requests in its `from`, then approvers and commits on its `for` line. Its `artifact` parent picks one artifact by name and walks no list. -**[sentence.for.scope]** In `assert` and in a later item, a bare first key is the subject's field and a named item is reached by its name. Inside an item's own `whose`, a bare key is that item's field. So in `for: some approvals whose role is "qa" as approval` with `assert: approval.approved_at is before started_at`, `role` is the approval's and `started_at` the subject's. +**[sentence.for.scope]** In `assert` and in a later item, a bare first key is the subject's field and an item is reached by its alias. Inside an item's own `whose`, a bare key is that item's field. So in `for: some approvals whose role is "qa" as approval` with `assert: approval.approved_at is before started_at`, `role` is the approval's and `started_at` the subject's. -**[sentence.for.name]** A name is a bare word that isn't `params`, `input` or a word of the grammar, like `every`, `as` or `empty`. For the rest of the check it shadows a field of the subject with the same name. A name given twice is written wrong. +**[sentence.for.alias]** An alias is a bare word that isn't `params`, `input` or a word of the grammar, like `every`, `as` or `empty`. For the rest of the check it shadows a field of the subject with the same name. An alias given twice is written wrong, and so is a bare word after `named by` that no `for` line gives, with `unknown alias `. **[sentence.for.fail]** Each item fails closed as a one-line `every` or `some` does: `some` over an empty list fails, `every` over one fails unless the item says `if any`, a missing or non-list path fails as `absent`, `null` or `unusable`, and an item a `whose` can't decide fails the try with its cause. @@ -205,12 +205,12 @@ Items nest left to right, so a later one can use an earlier name, as `pr.approve Some rules can't be said in ABNF, so they're here: -- A bare first key is never a word of the grammar, see [sentence.words](#words-a-field-cant-be-called). A `for` name isn't one either, nor `params` or `input`. -- Which bare first key is a subject's field, an item's field, a `for` name or a subject's name depends on where the sentence is, see [sentence.path.start](#paths), [sentence.for.scope](#the-for-line) and [sentence.subject.own](#subjects). +- A bare first key is never a word of the grammar, see [sentence.words](#words-a-field-cant-be-called). A `for` alias isn't one either, nor `params` or `input`. +- Which bare first key is a subject's field, an item's field, a `for` alias or a subject's name depends on where the sentence is, see [sentence.path.start](#paths), [sentence.for.scope](#the-for-line) and [sentence.subject.own](#subjects). - A `for` line has two items at most, and a check walks three lists at most, see [sentence.for.depth](#the-for-line). - `is`, `equals` and `is equal to` read the same, as do `is one of` and `is in`, and the report picks the spelling, see [sentence.print.canonical](#printing-the-sentence). - A run of spaces between two words reads as one, and a list of values can be written with or without a space after each comma. The report prints one space and `, `. -- The bounds of `is between` are in order, a string in `matches` is a valid regular expression, and a `for` name is given once, or the sentence is written wrong. +- The bounds of `is between` are in order, a string in `matches` is a valid regular expression, and a `for` alias is given once, or the sentence is written wrong. A grammar word only needs quoting or a dot when it's the first key, so `a`, `an`, `in`, `on` and `of` cost nothing in ordinary paths. @@ -395,7 +395,7 @@ Some lines have two causes. The tokenizer also applied [sentence.subject.own](#s 28 made-up lines test what the corpus doesn't reach: -- `count of x whose n is m plus k is t` read three ways until [sentence.derived.plus](#derived-values) was written. Under the grammar it reads one way, with `m plus k` inside the condition. A `for` item named `every` reads one way until [sentence.for.name](#the-for-line) forbids it. +- `count of x whose n is m plus k is t` read three ways until [sentence.derived.plus](#derived-values) was written. Under the grammar it reads one way, with `m plus k` inside the condition. A `for` item named `every` reads one way until [sentence.for.alias](#the-for-line) forbids it. - `if any` with `some`, two quantifiers in one sentence, `count of ... is empty`, `whose count of reviews is empty` and the bare key `foo$bar` parse no way, as they should. - `whose count of reviews is at least 1 and state is "open"` and `some orders.id whose sum of items.price is more than 100 is not empty` read one way. - `every items.price whose kind is "a" and qty is 1 is at least 2 and owner is "x"` reads one way, with `owner` on the subject. So do `state is "MERGED" and every commits.verified is true` and `every items.price whose kind is "a" is at least 2 and some items.owner is "x"`, two quantified assertions on one line. diff --git a/spec/syntax.md b/spec/syntax.md index 2092667..607f58d 100644 --- a/spec/syntax.md +++ b/spec/syntax.md @@ -56,13 +56,13 @@ Draft 0.1. This file covers the syntax policies use today, where a check is a JS **[compare.written_wrong]** A `cmp` that isn't one of `eq`, `ne`, `gt`, `gte`, `lt` and `lte` is written `invalid cmp`. A `compare` or `compare_time` check has no `path`. -**[list.written_wrong]** A name in `as` must be a string that doesn't start with `$` and isn't `params` or `input`, which would name like the params and the input, or it's written `invalid name`. A name already given is written `name given twice`, and a path starting with a name nothing gave is written `unknown name $`. `as` or `each` on another operator is written `as can't go here` or `each can't go here`. A missing inner check is written `missing check`, one that isn't an object `invalid check`, a third level `nested too deep`, and a custom operator ` can't go here` (see [REFERENCE.md](../REFERENCE.md#custom-operators)). +**[list.written_wrong]** An alias in `as` must be a string that doesn't start with `$` and isn't `params` or `input`, which would name like the params and the input, or it's written `invalid alias`. An alias already given is written `alias given twice`, and a path starting with an alias nothing gave is written `unknown alias $`. `as` or `each` on another operator is written `as can't go here` or `each can't go here`. A missing inner check is written `missing check`, one that isn't an object `invalid check`, a third level `nested too deep`, and a custom operator ` can't go here` (see [REFERENCE.md](../REFERENCE.md#custom-operators)). **[any_of.written_wrong]** `options` is an object of named options, or a list of them, named by their position as text: `"0"`, `"1"` and so on. A missing `options` is written `missing options`, an empty one `empty options`, an empty option `empty option `, an option that isn't a list `option not a list`, and an `any_of` or custom operator inside an option ` can't go here`. ## Naming a path -A row's `inputs`, a ref's entry in `$refs`, an item in `failed_items` and a path in an `expression` name the paths they read the way a sentence writes them (see [sentences.md](sentences.md#paths)). A name made of keys, positions and `named by` one key, starting at the subject, `$params` or `$input`, can be pasted into a sentence. Names given by `each_as` or `as`, like `$pr.author`, selectors, the `[]` of a list's items and refs of more than one key have no sentence spelling yet, so those can't. The names `$well_formed` gives the policy's own fields, like `checks.c` or `subjects.deployment.from`, follow [written_wrong.row](#checks-written-wrong) instead. +A row's `inputs`, a ref's entry in `$refs`, an item in `failed_items` and a path in an `expression` name the paths they read the way a sentence writes them (see [sentences.md](sentences.md#paths)). A name made of keys, positions and `named by` one key, starting at the subject, `$params` or `$input`, can be pasted into a sentence. Aliases given by `each_as` or `as`, like `$pr.author`, selectors, the `[]` of a list's items and refs of more than one key have no sentence spelling yet, so those can't. The names `$well_formed` gives the policy's own fields, like `checks.c` or `subjects.deployment.from`, follow [written_wrong.row](#checks-written-wrong) instead. **[name.keys]** Keys are joined with `.`. A key is written as it is when it starts with an ASCII letter or `_` and the rest is ASCII letters, digits, `_` or `-`, and in double quotes otherwise, so a key with a `$` in it is quoted, like `"$schema".x` or `x."$b"`. A first key that's a word of the sentence grammar (see [sentence.words](sentences.md#words-a-field-cant-be-called)) has a `.` in front, like `.empty` or `.a`. A path that starts at the input or the params starts with `$input` or `$params`, and one that starts at a name keeps it, like `$pr.author`. Since `params` and `input` can't be names, a path that starts with `$params` or `$input` in today's syntax names nothing and is shown as ``, so it never looks like the params or the input. @@ -112,7 +112,7 @@ Each check's definition in the report has an `expression` that says what it chec **[substitute.expression]** A check with a substitute is shown as `, or substitute: `, like `pull_request is present, or substitute: verified == true`. -**[list.expression]** `every : ` or `some : `, with the inner check's paths named inside the item. With `each`, the list is shown as `[].`, and with `as`, it's followed by ` as $`. A path with no steps in the inner check is named `[]`, like `every bs: bs[] matches one of ["^main$"]`. A missing or badly written inner check is shown as ``, ``, `` or ``, and a bad name as `` or ``. +**[list.expression]** `every : ` or `some : `, with the inner check's paths named inside the item. With `each`, the list is shown as `[].`, and with `as`, it's followed by ` as $`. A path with no steps in the inner check is named `[]`, like `every bs: bs[] matches one of ["^main$"]`. A missing or badly written inner check is shown as ``, ``, `` or ``, and a bad alias as `` or ``. **[any_of.expression]** `one of: ` followed by the options, sorted by name as text and joined with ` | `, each written `( and )` with its checks in the order written. A missing `options` is shown as ``, an empty one as ``, an empty option as `()` and one that isn't a list as `()`.