From 8a11cafffc8b11ecba58f4b18f366dc12126bd9d Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:25:12 +0200 Subject: [PATCH 01/15] spec: keep the corpus sentences as JSON for the tests spec/sentences_corpus.py pulls every sentence, for line and from line out of sentences-corpus.md into sentences-corpus.json, which OPA loads as data.spec.sentence_corpus, so a test can parse each one. Co-Authored-By: Claude Fable 5.1 --- spec/README.md | 4 +- spec/sentences-corpus.json | 310 +++++++++++++++++++++++++++++++++++++ spec/sentences.md | 2 +- spec/sentences_corpus.py | 30 ++++ 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 spec/sentences-corpus.json create mode 100644 spec/sentences_corpus.py 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.md b/spec/sentences.md index b7eca15..e41a6f1 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. 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) From ba74c3ab32b9597106212268398c9a6d7f77bc7a Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:25:12 +0200 Subject: [PATCH 02/15] core: read a sentence into a check Adds the parser for spec/sentences.abnf to ergo.rego: _sentence reads a check, _for_line a for line and _from_line a from line, each into a JSON shape that mirrors the sentence, or into a list of problems when the line is written wrong. _s_check_text and its two siblings print a parsed line back in the spelling the report will use. The grammar is flat, so the parser is layered regular expressions: one splits the assertions, one reads an assertion's parts, one reads the conditions of a where, and the phrase is picked by its leading words. The patterns are built once per call in _s_patterns and passed down, because Wasm rebuilds a constant rule at every reference, and the phrase union is factored by argument kind to keep them small. Every corpus line parses and prints back unchanged, in 11 seconds for all 306 under Wasm. Nothing calls it from ergo.report yet. Co-Authored-By: Claude Fable 5.1 --- ergo.rego | 558 +++++++++++++++++++++++++++++++++++++++++++++++++ ergo_test.rego | 189 +++++++++++++++++ 2 files changed, 747 insertions(+) diff --git a/ergo.rego b/ergo.rego index e6146e2..c98dcb6 100644 --- a/ergo.rego +++ b/ergo.rego @@ -3927,3 +3927,561 @@ _derived_placement_problem(node, kinds) := {concat("", [_side_form(side), " can' kinds[count(kinds) - 1] == "where" "substitute" in object.keys(node) } + +_s_patterns := { + "assertions": concat("", [`(?:^| +and +)(`, assertion, `)`]), + "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("", [`(?:^| +and +)(`, cond, `)`]), + "cond": concat("", [`^(`, left, `) +(`, phrase, `)$`]), + "leads": concat("", [`^(?:`, concat("|", [concat("", [`(`, ph[1], `)`]) | 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-9]+)\]`]), + "terms": concat("", [`(?:^| +plus +)(`, term, `)`]), + "number": concat("", [`^`, num, `$`]), + "derived": concat("", [`^(count|sum) +of +(`, plain, `)$`]), + "value": concat("", [`^`, value, `$`]), + "values": concat("", [`^`, values, `$`]), + "each_value": value, + "range": concat("", [`^(`, num, `) +and +(`, num, `)$`]), + "operand_within": concat("", [`^(`, operand, `)(?: +within +(`, num, `))?$`]), + "quotes": concat("", [`^(?:`, dq, `|`, sq, `|[^"']+)*$`]), +} if { + key := `[A-Za-z_][A-Za-z0-9_-]*` + dq := `"(?:[^"\\]|\\.)*"` + sq := `'(?:[^'\\]|\\.)*'` + 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-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 +`, plain]) + term := concat("", [`(?:`, derived, `|`, rpath, `|`, num, `)`]) + terms := concat("", [term, `(?: +plus +`, term, `)*`]) + left := concat("", [`(?:`, path, `|`, terms, `)`]) + value := concat("", [`(?:`, dq, `|`, sq, `|`, num, `|(?:true|false|null)\b)`]) + values := concat("", [value, `(?:,\s*`, value, `)*`]) + operand := concat("", [`(?:`, num, `(?: +plus +`, term, `)+|`, value, `|(?:`, derived, `|`, rpath, `)(?: +plus +`, term, `)*)`]) + list := concat("", [`(?:`, values, `|`, rpath, `)`]) + type := `(?:list|string|number|boolean|object)\b` + 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\b`, ``, "none"], + ["is a", `is +an? +`, type, "type"], + ["is not empty", `is +not +empty\b`, ``, "none"], + ["is empty", `is +empty\b`, ``, "none"], + ["does not exist", `does +not +exist\b`, ``, "none"], + ["exists", `exists\b`, ``, "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": sort(_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": sort(_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": sort(_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) := to_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) := 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_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([_s_key_of(key)], steps)} if { + not startswith(key, "$") + not startswith(key, ".") +} + +_s_start_of(key, steps) := {"steps": array.concat([_s_key_of(substring(key, 1, -1))], steps), "dotted": true} 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 regex.find_all_string_submatch_n(pat.steps, text, -1)] + +_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, "$") + +_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.leads, 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": to_number(m[1]), "max": to_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(digits) := {"within": to_number(digits)} if digits != "" + +_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) := json.unmarshal(text) if not startswith(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_sq_char(`\'`) := "'" + +_s_sq_char(`\\`) := `\` + +_s_sq_char(c) := c if not c in {`\'`, `\\`} + +_s_sentence_problems(parsed) := (((({concat("", [key, " is a word of the grammar: write .", key]) | + some node in _s_nodes(parsed) + is_object(node) + "steps" in object.keys(node) + not "start" in object.keys(node) + not object.get(node, "dotted", false) + key := node.steps[0] + key in _s_words +} | {"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 +}) | {concat("", [node.as, " can't be a name"]) | + some node in _s_nodes(parsed) + is_object(node) + name := object.get(node, "as", "") + _s_bad_name(name) +}) | {"a where after plus goes on a count of or a sum of" | + some node in _s_nodes(parsed) + is_object(node) + "terms" in object.keys(node) + "where" in object.keys(node) + last := node.terms[count(node.terms) - 1] + not _s_derived_term(last) +}) | {"min above max" | + some node in _s_nodes(parsed) + is_object(node) + node.op == "is between" + node.min > node.max +}) | {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 _s_valid_pattern(v) +} + +_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) := ["unclosed quote"] if { + not regex.match(pat.quotes, text) +} else := problems if { + problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) + count(problems) > 0 +} else := ["two quantifiers in one sentence: put them on a for line"] if { + regex.match(`^(?:every|some) [^ ]+ (?:every|some) `, 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, _) := "." if key in _s_words + +_s_dot(key, "right") := "." if { + not key in _s_words + not _s_bare_key(key) +} + +_s_dot(key, _) := "" if { + not key in _s_words + _s_bare_key(key) +} + +_s_dot(key, "left") := "" if { + not key in _s_words + 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 { + is_string(v) + contains(v, `\`) + not contains(v, "'") +} + +_s_value_text(v) := _json_text(v) if not _s_single_quoted(v) + +_s_single_quoted(v) if { + is_string(v) + contains(v, `\`) + not contains(v, "'") +} diff --git a/ergo_test.rego b/ergo_test.rego index 0a3c224..89138cc 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -7832,3 +7832,192 @@ 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_reads_one_way_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] is 1`).assertions[0].path == {"start": "params", "steps": ["k", "b c", {"index": 2}]} + _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 == {"dotted": true, "steps": ["count"]} + _read(`x is ."$schema"`).assertions[0].phrase == {"op": "is", "terms": [{"dotted": true, "steps": ["$schema"]}]} +} + +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]} +} + +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 '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] is 1`) == "b named by persona[2] 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(`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" +} + +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_sentence_written_wrong_says_what_is_wrong if { + _read(`x is "a`) == {"problems": ["unclosed quote"]} + _read(`x is 'a`) == {"problems": ["unclosed quote"]} + _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 after plus goes on a count of or a sum of"]} + _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"]} +} From 72037dfaeb1a1f3b1e545a9790a96ccfdabe1e1b Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:41:50 +0200 Subject: [PATCH 03/15] spec: say when a string prints in single quotes, and let is equal to take within Single quotes are only safe for a string with a backslash when it has no single quote, no two backslashes in a row, no backslash at the end and no control character, or parsing the printed sentence gives a different value. The written-wrong table loses the is empy row, since that sentence parses as a comparison with a field. Co-Authored-By: Claude Fable 5.1 --- spec/sentences.abnf | 2 +- spec/sentences.md | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/spec/sentences.abnf b/spec/sentences.abnf index 44e5c71..beaa88f 100644 --- a/spec/sentences.abnf +++ b/spec/sentences.abnf @@ -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 e41a6f1..f9fc132 100644 --- a/spec/sentences.md +++ b/spec/sentences.md @@ -334,20 +334,19 @@ 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` | -`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. From d9a37be16b0867dbf64e0444852e878db86580f0 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:41:50 +0200 Subject: [PATCH 04/15] core: fix what the review of the sentence parser found An index with a leading zero and a string with a bad escape stopped the evaluation instead of giving a problem. A value or key starting with true, false, null or empty was rejected, because the split took the first match; each piece now has to end at the separator or the end. The parser accepted a where on a plain path, a lone number on the left, a phrase other than a comparison after count of, sum of or plus, and a for name given twice; each is a problem now. A quoted grammar word is a key, and the dot in front of a key is spelling, not part of the parse, so the same check always reads the same. A position written in brackets at the end of a path reads and prints as item n of, and a string prints in single quotes only when that reads back to the same value. The messages for an unclosed quote, a missing phrase, a missing number after within and an unknown now say what the spec promises. Co-Authored-By: Claude Fable 5.1 --- ergo.rego | 158 ++++++++++++++++++++++++++++++------------------- ergo_test.rego | 58 +++++++++++++++--- 2 files changed, 147 insertions(+), 69 deletions(-) diff --git a/ergo.rego b/ergo.rego index c98dcb6..29fafae 100644 --- a/ergo.rego +++ b/ergo.rego @@ -3929,19 +3929,19 @@ _derived_placement_problem(node, kinds) := {concat("", [_side_form(side), " can' } _s_patterns := { - "assertions": concat("", [`(?:^| +and +)(`, assertion, `)`]), + "assertions": concat("", [`(`, assertion, `)(?: +and +|$)`]), "assertion": concat("", [`^(?:(every|some) +(`, path, `)|(`, left, `))(?: +where +(`, conds, `))?(, +if +any,)? +(`, phrase, `)$`]), - "for_items": concat("", [`(?:^|, +)(`, for_item, `)`]), + "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("", [`(?:^| +and +)(`, cond, `)`]), + "conds": concat("", [`(`, cond, `)(?: +and +|$)`]), "cond": concat("", [`^(`, left, `) +(`, phrase, `)$`]), - "leads": concat("", [`^(?:`, concat("|", [concat("", [`(`, ph[1], `)`]) | some ph in phrases]), `)`]), + "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-9]+)\]`]), - "terms": concat("", [`(?:^| +plus +)(`, term, `)`]), + "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 +(`, plain, `)$`]), "value": concat("", [`^`, value, `$`]), @@ -3949,14 +3949,17 @@ _s_patterns := { "each_value": value, "range": concat("", [`^(`, num, `) +and +(`, num, `)$`]), "operand_within": concat("", [`^(`, operand, `)(?: +within +(`, num, `))?$`]), - "quotes": concat("", [`^(?:`, dq, `|`, sq, `|[^"']+)*$`]), + "quotes": `^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"']+)*`, + "strings": `"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'`, + "string": concat("", [`^(?:`, dq, `|`, sq, `)$`]), + "quantified_path": concat("", [`^(?:(?:every|some) +)?`, path, `$`]), } if { key := `[A-Za-z_][A-Za-z0-9_-]*` - dq := `"(?:[^"\\]|\\.)*"` - sq := `'(?:[^'\\]|\\.)*'` + 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-9]+)\]`]) + 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 +` @@ -3967,20 +3970,20 @@ _s_patterns := { term := concat("", [`(?:`, derived, `|`, rpath, `|`, num, `)`]) terms := concat("", [term, `(?: +plus +`, term, `)*`]) left := concat("", [`(?:`, path, `|`, terms, `)`]) - value := concat("", [`(?:`, dq, `|`, sq, `|`, num, `|(?:true|false|null)\b)`]) - values := concat("", [value, `(?:,\s*`, value, `)*`]) + 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)\b` + 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\b`, ``, "none"], + ["is a non-empty string", `is +a +non-empty +string`, ``, "none"], ["is a", `is +an? +`, type, "type"], - ["is not empty", `is +not +empty\b`, ``, "none"], - ["is empty", `is +empty\b`, ``, "none"], - ["does not exist", `does +not +exist\b`, ``, "none"], - ["exists", `exists\b`, ``, "none"], + ["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"], @@ -4115,10 +4118,17 @@ _s_term_of(text, pat) := to_number(text) if { m := regex.find_all_string_submatch_n(pat.derived, text, 1)[0] } else := _s_path_of(text, pat) -_s_path_of(text, pat) := object.union(_s_position_of(m[1], m[2]), _s_start_of(m[3], _s_steps_of(m[4], pat))) if { +_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 != "" @@ -4129,18 +4139,24 @@ _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), "dotted": true} if 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 regex.find_all_string_submatch_n(pat.steps, text, -1)] +_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] != "" @@ -4156,10 +4172,15 @@ _s_ref_of(text) := {"start": "params", "steps": [_s_key_of(substring(text, 8, -1 _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, "$") +_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.leads, text, 1)[0] + 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] } @@ -4184,7 +4205,7 @@ _s_args_of("operand_within", text, pat) := object.union(_s_operand_of(m[1], pat) _s_within_of("") := {} -_s_within_of(digits) := {"within": to_number(digits)} if digits != "" +_s_within_of(number) := {"within": to_number(number)} if number != "" _s_operand_of(text, pat) := {"value": _s_value_of(text)} if { regex.match(pat.value, text) @@ -4202,31 +4223,45 @@ _s_sq_char(`\\`) := `\` _s_sq_char(c) := c if not c in {`\'`, `\\`} -_s_sentence_problems(parsed) := (((({concat("", [key, " is a word of the grammar: write .", key]) | +_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) - "steps" in object.keys(node) - not "start" in object.keys(node) - not object.get(node, "dotted", false) - key := node.steps[0] - key in _s_words -} | {"if any can't go with some" | + "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 -}) | {concat("", [node.as, " can't be a name"]) | +}) | {"a where goes after every, some, count of or sum of" | some node in _s_nodes(parsed) + node != parsed is_object(node) - name := object.get(node, "as", "") - _s_bad_name(name) -}) | {"a where after plus goes on a count of or a sum of" | + "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) - "where" in object.keys(node) - last := node.terms[count(node.terms) - 1] - not _s_derived_term(last) + 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) @@ -4256,13 +4291,24 @@ _s_bad_name(name) if name in {"params", "input"} _s_nodes(x) := {y | walk(x, [_, y])} -_s_problems(text, pat) := ["unclosed quote"] if { - not regex.match(pat.quotes, text) +_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 { + invalid := [concat("", ["invalid string ", m[0]]) | some m in regex.find_all_string_submatch_n(pat.strings, text, -1); not regex.match(pat.string, m[0])] + count(invalid) > 0 } else := problems if { problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) count(problems) > 0 +} else := names if { + names := [concat("", ["unknown name ", m[1]]) | some m in regex.find_all_string_submatch_n(`(?:^|[ ,\[])(\$[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) [^ ]+ (?:every|some) `, text) + 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 { @@ -4356,22 +4402,13 @@ _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, _) := "." if key in _s_words +_s_dot(key, side) := "." if { + _s_needs_dot(key, side) +} else := "" -_s_dot(key, "right") := "." if { - not key in _s_words - not _s_bare_key(key) -} +_s_needs_dot(key, _) if key in _s_words -_s_dot(key, _) := "" if { - not key in _s_words - _s_bare_key(key) -} - -_s_dot(key, "left") := "" if { - not key in _s_words - not _s_bare_key(key) -} +_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) @@ -4473,15 +4510,14 @@ _s_operand_text(ph) := _s_path_text(ph.path, "right") if "path" in object.keys(p _s_operand_text(ph) := "" if object.keys(ph) & {"value", "terms", "values", "path"} == set() _s_value_text(v) := concat("", ["'", v, "'"]) if { - is_string(v) - contains(v, `\`) - not contains(v, "'") -} - -_s_value_text(v) := _json_text(v) if not _s_single_quoted(v) + _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 89138cc..2d44bb8 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -7844,7 +7844,7 @@ test_a_sentence_reads_into_one_assertion_per_and if { ]} } -test_every_corpus_line_reads_one_way_and_prints_back_unchanged if { +test_every_corpus_line_parses_and_prints_back_unchanged if { every text in data.spec.sentence_corpus { _prints_back(text) } @@ -7888,7 +7888,10 @@ test_a_count_with_a_where_keeps_the_where_on_the_assertion_and_plus_inside_the_c } test_a_path_reads_params_input_lookups_positions_and_quoted_keys if { - _read(`$params.k."b c"[2] is 1`).assertions[0].path == {"start": "params", "steps": ["k", "b c", {"index": 2}]} + _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"]}}]} @@ -7898,8 +7901,11 @@ test_a_path_reads_params_input_lookups_positions_and_quoted_keys if { _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 == {"dotted": true, "steps": ["count"]} - _read(`x is ."$schema"`).assertions[0].phrase == {"op": "is", "terms": [{"dotted": true, "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 { @@ -7908,6 +7914,12 @@ test_a_value_reads_as_json_and_a_single_quoted_string_keeps_its_backslashes if { _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 { @@ -7961,15 +7973,23 @@ test_the_report_prints_one_spelling_for_each_phrase if { _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] is 1`) == "b named by persona[2] 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" @@ -7997,13 +8017,35 @@ test_a_from_line_reads_its_path_keys_and_where if { } test_a_sentence_written_wrong_says_what_is_wrong if { - _read(`x is "a`) == {"problems": ["unclosed quote"]} - _read(`x is 'a`) == {"problems": ["unclosed quote"]} + _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 "\q"`]} + _read(`x is "a b"`) == {"problems": [`invalid string "a b"`]} + _read(`x is 'a b'`) == {"problems": [`invalid string 'a b'`]} + _read(`"\q" is 1`) == {"problems": [`invalid string "\q"`]} _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 after plus goes on a count of or a sum of"]} + _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 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"]} From 402b5c0f3c824bffacb1f12d4be71458ffdce927 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:41:50 +0200 Subject: [PATCH 05/15] ci: fail when the corpus JSON is out of date Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4000921..0362aaf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,7 @@ jobs: - run: opa check --strict . --ignore .github - run: opa fmt --list --fail . - run: opa test . --ignore .github --timeout 120s --verbose + - run: python3 spec/sentences_corpus.py --check - name: Lint for likely bugs run: regal lint --format github --disable-all --enable-category bugs --disable redundant-existence-check --ignore-files '.github/**' . - name: Check there are no comments From 397dbbcb3d3816f0ae0dcb0d4ea0dd85df4603f0 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:48:27 +0200 Subject: [PATCH 06/15] spec: let count of and sum of take a position So that count of item 1 of x, which is how the report prints count of x[0], is a sentence. Co-Authored-By: Claude Fable 5.1 --- spec/sentences.abnf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/sentences.abnf b/spec/sentences.abnf index beaa88f..13703d2 100644 --- a/spec/sentences.abnf +++ b/spec/sentences.abnf @@ -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) From 6038ced642fa923af157c3c06719b3a5b8a816a8 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:48:27 +0200 Subject: [PATCH 07/15] core: print a counted position as a sentence that parses, and tidy three messages count of x[0] printed as count of item 1 of x, which did not parse, because a count or sum took no position. The unknown-name message skips quoted strings and names each name once, the invalid-string message gives a column so that a tab or newline in the string does not break the row, and the missing-phrase message quotes is and exists as the spec does. Co-Authored-By: Claude Fable 5.1 --- ergo.rego | 10 +++++----- ergo_test.rego | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ergo.rego b/ergo.rego index 29fafae..b4c352c 100644 --- a/ergo.rego +++ b/ergo.rego @@ -3943,7 +3943,7 @@ _s_patterns := { "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 +(`, plain, `)$`]), + "derived": concat("", [`^(count|sum) +of +(`, path, `)$`]), "value": concat("", [`^`, value, `$`]), "values": concat("", [`^`, values, `$`]), "each_value": value, @@ -3966,7 +3966,7 @@ _s_patterns := { plain := concat("", [`(?:`, start, `)(?:`, step, `)*`]) path := concat("", [`(?:`, pos, `)?`, plain]) rpath := concat("", [`(?:`, pos, `)?(?:`, rstart, `)(?:`, step, `)*`]) - derived := concat("", [`(?:count|sum) +of +`, plain]) + derived := concat("", [`(?:count|sum) +of +`, path]) term := concat("", [`(?:`, derived, `|`, rpath, `|`, num, `)`]) terms := concat("", [term, `(?: +plus +`, term, `)*`]) left := concat("", [`(?:`, path, `|`, terms, `)`]) @@ -4295,19 +4295,19 @@ _s_problems(text, pat) := [concat("", ["unclosed quote at column ", _json_text(c closed := regex.find_all_string_submatch_n(pat.quotes, text, 1)[0][0] count(closed) < count(text) } else := invalid if { - invalid := [concat("", ["invalid string ", m[0]]) | some m in regex.find_all_string_submatch_n(pat.strings, text, -1); not regex.match(pat.string, m[0])] + invalid := [concat("", ["invalid string at column ", _json_text(indexof(text, m[0]) + 1)]) | some m in regex.find_all_string_submatch_n(pat.strings, text, -1); not regex.match(pat.string, m[0])] count(invalid) > 0 } else := problems if { problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) count(problems) > 0 } else := names if { - names := [concat("", ["unknown name ", m[1]]) | some m in regex.find_all_string_submatch_n(`(?:^|[ ,\[])(\$[A-Za-z_][A-Za-z0-9_]*)`, text, -1); not m[1] in {"$params", "$input"}] + names := sort({concat("", ["unknown name ", m[1]]) | some m in regex.find_all_string_submatch_n(`(?:^|[ ,\[])(\$[A-Za-z_][A-Za-z0-9_]*)`, regex.replace(text, pat.strings, ""), -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 { +} 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"] diff --git a/ergo_test.rego b/ergo_test.rego index 2d44bb8..ab239a5 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -7995,6 +7995,8 @@ test_the_report_prints_one_spelling_for_each_phrase if { _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 { @@ -8019,10 +8021,12 @@ test_a_from_line_reads_its_path_keys_and_where if { 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 "\q"`]} - _read(`x is "a b"`) == {"problems": [`invalid string "a b"`]} - _read(`x is 'a b'`) == {"problems": [`invalid string 'a b'`]} - _read(`"\q" is 1`) == {"problems": [`invalid string "\q"`]} + _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 "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"]} @@ -8038,7 +8042,7 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _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(`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} From d147719dbc4b409a8266ae2b4f9536b9161824a1 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:49:30 +0200 Subject: [PATCH 08/15] core: find unknown names without regex.replace, which the Wasm JS runtime lacks Co-Authored-By: Claude Fable 5.1 --- ergo.rego | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ergo.rego b/ergo.rego index b4c352c..4ecbcc7 100644 --- a/ergo.rego +++ b/ergo.rego @@ -4301,7 +4301,7 @@ _s_problems(text, pat) := [concat("", ["unclosed quote at column ", _json_text(c 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(`(?:^|[ ,\[])(\$[A-Za-z_][A-Za-z0-9_]*)`, regex.replace(text, pat.strings, ""), -1); not m[1] in {"$params", "$input"}}) + 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) From f2570975ed3aa2b4fb1b2b8495c605d468ed814c Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 22:53:28 +0200 Subject: [PATCH 09/15] core: give the column of each invalid string, not of the first text like it Co-Authored-By: Claude Fable 5.1 --- ergo.rego | 7 ++++++- ergo_test.rego | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ergo.rego b/ergo.rego index 4ecbcc7..e4fd06f 100644 --- a/ergo.rego +++ b/ergo.rego @@ -4295,7 +4295,12 @@ _s_problems(text, pat) := [concat("", ["unclosed quote at column ", _json_text(c closed := regex.find_all_string_submatch_n(pat.quotes, text, 1)[0][0] count(closed) < count(text) } else := invalid if { - invalid := [concat("", ["invalid string at column ", _json_text(indexof(text, m[0]) + 1)]) | some m in regex.find_all_string_submatch_n(pat.strings, text, -1); not regex.match(pat.string, m[0])] + 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 := problems if { problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) diff --git a/ergo_test.rego b/ergo_test.rego index ab239a5..7e09039 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -8025,6 +8025,8 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _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 20", "invalid string at column 6"]} + _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"]} From f6c1f523d0572fe2d3558606dbcfbf40f0e018c7 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 12 Oct 2026 23:25:13 +0200 Subject: [PATCH 10/15] ci: check the corpus JSON in the spec job Co-Authored-By: Claude Fable 5.1 --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0362aaf..8bacbf7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,6 @@ jobs: - run: opa check --strict . --ignore .github - run: opa fmt --list --fail . - run: opa test . --ignore .github --timeout 120s --verbose - - run: python3 spec/sentences_corpus.py --check - name: Lint for likely bugs run: regal lint --format github --disable-all --enable-category bugs --disable redundant-existence-check --ignore-files '.github/**' . - name: Check there are no comments @@ -96,6 +95,8 @@ jobs: version: 1.19.0 - name: Check every rule a case cites exists, and every rule has a case run: python3 spec/check_rules.py + - name: Check the corpus JSON matches the markdown + run: python3 spec/sentences_corpus.py --check - name: Check the cases' policies and reports match the schemas run: | python3 -m venv "$RUNNER_TEMP/venv" From 0acb58fa406ae3fac89502a789de943ba6d94058 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Tue, 13 Oct 2026 07:06:38 +0200 Subject: [PATCH 11/15] spec: take a right-side path after named by each of The parser and sentences.md already read it as a right-side path, where a quoted word is a value and not a key, so $input named by each of "T" doesn't parse. Co-Authored-By: Claude Opus 5.5 --- spec/sentences.abnf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/sentences.abnf b/spec/sentences.abnf index 13703d2..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) From 7c831d0ddeea0f2f7201df26a4ee9da395b405be Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Tue, 13 Oct 2026 07:06:38 +0200 Subject: [PATCH 12/15] core: refuse a number out of range in a sentence before OPA compares it A number has to be 0 or have a magnitude between 2.2250738585072014e-308 and 1.7976931348623157e308, as in a policy. The parser now checks the size from the text first. Comparing a number whose exponent is beyond one million crashed OPA with "panic: illegal value", as in x is between -6 and -80e-08923553, and a number above 1.8e308 made x is between 1 and 1e400 fail with "doesn't parse". Problems now keep the order of their columns, so column 6 comes before column 20. Co-Authored-By: Claude Opus 5.5 --- ergo.rego | 41 ++++++++++++++++++++++++++++++++--------- ergo_test.rego | 16 +++++++++++++++- spec/sentences.md | 3 ++- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/ergo.rego b/ergo.rego index e4fd06f..b2a5d3f 100644 --- a/ergo.rego +++ b/ergo.rego @@ -4035,7 +4035,7 @@ _sentence(raw) := {"assertions": assertions} if { count(assertions) == count(pieces) count(pieces) > 0 count(_s_sentence_problems({"assertions": assertions})) == 0 -} else := {"problems": sort(_s_problems(trim_space(raw), _s_patterns))} +} else := {"problems": _s_problems(trim_space(raw), _s_patterns)} _for_line(raw) := {"items": items} if { pat := _s_patterns @@ -4045,13 +4045,13 @@ _for_line(raw) := {"items": items} if { count(items) == count(pieces) count(pieces) in {1, 2} count(_s_sentence_problems({"items": items})) == 0 -} else := {"problems": sort(_s_problems(trim_space(raw), _s_patterns))} +} 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": sort(_s_problems(trim_space(raw), _s_patterns))} +} 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] @@ -4112,7 +4112,7 @@ _s_terms_of(text, pat) := terms if { count(terms) == count(pieces) } -_s_term_of(text, pat) := to_number(text) if { +_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] @@ -4195,7 +4195,7 @@ _s_args_of("list", text, pat) := {"values": _s_values_of(text, pat)} if { _s_args_of("operand", text, pat) := _s_operand_of(text, pat) -_s_args_of("range", text, pat) := {"min": to_number(m[1]), "max": to_number(m[2])} if { +_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] } @@ -4205,7 +4205,7 @@ _s_args_of("operand_within", text, pat) := object.union(_s_operand_of(m[1], pat) _s_within_of("") := {} -_s_within_of(number) := {"within": to_number(number)} if number != "" +_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) @@ -4213,17 +4213,36 @@ _s_operand_of(text, pat) := {"value": _s_value_of(text)} if { _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) := json.unmarshal(text) if not startswith(text, "'") +_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]) | +_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) @@ -4267,6 +4286,10 @@ _s_sentence_problems(parsed) := (((((((({concat("", [node.bare_word, " is a word is_object(node) node.op == "is between" 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) @@ -4302,7 +4325,7 @@ _s_problems(text, pat) := [concat("", ["unclosed quote at column ", _json_text(c not regex.match(pat.string, token) ] count(invalid) > 0 -} else := problems if { +} else := sort(problems) if { problems := _s_sentence_problems(_s_parsed_anyway(text, pat)) count(problems) > 0 } else := names if { diff --git a/ergo_test.rego b/ergo_test.rego index 7e09039..a080c0d 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -8018,6 +8018,19 @@ test_a_from_line_reads_its_path_keys_and_where if { 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 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"]} @@ -8025,7 +8038,7 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _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 20", "invalid string at column 6"]} + _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"]} @@ -8068,4 +8081,5 @@ test_a_sentence_written_wrong_says_what_is_wrong if { 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/sentences.md b/spec/sentences.md index f9fc132..563327c 100644 --- a/spec/sentences.md +++ b/spec/sentences.md @@ -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. @@ -341,6 +341,7 @@ Read as `is` or `equals`, and printed as one of them. With it in the grammar, ev | `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` 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. From c39e1b59631de7c86b32c3695a32b25d33769ab8 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Tue, 13 Oct 2026 07:06:49 +0200 Subject: [PATCH 13/15] ci: move the corpus check to its own pull request The squashed commit is core:, so the workflow change goes in a ci: pull request after this one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bacbf7..4000921 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,8 +95,6 @@ jobs: version: 1.19.0 - name: Check every rule a case cites exists, and every rule has a case run: python3 spec/check_rules.py - - name: Check the corpus JSON matches the markdown - run: python3 spec/sentences_corpus.py --check - name: Check the cases' policies and reports match the schemas run: | python3 -m venv "$RUNNER_TEMP/venv" From 51adb5b2d9702e1f390fd4319d6ead907d5cd5b7 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Tue, 13 Oct 2026 07:13:47 +0200 Subject: [PATCH 14/15] core: leave a number out of range out of min above max and invalid pattern x is between 1e400 and 1 said "min above max" too, and x matches 1e400 printed the parser's own marker as an invalid pattern. Co-Authored-By: Claude Opus 5.5 --- ergo.rego | 3 +++ ergo_test.rego | 2 ++ 2 files changed, 5 insertions(+) diff --git a/ergo.rego b/ergo.rego index b2a5d3f..2eb56ed 100644 --- a/ergo.rego +++ b/ergo.rego @@ -4285,6 +4285,8 @@ _s_sentence_problems(parsed) := ((((((((({concat("", [node.bare_word, " is a wor 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) @@ -4295,6 +4297,7 @@ _s_sentence_problems(parsed) := ((((((((({concat("", [node.bare_word, " is a wor 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) } diff --git a/ergo_test.rego b/ergo_test.rego index a080c0d..d085cec 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -8021,6 +8021,8 @@ test_a_from_line_reads_its_path_keys_and_where if { 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"]} From 926d0e2bdd9cd61b560728bbf6d9b370873d90ac Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Tue, 13 Oct 2026 07:49:57 +0200 Subject: [PATCH 15/15] core: refuse a negative within in a sentence sentences.md says the number after within is 0 or more, as REFERENCE.md says for today's within, but x is 1 within -1 parsed. Co-Authored-By: Claude Opus 5.5 --- ergo.rego | 7 ++++++- ergo_test.rego | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ergo.rego b/ergo.rego index 2eb56ed..478405a 100644 --- a/ergo.rego +++ b/ergo.rego @@ -4242,7 +4242,7 @@ _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]) | +_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) @@ -4299,6 +4299,11 @@ _s_sentence_problems(parsed) := ((((((((({concat("", [node.bare_word, " is a wor 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 { diff --git a/ergo_test.rego b/ergo_test.rego index d085cec..c99ef9d 100644 --- a/ergo_test.rego +++ b/ergo_test.rego @@ -8063,6 +8063,9 @@ test_a_sentence_written_wrong_says_what_is_wrong if { _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"]}