diff --git a/ergo.rego b/ergo.rego index e6146e2..478405a 100644 --- a/ergo.rego +++ b/ergo.rego @@ -3927,3 +3927,633 @@ _derived_placement_problem(node, kinds) := {concat("", [_side_form(side), " can' kinds[count(kinds) - 1] == "where" "substitute" in object.keys(node) } + +_s_patterns := { + "assertions": concat("", [`(`, assertion, `)(?: +and +|$)`]), + "assertion": concat("", [`^(?:(every|some) +(`, path, `)|(`, left, `))(?: +where +(`, conds, `))?(, +if +any,)? +(`, phrase, `)$`]), + "for_items": concat("", [`(`, for_item, `)(?:, +|$)`]), + "for_item": concat("", [`^(every|some) +(`, path, `)(?: +where +(`, conds, `))?(, +if +any,)? +as +(`, key, `)$`]), + "from": concat("", [`^(`, path, `)(?: +named +by +each +of +(`, rpath, `))?(?: +where +(`, conds, `))?$`]), + "conds": concat("", [`(`, cond, `)(?: +and +|$)`]), + "cond": concat("", [`^(`, left, `) +(`, phrase, `)$`]), + "phrase": concat("", [`^(?:`, concat("|", [concat("", [`(`, ph[1], ph[2], `)`]) | some ph in phrases]), `)$`]), + "phrases": phrases, + "path": concat("", [`^`, path, `$`]), + "path_parts": concat("", [`^(?:(item +([1-9][0-9]*)|first|last) +of +)?(`, start, `)((?:`, step, `)*)$`]), + "steps": concat("", [`\.(`, key, `|`, dq, `)| +named +by +(`, ref, `)|\[(`, ref, `)\]|\[(`, dq, `)\]|\[(0|[1-9][0-9]*)\]`]), + "terms": concat("", [`(`, term, `)(?: +plus +|$)`]), + "number": concat("", [`^`, num, `$`]), + "derived": concat("", [`^(count|sum) +of +(`, path, `)$`]), + "value": concat("", [`^`, value, `$`]), + "values": concat("", [`^`, values, `$`]), + "each_value": value, + "range": concat("", [`^(`, num, `) +and +(`, num, `)$`]), + "operand_within": concat("", [`^(`, operand, `)(?: +within +(`, num, `))?$`]), + "quotes": `^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"']+)*`, + "strings": `"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'`, + "string": concat("", [`^(?:`, dq, `|`, sq, `)$`]), + "quantified_path": concat("", [`^(?:(?:every|some) +)?`, path, `$`]), +} if { + key := `[A-Za-z_][A-Za-z0-9_-]*` + dq := `"(?:[^"\\\x00-\x1f]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*"` + sq := `'(?:[^'\\\x00-\x1f]|\\[^\x00-\x1f])*'` + num := `-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?` + ref := concat("", [`(?:\$params|\$input)\.(?:`, key, `|`, dq, `)|`, key]) + step := concat("", [`\.(?:`, key, `|`, dq, `)| +named +by +(?:`, ref, `)|\[(?:`, ref, `|`, dq, `|0|[1-9][0-9]*)\]`]) + start := concat("", [`\$params|\$input|\.?(?:`, key, `|`, dq, `)`]) + rstart := concat("", [`\$params|\$input|`, key, `|\.(?:`, key, `|`, dq, `)`]) + pos := `(?:item +[1-9][0-9]*|first|last) +of +` + plain := concat("", [`(?:`, start, `)(?:`, step, `)*`]) + path := concat("", [`(?:`, pos, `)?`, plain]) + rpath := concat("", [`(?:`, pos, `)?(?:`, rstart, `)(?:`, step, `)*`]) + derived := concat("", [`(?:count|sum) +of +`, path]) + term := concat("", [`(?:`, derived, `|`, rpath, `|`, num, `)`]) + terms := concat("", [term, `(?: +plus +`, term, `)*`]) + left := concat("", [`(?:`, path, `|`, terms, `)`]) + value := concat("", [`(?:`, dq, `|`, sq, `|`, num, `|true|false|null)`]) + values := concat("", [value, `(?:, *`, value, `)*`]) + operand := concat("", [`(?:`, num, `(?: +plus +`, term, `)+|`, value, `|(?:`, derived, `|`, rpath, `)(?: +plus +`, term, `)*)`]) + list := concat("", [`(?:`, values, `|`, rpath, `)`]) + type := `(?:list|string|number|boolean|object)` + phrases := [ + ["is not one of", `is +not +(?:one +of|in) +`, list, "list"], + ["is one of", `is +(?:one +of|in) +`, list, "list"], + ["is a non-empty string", `is +a +non-empty +string`, ``, "none"], + ["is a", `is +an? +`, type, "type"], + ["is not empty", `is +not +empty`, ``, "none"], + ["is empty", `is +empty`, ``, "none"], + ["does not exist", `does +not +exist`, ``, "none"], + ["exists", `exists`, ``, "none"], + ["contains all of", `contains +all +of +`, list, "list"], + ["contains none of", `contains +none +of +`, list, "list"], + ["does not contain", `does +not +contain +`, operand, "operand"], + ["contains", `contains +`, operand, "operand"], + ["does not match", `does +not +match +`, list, "list"], + ["matches", `matches +`, list, "list"], + ["starts with", `starts +with +`, operand, "operand"], + ["ends with", `ends +with +`, operand, "operand"], + ["is at least", `is +at +least +`, operand, "operand"], + ["is at most", `is +at +most +`, operand, "operand"], + ["is more than", `is +more +than +`, operand, "operand"], + ["is less than", `is +less +than +`, operand, "operand"], + ["is between", `is +between +`, concat("", [num, ` +and +`, num]), "range"], + ["is on or after", `is +on +or +after +`, operand, "operand"], + ["is on or before", `is +on +or +before +`, operand, "operand"], + ["is before", `is +before +`, operand, "operand"], + ["is after", `is +after +`, operand, "operand"], + ["is not", `(?:is +not|does +not +equal) +`, operand, "operand"], + ["is", `(?:is +equal +to|equals|is) +`, concat("", [operand, `(?: +within +`, num, `)?`]), "operand_within"], + ] + phrase := concat("|", [concat("", [`(?:`, concat("|", [ph[1] | some ph in phrases; ph[3] == kind[0]]), `)`, kind[1]]) | + some kind in [ + ["none", ``], + ["type", type], + ["range", concat("", [num, ` +and +`, num])], + ["list", list], + ["operand", operand], + ["operand_within", concat("", [operand, `(?: +within +`, num, `)?`])], + ] + ]) + cond := concat("", [`(?:`, derived, `|`, path, `) +(?:`, phrase, `)`]) + conds := concat("", [cond, `(?: +and +`, cond, `)*`]) + assertion := concat("", [`(?:(?:every|some) +`, path, `|`, left, `)(?: +where +`, conds, `)?(?:, +if +any,)? +(?:`, phrase, `)`]) + for_item := concat("", [`(?:every|some) +`, path, `(?: +where +`, conds, `)?(?:, +if +any,)? +as +`, key]) +} + +_s_words := {"a", "after", "an", "and", "as", "at", "before", "between", "count", "each", "empty", "equal", "every", "false", "first", "in", "item", "last", "less", "more", "named", "not", "null", "of", "on", "one", "or", "plus", "some", "sum", "true", "where", "within"} + +_s_pieces(pattern, text) := [m[1] | some m in matches] if { + matches := regex.find_all_string_submatch_n(pattern, text, -1) + concat("", [m[0] | some m in matches]) == text +} + +_sentence(raw) := {"assertions": assertions} if { + pat := _s_patterns + text := trim_space(raw) + pieces := _s_pieces(pat.assertions, text) + assertions := [_s_assertion_of(a, pat) | some a in pieces] + count(assertions) == count(pieces) + count(pieces) > 0 + count(_s_sentence_problems({"assertions": assertions})) == 0 +} else := {"problems": _s_problems(trim_space(raw), _s_patterns)} + +_for_line(raw) := {"items": items} if { + pat := _s_patterns + text := trim_space(raw) + pieces := _s_pieces(pat.for_items, text) + items := [_s_for_item_of(a, pat) | some a in pieces] + count(items) == count(pieces) + count(pieces) in {1, 2} + count(_s_sentence_problems({"items": items})) == 0 +} else := {"problems": _s_problems(trim_space(raw), _s_patterns)} + +_from_line(raw) := line if { + pat := _s_patterns + line := _s_from_of(trim_space(raw), pat) + count(_s_sentence_problems(line)) == 0 +} else := {"problems": _s_problems(trim_space(raw), _s_patterns)} + +_s_from_of(text, pat) := object.union({"path": _s_path_of(m[1], pat)}, object.union(_s_each_of(m[2], pat), _s_where_of(m[3], pat))) if { + m := regex.find_all_string_submatch_n(pat.from, text, 1)[0] +} + +_s_each_of("", _) := {} + +_s_each_of(text, pat) := {"named_by_each_of": _s_path_of(text, pat)} if text != "" + +_s_assertion_of(text, pat) := out if { + m := regex.find_all_string_submatch_n(pat.assertion, text, 1)[0] + m[1] != "" + out := object.union( + {m[1]: _s_path_of(m[2], pat), "phrase": _s_phrase_of(m[6], pat)}, + object.union(_s_where_of(m[4], pat), _s_if_any(m[5])), + ) +} + +_s_assertion_of(text, pat) := out if { + m := regex.find_all_string_submatch_n(pat.assertion, text, 1)[0] + m[1] == "" + m[5] == "" + out := object.union(_s_left_of(m[3], pat), object.union(_s_where_of(m[4], pat), {"phrase": _s_phrase_of(m[6], pat)})) +} + +_s_for_item_of(text, pat) := out if { + m := regex.find_all_string_submatch_n(pat.for_item, text, 1)[0] + out := object.union( + {m[1]: _s_path_of(m[2], pat), "as": m[5]}, + object.union(_s_where_of(m[3], pat), _s_if_any(m[4])), + ) +} + +_s_if_any("") := {} + +_s_if_any(text) := {"if_any": true} if text != "" + +_s_where_of("", _) := {} + +_s_where_of(text, pat) := {"where": conds} if { + text != "" + pieces := _s_pieces(pat.conds, text) + conds := [_s_cond_of(c, pat) | some c in pieces] + count(conds) == count(pieces) +} + +_s_cond_of(text, pat) := object.union(_s_left_of(m[1], pat), {"phrase": _s_phrase_of(m[2], pat)}) if { + m := regex.find_all_string_submatch_n(pat.cond, text, 1)[0] +} + +_s_left_of(text, pat) := {"path": _s_path_of(text, pat)} if { + regex.match(pat.path, text) +} else := {"terms": _s_terms_of(text, pat)} + +_s_terms_of(text, pat) := terms if { + pieces := _s_pieces(pat.terms, text) + terms := [_s_term_of(t, pat) | some t in pieces] + count(terms) == count(pieces) +} + +_s_term_of(text, pat) := _s_number(text) if { + regex.match(pat.number, text) +} else := {m[1]: _s_path_of(m[2], pat)} if { + m := regex.find_all_string_submatch_n(pat.derived, text, 1)[0] +} else := _s_path_of(text, pat) + +_s_path_of(text, pat) := _s_lifted(object.union(_s_position_of(m[1], m[2]), _s_start_of(m[3], _s_steps_of(m[4], pat)))) if { + m := regex.find_all_string_submatch_n(pat.path_parts, text, 1)[0] +} + +_s_lifted(path) := object.union(object.remove(path, ["steps"]), {"position": last.index + 1, "steps": array.slice(path.steps, 0, count(path.steps) - 1)}) if { + not "position" in object.keys(path) + last := path.steps[count(path.steps) - 1] + is_object(last) + "index" in object.keys(last) +} else := path + +_s_position_of("", _) := {} + +_s_position_of(word, "") := {"position": word} if word != "" + +_s_position_of(_, digits) := {"position": to_number(digits)} if digits != "" + +_s_start_of("$params", steps) := {"start": "params", "steps": steps} + +_s_start_of("$input", steps) := {"start": "input", "steps": steps} + +_s_start_of(key, steps) := {"steps": array.concat([key], steps), "bare_word": key} if key in _s_words + +_s_start_of(key, steps) := {"steps": array.concat([_s_key_of(key)], steps)} if { + not key in _s_words + not startswith(key, "$") + not startswith(key, ".") +} + +_s_start_of(key, steps) := {"steps": array.concat([_s_key_of(substring(key, 1, -1))], steps)} if startswith(key, ".") + +_s_key_of(text) := json.unmarshal(text) if startswith(text, `"`) + +_s_key_of(text) := text if not startswith(text, `"`) + +_s_steps_of(text, pat) := [_s_step_of(m) | some m in matches] if { + matches := regex.find_all_string_submatch_n(pat.steps, text, -1) + concat("", [m[0] | some m in matches]) == text +} + +_s_step_of(m) := _s_key_of(m[1]) if m[1] != "" + +_s_step_of(m) := {"named_by": _s_ref_of(m[2])} if m[2] != "" + +_s_step_of(m) := {"named_by": _s_ref_of(m[3])} if m[3] != "" + +_s_step_of(m) := _s_key_of(m[4]) if m[4] != "" + +_s_step_of(m) := {"index": to_number(m[5])} if m[5] != "" + +_s_ref_of(text) := {"start": "params", "steps": [_s_key_of(substring(text, 8, -1))]} if startswith(text, "$params.") + +_s_ref_of(text) := {"start": "input", "steps": [_s_key_of(substring(text, 7, -1))]} if startswith(text, "$input.") + +_s_ref_of(text) := {"steps": [text]} if { + not startswith(text, "$") + not _s_bad_name(text) +} + +_s_ref_of(text) := {"steps": [text], "bad_name": text} if _s_bad_name(text) + +_s_phrase_of(text, pat) := object.union({"op": ph[0]}, _s_args_of(ph[3], m[1], pat)) if { + groups := regex.find_all_string_submatch_n(pat.phrase, text, 1)[0] + ph := pat.phrases[min({i | some i in numbers.range(1, count(pat.phrases)); groups[i] != ""}) - 1] + m := regex.find_all_string_submatch_n(concat("", [`^(?:`, ph[1], `)(`, ph[2], `)$`]), text, 1)[0] +} + +_s_args_of("none", _, _) := {} + +_s_args_of("type", text, _) := {"type": text} + +_s_args_of("list", text, pat) := {"values": _s_values_of(text, pat)} if { + regex.match(pat.values, text) +} else := {"path": _s_path_of(text, pat)} + +_s_args_of("operand", text, pat) := _s_operand_of(text, pat) + +_s_args_of("range", text, pat) := {"min": _s_number(m[1]), "max": _s_number(m[2])} if { + m := regex.find_all_string_submatch_n(pat.range, text, 1)[0] +} + +_s_args_of("operand_within", text, pat) := object.union(_s_operand_of(m[1], pat), _s_within_of(m[2])) if { + m := regex.find_all_string_submatch_n(pat.operand_within, text, 1)[0] +} + +_s_within_of("") := {} + +_s_within_of(number) := {"within": _s_number(number)} if number != "" + +_s_operand_of(text, pat) := {"value": _s_value_of(text)} if { + regex.match(pat.value, text) +} else := {"terms": _s_terms_of(text, pat)} + +_s_values_of(text, pat) := [_s_value_of(m[0]) | some m in regex.find_all_string_submatch_n(pat.each_value, text, -1)] + +_s_value_of(text) := _s_number(text) if regex.match(`^-?[0-9]`, text) + +_s_value_of(text) := json.unmarshal(text) if { + not startswith(text, "'") + not regex.match(`^-?[0-9]`, text) +} + +_s_value_of(text) := concat("", [_s_sq_char(m[0]) | some m in regex.find_all_string_submatch_n(`\\'|\\\\|\\.|[^\\]`, substring(text, 1, count(text) - 2), -1)]) if startswith(text, "'") + +_s_number(text) := 0 if { + regex.match(`^-?[0.]+[eE]`, text) +} else := n if { + _s_magnitude(text) >= -400 + _s_magnitude(text) <= 400 + n := to_number(text) + _fits_a_float(n) +} else := {"out_of_range": text} + +_s_magnitude(text) := (count(m[1]) + _exponent(m[3])) - (count(digits) - count(trim_left(digits, "0"))) if { + m := regex.find_all_string_submatch_n(`^-?([0-9]+)(?:\.([0-9]*))?(?:[eE]([+-]?[0-9]+))?$`, text, 1)[0] + digits := concat("", [m[1], m[2]]) +} + +_s_sq_char(`\'`) := "'" + +_s_sq_char(`\\`) := `\` + +_s_sq_char(c) := c if not c in {`\'`, `\\`} + +_s_sentence_problems(parsed) := (((((((((({concat("", [node.bare_word, " is a word of the grammar: write .", node.bare_word]) | + some node in _s_nodes(parsed) + is_object(node) + "bare_word" in object.keys(node) +} | {concat("", [node.bad_name, " can't be a name"]) | + some node in _s_nodes(parsed) + is_object(node) + "bad_name" in object.keys(node) +}) | {concat("", [node.as, " can't be a name"]) | + some node in _s_nodes(parsed) + is_object(node) + _s_bad_name(object.get(node, "as", "")) +}) | {concat("", [name, " is given twice"]) | + some i, item in object.get(parsed, "items", []) + name := item.as + parsed.items[j].as == name + j < i +}) | {"if any can't go with some" | + some node in _s_nodes(parsed) + is_object(node) + "some" in object.keys(node) + object.get(node, "if_any", false) == true +}) | {"a where goes after every, some, count of or sum of" | + some node in _s_nodes(parsed) + node != parsed + is_object(node) + "where" in object.keys(node) + object.keys(node) & {"every", "some"} == set() + not _s_derived_term(object.get(node, "terms", [null])[count(object.get(node, "terms", [null])) - 1]) +}) | {concat("", [node.phrase.op, " can't follow a count of, a sum of or a plus"]) | + some node in _s_nodes(parsed) + is_object(node) + "terms" in object.keys(node) + not node.phrase.op in {"is", "is not", "is at least", "is at most", "is more than", "is less than", "is between"} +}) | {"a number can't start a sentence" | + some node in _s_nodes(parsed) + is_object(node) + node.terms == [node.terms[0]] + is_number(node.terms[0]) +}) | {"min above max" | + some node in _s_nodes(parsed) + is_object(node) + node.op == "is between" + is_number(node.min) + is_number(node.max) + node.min > node.max +}) | {concat("", ["number out of range: ", node.out_of_range]) | + some node in _s_nodes(parsed) + is_object(node) + "out_of_range" in object.keys(node) +}) | {concat("", ["invalid pattern ", _json_text(v)]) | + some node in _s_nodes(parsed) + is_object(node) + node.op in {"matches", "does not match"} + some v in object.get(node, "values", []) + not is_object(v) + not _s_valid_pattern(v) +}) | {`"within" needs a number of 0 or more` | + some node in _s_nodes(parsed) + is_object(node) + is_number(node.within) + node.within < 0 +} + +_s_valid_pattern(v) if { + is_string(v) + regex.is_valid(v) +} + +_s_derived_term(t) if { + is_object(t) + object.keys(t) & {"count", "sum"} != set() +} + +_s_bad_name(name) if name in _s_words + +_s_bad_name(name) if name in {"params", "input"} + +_s_nodes(x) := {y | walk(x, [_, y])} + +_s_problems(text, pat) := [concat("", ["unclosed quote at column ", _json_text(count(closed) + 1)])] if { + closed := regex.find_all_string_submatch_n(pat.quotes, text, 1)[0][0] + count(closed) < count(text) +} else := invalid if { + tokens := [m[0] | some m in regex.find_all_string_submatch_n(concat("", [pat.strings, `|[^"']+`]), text, -1)] + invalid := [concat("", ["invalid string at column ", _json_text(count(concat("", array.slice(tokens, 0, i))) + 1)]) | + some i, token in tokens + regex.match(`^["']`, token) + not regex.match(pat.string, token) + ] + count(invalid) > 0 +} else := sort(problems) if { + problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) + count(problems) > 0 +} else := names if { + names := sort({concat("", ["unknown name ", m[1]]) | some m in regex.find_all_string_submatch_n(concat("", [pat.strings, `|(?:^|[ ,\[])(\$[A-Za-z_][A-Za-z0-9_]*)`]), text, -1); not m[1] in {"", "$params", "$input"}}) + count(names) > 0 +} else := ["two quantifiers in one sentence: put them on a for line"] if { + regex.match(`^(?:every|some) +\S+ +(?:every|some) `, text) +} else := [`"within" needs a number after it`] if { + regex.match(` within$`, text) +} else := [concat("", ["nothing after the path at column ", _json_text(count(text) + 1), `: expected a phrase, like "is" or "exists"`])] if { + regex.match(pat.quantified_path, text) +} else := ["doesn't parse"] + +_s_parsed_anyway(text, pat) := {"assertions": assertions} if { + pieces := _s_pieces(pat.assertions, text) + assertions := [_s_assertion_of(a, pat) | some a in pieces] + count(assertions) == count(pieces) + count(pieces) > 0 +} + +_s_parsed_anyway(text, pat) := {"items": items} if { + pieces := _s_pieces(pat.for_items, text) + items := [_s_for_item_of(a, pat) | some a in pieces] + count(items) == count(pieces) + count(pieces) in {1, 2} +} + +_s_parsed_anyway(text, pat) := _s_from_of(text, pat) + +_s_check_text(parsed) := concat(" and ", [_s_assertion_text(a) | some a in parsed.assertions]) + +_s_for_line_text(parsed) := concat(", ", [_s_for_item_text(a) | some a in parsed.items]) + +_s_from_line_text(parsed) := concat("", [_s_path_text(parsed.path, "left"), _s_each_text(parsed), _s_where_text(parsed)]) + +_s_each_text(parsed) := concat("", [" named by each of ", _s_path_text(parsed.named_by_each_of, "right")]) if "named_by_each_of" in object.keys(parsed) + +_s_each_text(parsed) := "" if not "named_by_each_of" in object.keys(parsed) + +_s_for_item_text(item) := concat("", [_s_quantified_text(item), " as ", item.as]) + +_s_assertion_text(a) := concat(" ", [_s_quantified_text(a), _s_phrase_text(a.phrase)]) if _s_quantifier_of(a) != "" + +_s_assertion_text(a) := concat(" ", [concat("", [_s_left_text(a), _s_where_text(a)]), _s_phrase_text(a.phrase)]) if _s_quantifier_of(a) == "" + +_s_quantified_text(a) := concat("", [_s_quantifier_of(a), " ", _s_path_text(a[_s_quantifier_of(a)], "left"), _s_where_text(a), _s_if_any_text(a)]) + +_s_quantifier_of(a) := "every" if "every" in object.keys(a) + +_s_quantifier_of(a) := "some" if "some" in object.keys(a) + +_s_quantifier_of(a) := "" if object.keys(a) & {"every", "some"} == set() + +_s_if_any_text(a) := ", if any," if object.get(a, "if_any", false) == true + +_s_if_any_text(a) := "" if not object.get(a, "if_any", false) + +_s_where_text(a) := concat("", [" where ", concat(" and ", [_s_cond_text(c) | some c in a.where])]) if "where" in object.keys(a) + +_s_where_text(a) := "" if not "where" in object.keys(a) + +_s_cond_text(c) := concat(" ", [_s_left_text(c), _s_phrase_text(c.phrase)]) + +_s_left_text(c) := _s_path_text(c.path, "left") if "path" in object.keys(c) + +_s_left_text(c) := _s_terms_text(c.terms) if "terms" in object.keys(c) + +_s_terms_text(terms) := concat(" plus ", [_s_term_text(t) | some t in terms]) + +_s_term_text(t) := _json_text(t) if is_number(t) + +_s_term_text(t) := concat("", ["count of ", _s_path_text(t.count, "left")]) if { + is_object(t) + "count" in object.keys(t) +} + +_s_term_text(t) := concat("", ["sum of ", _s_path_text(t.sum, "left")]) if { + is_object(t) + "sum" in object.keys(t) +} + +_s_term_text(t) := _s_path_text(t, "right") if { + is_object(t) + "steps" in object.keys(t) +} + +_s_path_text(p, side) := concat("", [_s_position_text(p), _s_start_text(p, side), concat("", [_s_step_text(s) | some s in array.slice(p.steps, _s_first_step(p), count(p.steps))])]) + +_s_position_text(p) := concat("", ["item ", _json_text(p.position), " of "]) if is_number(object.get(p, "position", null)) + +_s_position_text(p) := concat("", [p.position, " of "]) if is_string(object.get(p, "position", null)) + +_s_position_text(p) := "" if not "position" in object.keys(p) + +_s_first_step(p) := 0 if "start" in object.keys(p) + +_s_first_step(p) := 1 if not "start" in object.keys(p) + +_s_start_text(p, _) := "$params" if object.get(p, "start", "") == "params" + +_s_start_text(p, _) := "$input" if object.get(p, "start", "") == "input" + +_s_start_text(p, side) := concat("", [_s_dot(p.steps[0], side), _s_key_text(p.steps[0])]) if not "start" in object.keys(p) + +_s_dot(key, side) := "." if { + _s_needs_dot(key, side) +} else := "" + +_s_needs_dot(key, _) if key in _s_words + +_s_needs_dot(key, "right") if not _s_bare_key(key) + +_s_bare_key(key) if regex.match(`^[A-Za-z_][A-Za-z0-9_-]*$`, key) + +_s_key_text(key) := key if _s_bare_key(key) + +_s_key_text(key) := _json_text(key) if not _s_bare_key(key) + +_s_step_text(s) := concat("", [".", _s_key_text(s)]) if is_string(s) + +_s_step_text(s) := concat("", [" named by ", _s_ref_text(s.named_by)]) if { + is_object(s) + "named_by" in object.keys(s) +} + +_s_step_text(s) := concat("", ["[", _json_text(s.index), "]"]) if { + is_object(s) + "index" in object.keys(s) +} + +_s_ref_text(r) := concat("", ["$", r.start, ".", _s_key_text(r.steps[0])]) if "start" in object.keys(r) + +_s_ref_text(r) := r.steps[0] if not "start" in object.keys(r) + +_s_phrase_text(ph) := concat(" ", [_s_phrase_words(ph), _s_args_text(ph)]) if _s_args_text(ph) != "" + +_s_phrase_text(ph) := _s_phrase_words(ph) if _s_args_text(ph) == "" + +_s_phrase_words(ph) := "equals" if { + ph.op == "is" + "terms" in object.keys(ph) +} + +_s_phrase_words(ph) := "is" if { + ph.op == "is" + "value" in object.keys(ph) +} + +_s_phrase_words(ph) := "does not equal" if { + ph.op == "is not" + "terms" in object.keys(ph) +} + +_s_phrase_words(ph) := "is not" if { + ph.op == "is not" + "value" in object.keys(ph) +} + +_s_phrase_words(ph) := "is one of" if { + ph.op == "is one of" + "values" in object.keys(ph) +} + +_s_phrase_words(ph) := "is in" if { + ph.op == "is one of" + "path" in object.keys(ph) +} + +_s_phrase_words(ph) := "is not one of" if { + ph.op == "is not one of" + "values" in object.keys(ph) +} + +_s_phrase_words(ph) := "is not in" if { + ph.op == "is not one of" + "path" in object.keys(ph) +} + +_s_phrase_words(ph) := "is an" if { + ph.op == "is a" + ph.type == "object" +} + +_s_phrase_words(ph) := "is a" if { + ph.op == "is a" + ph.type != "object" +} + +_s_phrase_words(ph) := ph.op if not ph.op in {"is", "is not", "is one of", "is not one of", "is a"} + +_s_args_text(ph) := ph.type if ph.op == "is a" + +_s_args_text(ph) := concat(" and ", [_json_text(ph.min), _json_text(ph.max)]) if ph.op == "is between" + +_s_args_text(ph) := concat(" within ", [_s_operand_text(ph), _json_text(ph.within)]) if "within" in object.keys(ph) + +_s_args_text(ph) := _s_operand_text(ph) if { + not ph.op in {"is a", "is between"} + not "within" in object.keys(ph) +} + +_s_operand_text(ph) := _s_value_text(ph.value) if "value" in object.keys(ph) + +_s_operand_text(ph) := _s_terms_text(ph.terms) if "terms" in object.keys(ph) + +_s_operand_text(ph) := concat(", ", [_s_value_text(v) | some v in ph.values]) if "values" in object.keys(ph) + +_s_operand_text(ph) := _s_path_text(ph.path, "right") if "path" in object.keys(ph) + +_s_operand_text(ph) := "" if object.keys(ph) & {"value", "terms", "values", "path"} == set() + +_s_value_text(v) := concat("", ["'", v, "'"]) if { + _s_single_quoted(v) +} else := _json_text(v) + +_s_single_quoted(v) if { + is_string(v) + contains(v, `\`) + not contains(v, "'") + not contains(v, `\\`) + not endswith(v, `\`) + regex.match(`^[^\x00-\x1f]*$`, v) +} diff --git a/ergo_test.rego b/ergo_test.rego index 0a3c224..c99ef9d 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -7832,3 +7832,259 @@ test_a_built_in_check_takes_its_cause_from_what_it_reads_whatever_inputs_it_show derived_row({"b": 1}, check) == [false, "absent", [{"name": "b", "value": 1}]] } } + +_read(text) := ergo._sentence(text) + +_printed(text) := ergo._s_check_text(ergo._sentence(text)) + +test_a_sentence_reads_into_one_assertion_per_and if { + _read(`environment is "prod" and every commits.verified is true`) == {"assertions": [ + {"path": {"steps": ["environment"]}, "phrase": {"op": "is", "value": "prod"}}, + {"every": {"steps": ["commits", "verified"]}, "phrase": {"op": "is", "value": true}}, + ]} +} + +test_every_corpus_line_parses_and_prints_back_unchanged if { + every text in data.spec.sentence_corpus { + _prints_back(text) + } + count(data.spec.sentence_corpus) == 306 +} + +_prints_back(text) if _printed(text) == text + +_prints_back(text) if ergo._s_for_line_text(ergo._for_line(text)) == text + +_prints_back(text) if ergo._s_from_line_text(ergo._from_line(text)) == text + +test_a_where_belongs_to_the_quantifier_and_an_and_after_the_phrase_starts_a_new_assertion if { + _read(`every items.price where kind is "a" and qty is 1 is at least 2 and owner is "x"`) == {"assertions": [ + { + "every": {"steps": ["items", "price"]}, + "where": [ + {"path": {"steps": ["kind"]}, "phrase": {"op": "is", "value": "a"}}, + {"path": {"steps": ["qty"]}, "phrase": {"op": "is", "value": 1}}, + ], + "phrase": {"op": "is at least", "value": 2}, + }, + {"path": {"steps": ["owner"]}, "phrase": {"op": "is", "value": "x"}}, + ]} +} + +test_the_and_of_between_is_not_the_and_between_assertions if { + _read(`qty is between 1 and 5 and kind is "a"`).assertions[0].phrase == {"op": "is between", "min": 1, "max": 5} + count(_read(`qty is between 1 and 5 and kind is "a"`).assertions) == 2 +} + +test_a_count_with_a_where_keeps_the_where_on_the_assertion_and_plus_inside_the_condition if { + _read(`count of x where n is m plus k is t`) == {"assertions": [{ + "terms": [{"count": {"steps": ["x"]}}], + "where": [{"path": {"steps": ["n"]}, "phrase": {"op": "is", "terms": [{"steps": ["m"]}, {"steps": ["k"]}]}}], + "phrase": {"op": "is", "terms": [{"steps": ["t"]}]}, + }]} + _read(`k plus count of x where n is m is t`).assertions[0].terms == [{"steps": ["k"]}, {"count": {"steps": ["x"]}}] + _read(`sum of stages.usd equals total_usd within 0.01`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["total_usd"]}], "within": 0.01} + _read(`x is 1 plus 2`).assertions[0].phrase == {"op": "is", "terms": [1, 2]} +} + +test_a_path_reads_params_input_lookups_positions_and_quoted_keys if { + _read(`$params.k."b c"[2].d is 1`).assertions[0].path == {"start": "params", "steps": ["k", "b c", {"index": 2}, "d"]} + _read(`$params.k."b c"[2] is 1`).assertions[0].path == {"start": "params", "position": 3, "steps": ["k", "b c"]} + _read(`item 3 of k."b c" is 1`).assertions[0].path == {"position": 3, "steps": ["k", "b c"]} + _read(`item 3 of k[0] is 1`).assertions[0].path == {"position": 3, "steps": ["k", {"index": 0}]} + _read(`$input.x named by persona.y is 1`).assertions[0].path == {"start": "input", "steps": ["x", {"named_by": {"steps": ["persona"]}}, "y"]} + _read(`b[$params.k] is 1`).assertions[0].path == {"steps": ["b", {"named_by": {"start": "params", "steps": ["k"]}}]} + _read(`b[$input."k"] is 1`).assertions[0].path == {"steps": ["b", {"named_by": {"start": "input", "steps": ["k"]}}]} + _read(`b[persona] is 1`).assertions[0].path == {"steps": ["b", {"named_by": {"steps": ["persona"]}}]} + _read(`b["k"] is 1`).assertions[0].path == {"steps": ["b", "k"]} + _read(`item 3 of b.c is 1`).assertions[0].path == {"position": 3, "steps": ["b", "c"]} + _read(`first of b is 1`).assertions[0].path == {"position": "first", "steps": ["b"]} + _read(`last of b is 1`).assertions[0].path == {"position": "last", "steps": ["b"]} + _read(`"$schema" is 1`).assertions[0].path == {"steps": ["$schema"]} + _read(`.count is 1`).assertions[0].path == {"steps": ["count"]} + _read(`"empty" is 1`).assertions[0].path == {"steps": ["empty"]} + _read(`x is ."$schema"`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["$schema"]}]} + _read(`.x is 1`) == _read(`x is 1`) + _read(`x equals .total`) == _read(`x equals total`) +} + +test_a_value_reads_as_json_and_a_single_quoted_string_keeps_its_backslashes if { + _read(`x is "a\"b"`).assertions[0].phrase.value == `a"b` + _read(`x is 'a\'b\\c\sd'`).assertions[0].phrase.value == `a'b\c\sd` + _read(`x is 1e2`).assertions[0].phrase.value == 100 + _read(`x is null`).assertions[0].phrase == {"op": "is", "value": null} + _read(`x is one of "a", 1, false`).assertions[0].phrase == {"op": "is one of", "values": ["a", 1, false]} + _read(`x is false-positive`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["false-positive"]}]} + _read(`x is true-ish and y is null-x`).assertions[1].phrase == {"op": "is", "terms": [{"steps": ["null-x"]}]} + _read(`x is empty-thing`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["empty-thing"]}]} + _read(`x is not empty-ish`).assertions[0].phrase == {"op": "is not", "terms": [{"steps": ["empty-ish"]}]} + _read(`x is a listing`) == {"problems": ["doesn't parse"]} + _read(`x is -0.5`).assertions[0].phrase == {"op": "is", "value": -0.5} +} + +test_each_phrase_reads_into_its_op if { + _read(`x is not one of "a", "b"`).assertions[0].phrase == {"op": "is not one of", "values": ["a", "b"]} + _read(`x is not in $params.k`).assertions[0].phrase == {"op": "is not one of", "path": {"start": "params", "steps": ["k"]}} + _read(`x is in $params.k`).assertions[0].phrase == {"op": "is one of", "path": {"start": "params", "steps": ["k"]}} + _read(`x is a non-empty string`).assertions[0].phrase == {"op": "is a non-empty string"} + _read(`x is an object`).assertions[0].phrase == {"op": "is a", "type": "object"} + _read(`x is a list`).assertions[0].phrase == {"op": "is a", "type": "list"} + _read(`x is empty`).assertions[0].phrase == {"op": "is empty"} + _read(`x is not empty`).assertions[0].phrase == {"op": "is not empty"} + _read(`x exists`).assertions[0].phrase == {"op": "exists"} + _read(`x does not exist`).assertions[0].phrase == {"op": "does not exist"} + _read(`x contains all of "a", "b"`).assertions[0].phrase == {"op": "contains all of", "values": ["a", "b"]} + _read(`x contains none of $params.k`).assertions[0].phrase == {"op": "contains none of", "path": {"start": "params", "steps": ["k"]}} + _read(`x contains "a"`).assertions[0].phrase == {"op": "contains", "value": "a"} + _read(`x does not contain y`).assertions[0].phrase == {"op": "does not contain", "terms": [{"steps": ["y"]}]} + _read(`x matches "^a"`).assertions[0].phrase == {"op": "matches", "values": ["^a"]} + _read(`x does not match $params.p`).assertions[0].phrase == {"op": "does not match", "path": {"start": "params", "steps": ["p"]}} + _read(`x starts with "a"`).assertions[0].phrase == {"op": "starts with", "value": "a"} + _read(`x ends with "a"`).assertions[0].phrase == {"op": "ends with", "value": "a"} + _read(`x is at least 1`).assertions[0].phrase == {"op": "is at least", "value": 1} + _read(`x is at most 1`).assertions[0].phrase == {"op": "is at most", "value": 1} + _read(`x is more than 1`).assertions[0].phrase == {"op": "is more than", "value": 1} + _read(`x is less than 1`).assertions[0].phrase == {"op": "is less than", "value": 1} + _read(`x is on or after y`).assertions[0].phrase == {"op": "is on or after", "terms": [{"steps": ["y"]}]} + _read(`x is on or before y`).assertions[0].phrase == {"op": "is on or before", "terms": [{"steps": ["y"]}]} + _read(`x is before y`).assertions[0].phrase == {"op": "is before", "terms": [{"steps": ["y"]}]} + _read(`x is after y`).assertions[0].phrase == {"op": "is after", "terms": [{"steps": ["y"]}]} + _read(`x is 1 within 0.5`).assertions[0].phrase == {"op": "is", "value": 1, "within": 0.5} + _read(`x is not 1`).assertions[0].phrase == {"op": "is not", "value": 1} + _read(`x does not equal y`).assertions[0].phrase == {"op": "is not", "terms": [{"steps": ["y"]}]} + _read(`x is equal to y`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["y"]}]} + _read(`x equals y`).assertions[0].phrase == {"op": "is", "terms": [{"steps": ["y"]}]} +} + +test_the_report_prints_one_spelling_for_each_phrase if { + _printed(`x is equal to y`) == "x equals y" + _printed(`x is y`) == "x equals y" + _printed(`x equals "y"`) == `x is "y"` + _printed(`x is not y`) == "x does not equal y" + _printed(`x does not equal "y"`) == `x is not "y"` + _printed(`x is one of $params.k`) == "x is in $params.k" + _printed(`x is in "a", "b"`) == `x is one of "a", "b"` + _printed(`x is not one of $params.k`) == "x is not in $params.k" + _printed(`x is not in "a"`) == `x is not one of "a"` + _printed(`x is a object`) == "x is an object" + _printed(`x is an list`) == "x is a list" + _printed(`x is 1e2 within 1.50`) == "x is 100 within 1.5" + _printed(`x equals y within 1`) == "x equals y within 1" + _printed(` x is one of "a", "b" `) == `x is one of "a", "b"` + _printed(`x is '^\s'`) == `x is '^\s'` + _printed(`x is "^\\s"`) == `x is '^\s'` + _printed(`x is "a\\\\b"`) == `x is "a\\\\b"` + _printed(`x is "a\\"`) == `x is "a\\"` + _printed(`x is "a\\\tb"`) == `x is "a\\\tb"` + _printed(`x is '^\s'`) == _printed(_printed(`x is '^\s'`)) + _printed(`x is 'it\'s'`) == `x is "it's"` + _printed(`x is "<"`) == `x is "<"` + _printed(`b[$params.k] is 1`) == "b named by $params.k is 1" + _printed(`b[persona][2].c is 1`) == "b named by persona[2].c is 1" + _printed(`b[persona][2] is 1`) == "item 3 of b named by persona is 1" + _printed(`commits[0] is "x"`) == `item 1 of commits is "x"` + _printed(`.x is 1`) == "x is 1" + _printed(`b["k"] is 1`) == "b.k is 1" + _printed(`b."c d" is 1`) == `b."c d" is 1` + _printed(`"$schema" is 1`) == `"$schema" is 1` + _printed(`x is ."$schema"`) == `x equals ."$schema"` + _printed(`.count is .sum`) == ".count equals .sum" + _printed(`"count" is 1`) == ".count is 1" + _printed(`item 3 of b is 1 and first of c is 2 and last of d is 3`) == "item 3 of b is 1 and first of c is 2 and last of d is 3" + _printed(`every x where n is 1, if any, is 2`) == "every x where n is 1, if any, is 2" + _printed(`some x where count of y is 1 is 2`) == "some x where count of y is 1 is 2" + _printed(`count of x where n is 1 is 2`) == "count of x where n is 1 is 2" + _printed(`sum of x.n plus 1 plus count of y is 2`) == "sum of x.n plus 1 plus count of y is 2" + _printed(`count of x[0] is 1 and y equals sum of z[1].n`) == "count of item 1 of x is 1 and y equals sum of z[1].n" + _read(_printed(`count of x[0] is 1`)) == _read(`count of x[0] is 1`) +} + +test_a_for_line_reads_its_items if { + ergo._for_line(`every pull_requests as pr, some pr.approvers where state is "APPROVED" as approver`) == {"items": [ + {"every": {"steps": ["pull_requests"]}, "as": "pr"}, + {"some": {"steps": ["pr", "approvers"]}, "where": [{"path": {"steps": ["state"]}, "phrase": {"op": "is", "value": "APPROVED"}}], "as": "approver"}, + ]} + ergo._for_line(`every commits, if any, as commit`) == {"items": [{"every": {"steps": ["commits"]}, "if_any": true, "as": "commit"}]} + ergo._s_for_line_text(ergo._for_line(`every commits, if any, as commit`)) == "every commits, if any, as commit" +} + +test_a_from_line_reads_its_path_keys_and_where if { + ergo._from_line(`artifact.attestations named by each of $params.names where status is "COMPLETE"`) == { + "path": {"steps": ["artifact", "attestations"]}, + "named_by_each_of": {"start": "params", "steps": ["names"]}, + "where": [{"path": {"steps": ["status"]}, "phrase": {"op": "is", "value": "COMPLETE"}}], + } + ergo._from_line(`trail`) == {"path": {"steps": ["trail"]}} + ergo._s_from_line_text(ergo._from_line(`artifact.attestations named by each of $params.names where status is "COMPLETE"`)) == `artifact.attestations named by each of $params.names where status is "COMPLETE"` +} + +test_a_number_out_of_range_is_written_wrong_without_opa_comparing_it if { + _read(`x is between -6 and -80e-08923553`) == {"problems": ["number out of range: -80e-08923553"]} + _read(`x is between 1 and 1e400`) == {"problems": ["number out of range: 1e400"]} + _read(`x is between 1e400 and 1`) == {"problems": ["number out of range: 1e400"]} + _read(`x matches 1e400`) == {"problems": ["number out of range: 1e400"]} + _read(`x is at least 1e400`) == {"problems": ["number out of range: 1e400"]} + _read(`x is 2.2e-308`) == {"problems": ["number out of range: 2.2e-308"]} + _read(`x is one of 1, 2e400`) == {"problems": ["number out of range: 2e400"]} + _read(`x is 1 within 1e999`) == {"problems": ["number out of range: 1e999"]} + _read(`count of y plus 1e-9999999 is 3`) == {"problems": ["number out of range: 1e-9999999"]} + _read(`x is 1.7976931348623157e308`).assertions[0].phrase.value == 1.7976931348623157e308 + _read(`x is 2.2250738585072014e-308`).assertions[0].phrase.value == 2.2250738585072014e-308 + _read(`x is 0.0e-9999999`).assertions[0].phrase.value == 0 +} + +test_a_sentence_written_wrong_says_what_is_wrong if { + _read(`x is "a`) == {"problems": ["unclosed quote at column 6"]} + _read(`x is 'a' and y is 'b`) == {"problems": ["unclosed quote at column 19"]} + _read(`x is "\q"`) == {"problems": ["invalid string at column 6"]} + _read(`x is "a b"`) == {"problems": ["invalid string at column 6"]} + _read(`x is 'a b'`) == {"problems": ["invalid string at column 6"]} + _read(`"\q" is 1`) == {"problems": ["invalid string at column 1"]} + _read(`x is "\q" and y is "\q"`) == {"problems": ["invalid string at column 6", "invalid string at column 20"]} + _read(`x is 'a"\q"b' and y is "\q"`) == {"problems": ["invalid string at column 24"]} + _read(`x is "cost $usd" and`) == {"problems": ["doesn't parse"]} + _read(`$foo is $bar and $foo exists`) == {"problems": ["unknown name $bar", "unknown name $foo"]} + _read(`some x, if any, is 1`) == {"problems": ["if any can't go with some"]} + _read(`every x some y is 1`) == {"problems": ["two quantifiers in one sentence: put them on a for line"]} + _read(`count is 1`) == {"problems": ["count is a word of the grammar: write .count"]} + _read(`x is empty_`) == {"assertions": [{"path": {"steps": ["x"]}, "phrase": {"op": "is", "terms": [{"steps": ["empty_"]}]}}]} + _read(`a plus b where c is 1 is 2`) == {"problems": ["a is a word of the grammar: write .a", "a where goes after every, some, count of or sum of"]} + _read(`x where k is 1 is 2`) == {"problems": ["a where goes after every, some, count of or sum of"]} + _read(`count of approvers is empty`) == {"problems": ["is empty can't follow a count of, a sum of or a plus"]} + _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(`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": []}] + _read(`every commits.signed`) == {"problems": [`nothing after the path at column 21: expected a phrase, like "is" or "exists"`]} + _read(`sum of stages.usd equals total within`) == {"problems": [`"within" needs a number after it`]} + _read(`every pull_requests some approvers.state is "APPROVED"`) == {"problems": ["two quantifiers in one sentence: put them on a for line"]} + _read(`x is equal to 1 within 2`).assertions[0].phrase == {"op": "is", "value": 1, "within": 2} + _read(`x is 1 within -1`) == {"problems": [`"within" needs a number of 0 or more`]} + _read(`x equals y within -0.5`) == {"problems": [`"within" needs a number of 0 or more`]} + _read(`x is 1 within -0`).assertions[0].phrase.within == 0 + _read(`x is one of "a", "b"`) == {"problems": ["doesn't parse"]} + ergo._for_line(`every xs as x, every ys as x`) == {"problems": ["x is given twice"]} + ergo._from_line(`xs[01]`) == {"problems": ["doesn't parse"]} + ergo._from_line(`xs where k is 1`) == {"path": {"steps": ["xs"]}, "where": [{"path": {"steps": ["k"]}, "phrase": {"op": "is", "value": 1}}]} + _read(`x is between 5 and 1`) == {"problems": ["min above max"]} + _read(`x matches "("`) == {"problems": [`invalid pattern "("`]} + _read(`x is`) == {"problems": ["doesn't parse"]} + _read(``) == {"problems": ["doesn't parse"]} + _read(`x is 1 and`) == {"problems": ["doesn't parse"]} + _read(`every x where a is 1`) == {"problems": ["doesn't parse"]} + _read(`x is count of y where a is 1`) == {"problems": ["doesn't parse"]} + _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 a, every y as b, every z as c`) == {"problems": ["doesn't parse"]} + ergo._for_line(`some x, if any, as y`) == {"problems": ["if any can't go with some"]} + ergo._from_line(`count where x is 1`) == {"problems": ["count is a word of the grammar: write .count"]} + ergo._from_line(`x is 1`) == {"problems": ["doesn't parse"]} + ergo._from_line(`$input named by each of "T"`) == {"problems": ["doesn't parse"]} +} diff --git a/spec/README.md b/spec/README.md index f6ec30b..5debf6f 100644 --- a/spec/README.md +++ b/spec/README.md @@ -4,7 +4,7 @@ Draft 0.1. ergo is three specs, and `ergo.rego` is an implementation of them: - [`semantics.md`](semantics.md) says what a check means, as named rules about a model of checks that doesn't depend on how they're written. - [`syntax.md`](syntax.md) says how today's policies write checks, how they can be written wrong, and how the report shows paths and checks. A new way of writing checks would get a file like it, reading into the same model. -- [`sentences.md`](sentences.md) is a draft of the next way, from [#174](https://github.com/kosli-dev/ergo/issues/174): every check a one-line sentence like `approved_by is not empty`. [`sentences.abnf`](sentences.abnf) is its grammar, [`sentences-corpus.md`](sentences-corpus.md) every real check written that way, and [`sentences-findings.md`](sentences-findings.md) what writing them found. Nothing implements it yet. +- [`sentences.md`](sentences.md) is a draft of the next way, from [#174](https://github.com/kosli-dev/ergo/issues/174): every check a one-line sentence like `approved_by is not empty`. [`sentences.abnf`](sentences.abnf) is its grammar, [`sentences-corpus.md`](sentences-corpus.md) every real check written that way, and [`sentences-findings.md`](sentences-findings.md) what writing them found. `ergo.rego` reads sentences, but no report runs on one yet. - [`policy/schema.json`](policy/schema.json) describes today's syntax as a JSON Schema. - [`report/schema.json`](report/schema.json) says what a report looks like, as a JSON Schema. - [`cases/`](cases) holds the cases that pin the rules down, one folder per topic. @@ -56,6 +56,8 @@ and this is the case: `python3 spec/check_rules.py` fails when a case cites a rule that doesn't exist, or when no case cites a rule. A rule can go without cases only while it's listed under [Rules without cases](OPEN.md#rules-without-cases) in `OPEN.md`, and the script also fails when a rule on that list has a case or doesn't exist. +`spec/sentences-corpus.json` holds every sentence, `for` line and `from` line of `sentences-corpus.md` as a list, so that `ergo_test.rego` can parse each one. `python3 spec/sentences_corpus.py` writes it from the markdown, and `python3 spec/sentences_corpus.py --check` fails when it's out of date. + ## Running the cases `spec_test.rego` runs every case against `ergo.rego`. Its `known_differences` lists the cases an implementation is known to fail, which is where it still differs from the spec. The test fails when another case fails, and also when a listed case passes, so the list stays true. diff --git a/spec/sentences-corpus.json b/spec/sentences-corpus.json new file mode 100644 index 0000000..ed20ae9 --- /dev/null +++ b/spec/sentences-corpus.json @@ -0,0 +1,310 @@ +{ +"sentence_corpus": [ +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"name is not empty", +"git_commit_info.sha1 is not empty", +"git_commit_info.url is not empty", +"origin_url is not empty", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"attestations_statuses named by $params.pr_attestation_name.status is \"COMPLETE\"", +"artifact.attestations_statuses named by $params.pr_attestation_name.pull_requests", +"state is \"MERGED\"", +"base_ref equals $params.protected_branch", +"every commits.verified is true", +"some approvers where state is \"APPROVED\" as approver, every commits as commit", +"approver.username does not equal author", +"approver.timestamp is after commit.timestamp", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"artifact.attestations_statuses named by each of $params.test_attestation_names", +"status is \"COMPLETE\"", +"is_compliant is true", +"has_audit_package is true", +"artifact_fingerprint is not empty", +"attestations_statuses named by $params.lock_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.dockerfile_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.sbom_attestation_name.status is \"COMPLETE\"", +"lockfile where status exists and status is \"COMPLETE\"", +"exact_pins is between 1 and 1000000", +"hashed is true", +"lock_entries", +"entry does not match '^[A-Za-z0-9._-]+\\s*(>=|<=|~=|!=|<|>)'", +"components where lock_release exists", +"sbom_release equals lock_release", +"components where exempt is false", +"some licences is not empty", +"some licences is in $params.allowed_licenses", +"base_images", +"pinned is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"attestations_statuses named by $params.build_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.build_attestation_name.is_compliant is true", +"attestations_statuses named by $params.build_attestation_name.has_audit_package is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"attestations_statuses named by $params.scan_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.scan_attestation_name.is_compliant is true", +"attestations_statuses named by $params.scan_attestation_name.has_audit_package is true", +"lockfile where status is \"COMPLETE\"", +"some licences.id is not empty", +"some licences.allowed is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"attestations_statuses named by $params.pr_attestation_name.status is \"COMPLETE\"", +"pull_requests", +"state is \"MERGED\"", +"base_ref equals $params.protected_branch", +"every commits.verified is true", +"some approvers.timestamp where state is \"APPROVED\" and username does not equal pr_author is after last_commit_timestamp", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact_fingerprint is not empty", +"artifact.attestations_statuses named by each of $params.test_attestation_names", +"status is \"COMPLETE\"", +"is_compliant is true", +"has_audit_package is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"artifact.attestations_statuses named by $params.approval_attestation_name", +"status is \"COMPLETE\"", +"is_compliant is true", +"has_audit_package is true", +"some approvers as approver", +"approver does not equal author", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"attestations_statuses named by $params.scan_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.scan_attestation_name.is_compliant is true", +"attestations_statuses named by $params.scan_attestation_name.has_audit_package is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"attestations_statuses named by $params.scan_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.scan_attestation_name.is_compliant is true", +"attestations_statuses named by $params.scan_attestation_name.has_audit_package is true", +"trail.compliance_status.artifacts_statuses named by $params.artifact_name", +"attestations_statuses named by $params.scan_attestation_name.status is \"COMPLETE\"", +"attestations_statuses named by $params.scan_attestation_name.is_compliant is true", +"attestations_statuses named by $params.scan_attestation_name.has_audit_package is true", +"artifact_fingerprint is not empty", +"is_compliant is true", +"trail.compliance_status.artifacts_statuses.artifact.attestations_statuses.npm-bump-prs.attestation_data", +"open_bump_prs is empty", +"trail", +"compliance_status.attestations_statuses.coverage-verification.attestation_data.hunks_acknowledged equals compliance_status.attestations_statuses.coverage-verification.attestation_data.hunks_total", +"compliance_status.attestations_statuses.claude-review.attestation_data.model_type does not equal compliance_status.attestations_statuses.gemini-review.attestation_data.model_type", +"compliance_status.attestations_statuses.cross-model-comparison.attestation_data.both_models_ran is true", +"count of compliance_status.attestations_statuses.classifier.attestation_data.personas_dispatched is at least 1", +"compliance_status.attestations_statuses.verdict.attestation_data.moderator_status is one of \"resolved\", \"no_disagreements\", \"single_reviewer\"", +"compliance_status.attestations_statuses.claude-review.attestation_data.degraded is one of true, false", +"compliance_status.attestations_statuses.gemini-review.attestation_data.degraded is one of true, false", +"compliance_status.attestations_statuses.finding-verifier.attestation_data.degraded is one of true, false", +"count of compliance_status.attestations_statuses.finding-verifier.attestation_data.per_finding_records plus compliance_status.attestations_statuses.finding-verifier.attestation_data.findings_unverified_cost_cap equals compliance_status.attestations_statuses.finding-verifier.attestation_data.findings_in", +"compliance_status.attestations_statuses.finding-verifier.attestation_data.degraded equals compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.verifier_degraded", +"compliance_status.attestations_statuses.final-verdict.is_compliant is true", +"compliance_status.attestations_statuses.finding-verifier.attestation_data.verifier_enabled is true", +"compliance_status.attestations_statuses.review-context-manifest.attestation_id is not empty", +"compliance_status.attestations_statuses.preflight.attestation_data.reviewer_source_sha matches \"^[0-9a-f]{7,40}$\"", +"compliance_status.attestations_statuses.preflight.attestation_data.config_tree_sha matches \"^[0-9a-f]{40}$\"", +"compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.uncovered is a list", +"compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.cost_capped is one of true, false", +"count of compliance_status.attestations_statuses.coverage-verification.attestation_data.files is at least compliance_status.attestations_statuses.coverage-verification.attestation_data.total_files", +"count of compliance_status.attestations_statuses.findings.attestation_data.raw where source is \"first_pass\" equals compliance_status.attestations_statuses.claude-review.attestation_data.total_findings plus compliance_status.attestations_statuses.gemini-review.attestation_data.total_findings", +"compliance_status.attestations_statuses.findings.attestation_data.truncated is false", +"sum of compliance_status.attestations_statuses.final-verdict.attestation_data.cost.by_stage.usd equals compliance_status.attestations_statuses.final-verdict.attestation_data.cost.total_usd within 0.000001", +"sum of compliance_status.attestations_statuses.final-verdict.attestation_data.cost.by_stage.usd where stage is \"verifier\" equals compliance_status.attestations_statuses.finding-verifier.attestation_data.cost_usd within 0.000001", +"every compliance_status.attestations_statuses.classifier.attestation_data.personas_dispatched as persona", +"compliance_status.attestations_statuses.preflight.attestation_data.persona_blob_shas named by persona matches \"^[0-9a-f]{40}$\"", +"trail.compliance_status.attestations_statuses.finding-verifier.attestation_data.per_finding_records", +"decision is one of \"CONFIRMED\", \"UNSURE\", \"SUGGESTION\", \"REFUTED\"", +"trail.compliance_status.attestations_statuses.finding-verifier.attestation_data.per_finding_records where decision is \"CONFIRMED\"", +"some tools_called.ok is true", +"trail where compliance_status.attestations_statuses.verdict.attestation_data.moderator_status is \"resolved\"", +"count of compliance_status.attestations_statuses.verdict.attestation_data.debate_resolutions is at least compliance_status.attestations_statuses.verdict.attestation_data.debate_disagreements_found", +"trail where compliance_status.attestations_statuses.verdict.attestation_data.moderator_status does not match \"^(resolved|no_disagreements|single_reviewer|disabled)$\"", +"compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.moderator_failed is true", +"trail.compliance_status.attestations_statuses.verdict.attestation_data.debate_resolutions", +"reasoning is not empty", +"trail where count of compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.uncovered is at least 1 and compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.cost_capped is false", +"compliance_status.attestations_statuses.second-pass.attestation_data.files_targeted contains all of compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.uncovered", +"trail where count of compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.uncovered is at least 1 and compliance_status.attestations_statuses.coverage-verification.attestation_data.before_second_pass.cost_capped is true", +"compliance_status.attestations_statuses.second-pass.attestation_data.skip_reason is \"cost_cap\"", +"trail.compliance_status.attestations_statuses.coverage-verification.attestation_data.files", +"status is \"reviewed_ok\"", +"trail where compliance_status.attestations_statuses.second-pass.attestation_data.triggered is true", +"count of compliance_status.attestations_statuses.findings.attestation_data.raw where source is \"second_pass\" equals compliance_status.attestations_statuses.second-pass.attestation_data.findings_added", +"trail.compliance_status.attestations_statuses.findings.attestation_data.raw", +"disposition is one of \"posted\", \"merged\", \"refuted_by_codebase_check\", \"suppressed_by_moderator\", \"dropped_by_citation_check\", \"dropped_out_of_diff\", \"refuted_by_verifier\", \"demoted_to_suggestion\"", +"trail.compliance_status.attestations_statuses.findings.attestation_data.posted where source is not \"empty_file\"", +"in_file is true", +"trail where count of compliance_status.attestations_statuses.claude-review.attestation_data.personas_ran is at least 1", +"compliance_status.attestations_statuses.classifier.attestation_data.personas_dispatched equals compliance_status.attestations_statuses.claude-review.attestation_data.personas_ran", +"trail where count of compliance_status.attestations_statuses.gemini-review.attestation_data.personas_ran is at least 1", +"compliance_status.attestations_statuses.classifier.attestation_data.personas_dispatched equals compliance_status.attestations_statuses.gemini-review.attestation_data.personas_ran", +"trail where compliance_status.attestations_statuses.claude-review.attestation_data.degraded is true and compliance_status.attestations_statuses.claude-review.attestation_data.personas_ran is empty", +"some compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.degraded_reasons is one of \"single_model\", \"no_models\"", +"trail where compliance_status.attestations_statuses.gemini-review.attestation_data.degraded is true and compliance_status.attestations_statuses.gemini-review.attestation_data.personas_ran is empty", +"some compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.degraded_reasons is one of \"single_model\", \"no_models\"", +"trail where compliance_status.attestations_statuses.claude-review.attestation_data.degraded is true and compliance_status.attestations_statuses.gemini-review.attestation_data.degraded is true and compliance_status.attestations_statuses.claude-review.attestation_data.personas_ran is empty and compliance_status.attestations_statuses.gemini-review.attestation_data.personas_ran is empty", +"compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.degraded_reasons contains \"no_models\"", +"trail where compliance_status.attestations_statuses.claude-review.attestation_data.degraded is true and count of compliance_status.attestations_statuses.claude-review.attestation_data.personas_ran is at least 1", +"compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.degraded_reasons contains \"persona_failed\"", +"trail where compliance_status.attestations_statuses.gemini-review.attestation_data.degraded is true and count of compliance_status.attestations_statuses.gemini-review.attestation_data.personas_ran is at least 1", +"compliance_status.attestations_statuses.final-verdict.attestation_data.inputs.degraded_reasons contains \"persona_failed\"", +"batches", +"allergens does not contain \"nuts\"", +"bake.temp_c is between 175 and 200", +"bake.minutes is between 25 and 40", +"deployments where environment is \"prod\"", +"approved_by is not empty", +"release.requirements", +"document_url is not empty", +"assessed contains all of \"system_security\", \"data_protection\", \"privacy\"", +"approved_by is not empty", +"approved_at is before $input.release.deployed_at", +"deployments where environment is \"prod\" and change_type is \"new_development\"", +"impact_analysis.document_url is not empty", +"impact_analysis.approved_by_role is \"business_owner\"", +"impact_analysis.approved_at is before started_at", +"deployments where environment is \"prod\" and change_type is \"normal\"", +"deployments where environment is \"prod\"", +"every pull_requests.ticket.key matches \"^[A-Z][A-Z0-9]+-[0-9]+$\"", +"every pull_requests.ticket.status exists", +"deployments where environment is \"prod\"", +"artifact.source.repo_url matches $params.repository_patterns", +"artifact.source.commit_sha is not empty", +"artifact.source.branch_protected is true", +"deployments where environment is \"prod\"", +"source.branch_protection.enabled is true", +"source.branch_protection.require_signed_commits is true", +"source.branch_protection.allow_force_pushes is false", +"every source.commits.verified is true", +"deployments where environment is \"prod\"", +"artifact.attestations.secrets-scan.status is \"COMPLETE\"", +"artifact.attestations.secrets-scan.findings is 0", +"artifacts", +"provenance.builder_id is in $params.trusted_builders", +"signature.verified is true", +"provenance.subject_digest equals fingerprint", +"artifacts", +"sbom.format is one of \"spdx\", \"cyclonedx\"", +"every sbom.components.name is not empty", +"every sbom.components.version is not empty", +"deployments where environment is \"prod\"", +"iac_scan.status is \"COMPLETE\"", +"iac_scan.critical_or_high_findings is 0", +"artifact.attestations named by each of $params.required_scans", +"status is \"COMPLETE\"", +"is_compliant is true", +"artifact.attestations.pull-request.pull_requests", +"state is \"MERGED\"", +"some approvers where state is \"APPROVED\" as approver", +"approver.username does not equal author", +"artifact.vulnerabilities where status is \"open\"", +"remediate_by is on or after $input.evaluated_at", +"environment.missing_patches where severity is one of \"critical\", \"high\"", +"install_by is on or after $input.evaluated_at", +"artifact.attestations named by each of $params.required_tests where $input.deployment.change_type is \"new_development\"", +"is_compliant is true", +"finished_at is before $input.deployment.started_at", +"artifact.attestations named by each of $params.required_tests where $input.deployment.change_type is \"normal\"", +"deployments where environment is \"prod\" and includes_data_migration is true", +"migration_test.is_compliant is true", +"migration_test.migrated_records equals migration_test.source_records", +"migration_test.mismatched_records is 0", +"migration_test.finished_at is before started_at", +"artifact.attestations.uat", +"result is \"passed\"", +"$input.artifact.developers does not contain tested_by", +"report_url is not empty", +"approved_by is not empty", +"approved_at is on or after tested_at", +"approved_at is before $input.artifact.production_deployment.started_at", +"artifact.attestations.uat", +"result is \"passed\"", +"every $input.artifact.developers as developer", +"developer does not equal tested_by", +"report_url is not empty", +"approved_by is not empty", +"approved_at is on or after tested_at", +"approved_at is before $input.artifact.production_deployment.started_at", +"environments where type is one of \"development\", \"test\", \"staging\"", +"data_source is one of \"synthetic\", \"masked\"", +"connects_to_production_data is false", +"release.defects", +"defect where severity is \"critical\" and status is one of \"open\", \"in_progress\"", +"severity is one of \"critical\", \"high\", \"medium\", \"low\"", +"assignee is not empty", +"impact_review.reviewed_by is not empty", +"impact_review.reviewed_at is before $input.release.deployed_at", +"deployments where environment is \"prod\" and change_type is \"normal\"", +"every pull_requests.commits as commit", +"commit.author does not equal deployed_by", +"every pull_requests as pr, some pr.approvers where state is \"APPROVED\" as approver", +"approver.username does not equal pr.author", +"deployments where environment is \"prod\" and change_type is \"new_development\"", +"some approvals where role is \"business_owner\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"some approvals where role is \"qa\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"some approvals where role is \"development\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"deployments where environment is \"prod\" and change_type is \"normal\"", +"some approvals where role is \"business_owner\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"some approvals where role is \"qa\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"some approvals where role is \"development\" and decision is \"approved\" as approval", +"approval.approved_at is before started_at", +"deployments where environment is \"prod\" and change_type is \"new_development\"", +"rollback_plan.document_url is not empty", +"rollback_plan.approved_at is before started_at", +"previous_artifact.fingerprint is not empty", +"backup.taken_at is before started_at", +"deployments where environment is \"prod\" and change_type is \"normal\"", +"deployments where environment is \"prod\"", +"some artifact.environment_history where environment is \"staging\" as run", +"run.deployed_at is before started_at", +"deployed_with_identity is in $params.production_identities", +"snapshot.running", +"fingerprint is in $input.approved_fingerprints", +"deployment_id is not empty", +"environments where user_facing is true", +"latest_release", +"running_version equals $input.latest_release.version", +"release_notes_url is not empty", +"announcement_url is not empty", +"deployments where environment is \"prod\" and change_type is \"emergency\"", +"retro_approval.approved_by is not empty", +"retro_approval.approved_by does not equal deployed_by", +"retro_approval.approved_at is after started_at", +"deployments where environment is \"prod\" and change_type is \"emergency\"", +"review.lessons_learned is not empty", +"every review.incidents.action_ticket, if any, is not empty", +"review.approved_by_role is \"business_owner\"", +"review.approved_at is after started_at", +"deployments where environment is \"prod\" and change_type is \"new_development\"", +"review.lessons_learned is not empty", +"every review.incidents.action_ticket, if any, is not empty", +"review.approved_by_role is \"business_owner\"", +"review.approved_at is after started_at", +"deployments where environment is \"prod\" and change_type is \"normal\"", +"review.lessons_learned is not empty", +"every review.incidents.action_ticket, if any, is not empty", +"review.test_accounts_in_production is empty", +"review.completed_at is after started_at", +"application.security_tests named by each of $params.required_tests", +"due_by is on or after $input.evaluated_at", +"environment is one of \"prod\", \"prod-equivalent\"", +"open_critical_or_high_findings is 0", +"vulnerabilities where severity is \"critical\" and status is \"fixed\"", +"root_cause_analysis.document_url is not empty", +"every root_cause_analysis.actions.ticket is not empty", +"pull_requests where author is in $params.external_contributors", +"some approvers.username where state is \"APPROVED\" is in $params.internal_staff", +"developers", +"training_valid_until is on or after $input.evaluated_at", +"count of x is at least y", +"count of x where field is \"v\" equals y plus z", +"sum of x.usd where stage is \"v\" equals total within 0.000001", +"x contains all of y" +] +} diff --git a/spec/sentences.abnf b/spec/sentences.abnf index 44e5c71..373b836 100644 --- a/spec/sentences.abnf +++ b/spec/sentences.abnf @@ -20,7 +20,7 @@ for-item = quantifier WS path [WS where] [if-any] WS "as" WS name name = bare-key ; A subject's from: line. -from-line = path [WS "named by each of" WS path] [WS where] +from-line = path [WS "named by each of" WS right-path] [WS where] ; where keeps the items that pass every condition. where = "where" WS condition *(WS "and" WS condition) @@ -28,7 +28,7 @@ condition = path WS phrase / derived WS compare-phrase ; Derived values: count of, sum of, and terms added with plus. ; Only the last term on the left can carry a where, and none on the right. -derived = ("count" / "sum") WS "of" WS plain-path +derived = ("count" / "sum") WS "of" WS path term = right-path / derived / number derived-left = derived [WS where] / 1*(term WS "plus" WS) (term / derived WS where) @@ -44,7 +44,7 @@ compare-phrase = within-phrase / order-phrase / between-phrase / is-phrase is-phrase = ("is not" / "does not equal") WS operand / ("is equal to" / "equals" / "is") WS operand -within-phrase = ("equals" / "is") WS operand WS "within" WS number +within-phrase = ("is equal to" / "equals" / "is") WS operand WS "within" WS number order-phrase = ("is at least" / "is at most" / "is more than" / "is less than") WS operand between-phrase = "is between" WS number WS "and" WS number one-of-phrase = ("is one of" / "is in") WS list diff --git a/spec/sentences.md b/spec/sentences.md index b7eca15..563327c 100644 --- a/spec/sentences.md +++ b/spec/sentences.md @@ -1,6 +1,6 @@ # How checks are written as sentences -Draft 0.1, a proposal from [#174](https://github.com/kosli-dev/ergo/issues/174). Nothing implements it yet. This file says how a check written as a one-line sentence, like `approved_by is not empty`, reads into the [model](semantics.md#the-model-of-a-check), what counts as written wrong, and how the report prints it. What a check means once it's read is in [semantics.md](semantics.md), as for today's syntax in [syntax.md](syntax.md). +Draft 0.1, a proposal from [#174](https://github.com/kosli-dev/ergo/issues/174). `ergo.rego` reads a sentence into its model, and prints it back, but no report runs on one yet. This file says how a check written as a one-line sentence, like `approved_by is not empty`, reads into the [model](semantics.md#the-model-of-a-check), what counts as written wrong, and how the report prints it. What a check means once it's read is in [semantics.md](semantics.md), as for today's syntax in [syntax.md](syntax.md). [`sentences-corpus.md`](sentences-corpus.md) has every check from sdlc-policies, the server's demo and npm-bump policies, pr-reviewer, ergo's examples and Tore's DEV controls written both ways. Every sentence in it parses exactly one way with the phrases below. The phrases, the tally and the misfits come from it. @@ -111,7 +111,7 @@ The map is the only way to write `subjects`, and a simple subject is a name with ## Values -**[sentence.value.scalar]** A value is written as JSON writes a scalar: a string in double quotes, a number, `true`, `false` or `null`. A string can also be in single quotes, which keeps backslashes as written, so a regular expression doesn't need them doubled: `'^\s*FROM'` is `"^\\s*FROM"`. Inside single quotes, `\'` is a quote and `\\` a backslash. +**[sentence.value.scalar]** A value is written as JSON writes a scalar: a string in double quotes, a number, `true`, `false` or `null`. A string can also be in single quotes, which keeps backslashes as written, so a regular expression doesn't need them doubled: `'^\s*FROM'` is `"^\\s*FROM"`. Inside single quotes, `\'` is a quote and `\\` a backslash. A number has to be `0` or have a magnitude between `2.2250738585072014e-308` and `1.7976931348623157e308`, as a number in a policy does, so `x is at least 1e400` is written wrong. **[sentence.value.list]** A list of values is the values separated by `, `, with no brackets: `is one of "staging", "prod"`. A value can hold a comma, `matches "^fix(, |: )"`, because commas inside quotes don't separate. @@ -334,20 +334,20 @@ Read as `is` or `equals`, and printed as one of them. With it in the grammar, ev **[sentence.written_wrong.slot]** A sentence that doesn't parse is written wrong, as a check that can't be read into the model is today. Its rows fail with `ill_formed` and `$well_formed` lists it. Because the grammar is flat, the message can name the slot, what it expected and the column: -| sentence | `$well_formed` says | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `approved_by is empy` | `unknown phrase "is empy" at column 13, expected one of: is empty, is not empty, is one of, ...` | -| `environment is "prod` | `unclosed quote at column 16` | -| `every commits.signed` | `nothing after the path at column 21: expected a phrase, like "is" or "exists"` | -| `sum of stages.usd equals total within` | `"within" needs a number after it` | -| `some approvers, if any, is not empty` | `if any can't go with some` | -| `every pull_requests some approvers.state is "APPROVED"` | `two quantifiers in one sentence: put them on a for line` | +| sentence | `$well_formed` says | +| -------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `environment is "prod` | `unclosed quote at column 16` | +| `every commits.signed` | `nothing after the path at column 21: expected a phrase, like "is" or "exists"` | +| `sum of stages.usd equals total within` | `"within" needs a number after it` | +| `some approvers, if any, is not empty` | `if any can't go with some` | +| `every pull_requests some approvers.state is "APPROVED"` | `two quantifiers in one sentence: put them on a for line` | +| `x is at least 1e400` | `number out of range: 1e400` | -`environment is prod` parses, as a comparison with a field called `prod`, unless the subject has no such field. That's the one mistake the grammar can't catch, so the message for a missing field in an `is` argument should suggest quotes. +`environment is prod` and `approved_by is empy` parse, as comparisons with a field called `prod` or `empy`, unless the subject has no such field. That's the one mistake the grammar can't catch, so the message for a missing field in an `is` argument should suggest quotes. ## Printing the sentence -**[sentence.print.canonical]** The report's `expression` is the sentence printed from its parse in one spelling: single spaces, `is` and `is not` before a value and `equals` and `does not equal` before a field, `is one of` before written values and `is in` before a path, no alias, a string in single quotes when it holds a backslash and no single quote and in double quotes with JSON escapes otherwise, a dot in front of a right-side field only where [sentence.path.argument](#paths) needs one, numbers in their plain form, and values separated by `, `. Parsing the printed sentence gives the same parse. +**[sentence.print.canonical]** The report's `expression` is the sentence printed from its parse in one spelling: single spaces, `is` and `is not` before a value and `equals` and `does not equal` before a field, `is one of` before written values and `is in` before a path, no alias, a string in single quotes when it holds a backslash but no single quote, no two backslashes in a row, no backslash at the end and no control character, and in double quotes with JSON escapes otherwise, a dot in front of a right-side field only where [sentence.path.argument](#paths) needs one, numbers in their plain form, and values separated by `, `. Parsing the printed sentence gives the same parse. **[sentence.print.order]** `where` conditions and assertions joined with `and` print in the order the author wrote them. The report stays byte for byte the same when the same policy runs again on the same input. That rule is for the same policy, not for two policies that mean the same: `A and B` and `B and A` print differently, because the order changes neither what passes nor the cause, and sorting would have to reach `assert` lists too, and could never reach `for` items, whose order is their nesting. A row's `inputs` stay sorted by name, as today, because they're values that were read, not a sentence. diff --git a/spec/sentences_corpus.py b/spec/sentences_corpus.py new file mode 100644 index 0000000..ed7dc9f --- /dev/null +++ b/spec/sentences_corpus.py @@ -0,0 +1,30 @@ +import json +import re +import sys + +rows = [l for l in open("spec/sentences-corpus.md", encoding="utf-8") if l.startswith("| `")] +lines = [] +for row in rows: + cells = [c.strip() for c in row.strip().strip("|").split(" | ")] + if len(cells) < 3: + continue + for part in cells[2].split("
"): + m = re.match(r"^`(.*)`$", part.strip()) + if not m or m.group(1) == "assert:": + continue + text = m.group(1).replace("\\|", "|") + if text.startswith("for: "): + text = text[5:] + elif text.startswith("assert: "): + text = text[8:] + elif text.startswith(" - "): + text = text[4:] + lines.append(text) + +want = json.dumps({"sentence_corpus": lines}, indent=0, ensure_ascii=False) + "\n" +path = "spec/sentences-corpus.json" +if "--check" in sys.argv: + if open(path, encoding="utf-8").read() != want: + sys.exit(f"{path} is out of date: run python3 spec/sentences_corpus.py") +else: + open(path, "w", encoding="utf-8").write(want)