diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9c35e173..c034b328 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -50,6 +50,13 @@ jobs: working-directory: ${{ matrix.dir }} steps: - uses: actions/checkout@v7.0.1 + - name: Canon pin drift (rhodibot) + # The rule set is copied from hyperpolymath/standards and pinned by + # digest. This fails when the canon has moved since that pin, so a rule + # change arrives as a reviewable commit rather than silently changing + # what every repository is measured against. + if: matrix.module == 'rhodibot' + run: bash "$GITHUB_WORKSPACE/scripts/check-canon-drift.sh" - name: Ensure clippy + rustfmt components run: rustup component add clippy rustfmt - name: Build (all targets) diff --git a/bots/rhodibot/canon/pin.toml b/bots/rhodibot/canon/pin.toml new file mode 100644 index 00000000..b980472c --- /dev/null +++ b/bots/rhodibot/canon/pin.toml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# The released identity of the canon copied into this directory. +# +# The rule set rhodibot applies is not written here: it is read from the canon +# (`0-canon/rsr/rsr-criteria-v2.a2ml` in hyperpolymath/standards), copied +# verbatim, and this file records which revision the copy came from. A test +# recomputes the digest of the copy and fails if it stops matching, so a canon +# movement cannot take effect by accident -- re-pinning is an ordinary, +# reviewable commit. +# +# To re-pin: copy the new criteria file over `rsr-criteria-v2.a2ml`, update the +# three version fields, the digest, and the `categories`, `criteria` and +# `weight_sum` shape fields below, then run `cargo test`. The canon's own +# arithmetic is re-checked on parse, so a half-copied file fails rather than +# silently losing criteria. +# +# One discrepancy to be aware of, recorded rather than resolved here: the +# release in `canon.lock` is 2.0.4 while the criteria file's own `[meta] +# version` still reads 2.0.0-draft. Both are quoted below so the pin describes +# the artefact rather than a story about it. `canon.lock` also cannot be pinned +# by commit: its `commit` field is still the all-zero placeholder with the +# comment "fill at release". + +[source] +repo = "hyperpolymath/standards" +path = "0-canon/rsr/rsr-criteria-v2.a2ml" +slot = "criteria" +canon_version = "2.0.4" +criteria_version = "2.0.0-draft" +released = "2026-09-17" +pinned = "2026-09-19" +sha256 = "37cb5f679b414f6ee99c6bb62c460fd5349ff7d50cc1dab25b1e1a8e8d3c7bb9" + +# The shape of the pinned revision, checked after parsing as well as by digest. +# A rule set that silently shrinks is the failure mode that matters: these +# numbers make shrinking a test failure rather than a quieter scorecard. +categories = 11 +criteria = 74 +weight_sum = 88 diff --git a/bots/rhodibot/canon/rsr-criteria-v2.a2ml b/bots/rhodibot/canon/rsr-criteria-v2.a2ml new file mode 100644 index 00000000..bbb9cb3d --- /dev/null +++ b/bots/rhodibot/canon/rsr-criteria-v2.a2ml @@ -0,0 +1,249 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# rsr-criteria-v2.a2ml — RSR v2.0 machine-readable criteria (the SINGLE SOURCE OF TRUTH). +# +# This file is authored in the A2ML RECORD DIALECT (a2ml/RECORD-DIALECT-SPEC.adoc). +# It is the sole normative source for RSR v2.0 criteria. Every other artefact — +# the prose checklist, the one normative checker's rule table, and the badge +# thresholds — is GENERATED from this file (see RSR-SPEC-v2.adoc §7, "Solutions +# at source"). Do not hand-maintain criteria anywhere else. +# +# Grounding: the criteria below are re-based on a measured survey of estate +# reality (rsr-template-repo's shipped file set + hypatia's enforced rules), not +# the 2025-era spec.scm (GitLab / Nix / .scm / RVC / SaltRover), which is retired +# (see §Migration in RSR-SPEC-v2.adoc). Each criterion records how it is detected +# (`detect`, a hypatia rule id or "manual") and where the template satisfies it +# (`template_ref`) so spec -> checker -> template traceability is machine-checkable. + +[meta] +spec = "rhodium-standard-repositories" +version = "2.0.0-draft" +status = "draft" # draft | stable ; MUST NOT be cited as ratified until §Ratification passes +supersedes = "1.0.0" +date = "2026-07-03" +authority = "RSR-SPEC-v2.adoc" +normative-oracle = "hypatia:rsr-conformance" # the ONE checker; all others are non-normative (see [oracle]) +dialect = "a2ml-record" # dogfoods a2ml/RECORD-DIALECT-SPEC.adoc + +[versioning] +# Fixes the v1.0 "immutable forever" model, which estate reality already broke. +model = "semver-with-errata" +rule-major = "A MAJOR bump is an era change: it MAY remove or redefine criteria and MAY move a criterion between tiers. Cutting a major freezes the prior major's rsr-criteria as an immutable, hash-pinned artefact under archive/." +rule-minor = "A MINOR bump MAY ADD criteria or ADD a capability gate, but MUST NOT make a previously-conforming repo non-conforming at the same tier. Additive only." +rule-patch = "A PATCH bump is editorial (wording, detection-rule id corrections, template_ref fixes) with no criteria-set change." +freeze-mechanism = "A released major is frozen by pinning this file's byte-hash in .machine_readable/REGISTRY.a2ml (or the spec's own VERSIONS ledger), NOT by a prose 'FROZEN' banner. A freeze guard in CI (see [oracle].freeze-guard) fails any PR that mutates a frozen major's criteria file." +errata = "Corrections that do not change criteria semantics are recorded in RSR-SPEC-v2.adoc Appendix E (Errata) and shipped as PATCH bumps." + +[tiers] +# Score = weighted percentage of APPLICABLE criteria passed (see [scoring]). +# Thresholds are inclusive lower bounds. +bronze = 75 +silver = 90 +gold = 100 +rhodium = 100 # Gold + the rhodium-only exemplary criteria (those with tier = "rhodium") +scale = "X F E D C B A maps onto the readiness-grade scale; RSR tiers are the repo-shape projection (Bronze~C, Silver~B, Gold~A, Rhodium~A+)." + +[scoring] +# The capability-gated model (replaces v1.0's flat 'every repo needs all 11 categories'). +applicable-set = "A criterion is APPLICABLE to a repo iff its `gate` is `universal` OR the repo's .machine_readable/rsr-profile.a2ml declares the gating capability (per 0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc). Non-applicable criteria are scored `na` and excluded from the denominator." +denominator = "sum of weights of APPLICABLE criteria" +numerator = "sum of weights of APPLICABLE criteria whose `detect` returns pass" +partial = "A criterion MAY return `partial` (e.g. some but not all files present); partial contributes half its weight and is reported explicitly." +na-honesty = "A repo MUST NOT reach a tier by declaring away (via rsr-profile) a capability it actually has. hypatia cross-checks declared capabilities against detected ones (structural_drift) and flags under-declaration as :review." +report = "The oracle writes a per-repo scorecard to verisim-data (.machine_readable/scorecards/.scorecard.a2ml shape) with per-criterion verdicts, the applicable set, and the resulting tier." + +[oracle] +# ONE normative checker. RSR v1.0 shipped five divergent checkers; v2.0 designates +# exactly one and declares the rest non-normative. +normative = "hypatia rule family `rsr-conformance` (to be implemented; consumes THIS file). hypatia is the estate scanner and already writes scorecards to verisim-data, so it is the natural oracle." +freeze-guard = "hypatia rule `rsr-criteria-freeze`: fails any change to a frozen major's rsr-criteria-*.a2ml whose byte-hash no longer matches the registry pin." +retired = [ + "rsr-audit.sh (36K bash; no tests, no CI) -> non-normative reference only", + "rsr-check.scm (Guile) -> retired with the .scm era", + "rsr-compliance-checklist.k9.ncl -> retired; k9 kennel data is GENERATED from this file if needed", + "COMPLIANCE_CHECKLIST.md -> GENERATED from this file, not authored", + "rhodium-pipeline rsr-certifier -> product, not the spec's oracle; MAY consume this file but is not normative", +] +dogfood-gate = "CI runs the oracle against rsr-template-repo on every change to either repo and REQUIRES Gold for the applicable set (the template declares the union of capabilities, so its applicable set is near-total). See rsr-template-repo/.github/workflows/dogfood-gate.yml." + +# --------------------------------------------------------------------------- +# CATEGORIES — 11 weighted categories (structure retained from v1.0; content +# re-based on estate reality). Weights sum to 100. Each criterion: +# id stable dotted id (category.group.item), STABLE across minors +# name short slug +# desc what it checks +# tier lowest tier at which it is REQUIRED (bronze|silver|gold|rhodium) +# gate "universal" or a capability from TEMPLATE-APPLICABILITY-POLICY +# detect hypatia rule id, or "manual" (honest: no automated check yet) +# template_ref where rsr-template-repo satisfies it, or "-" +# --------------------------------------------------------------------------- + +[[category]] +id = 1 +key = "foundational-infrastructure" +name = "Foundational Infrastructure" +weight = 12 +criteria = [ + { id = "1.1.1", name = "scm-github", desc = "Repository canonical on GitHub (GitLab is mirror-only)", tier = "bronze", gate = "universal", detect = "structural_drift/SD001", template_ref = ".github/" }, + { id = "1.1.2", name = "justfile", desc = "Justfile task runner present with real recipes", tier = "bronze", gate = "universal", detect = "build_system_rules/justfile", template_ref = "Justfile" }, + { id = "1.1.3", name = "no-makefile", desc = "No Makefile (Mustfile/justfile only)", tier = "bronze", gate = "universal", detect = "cicd_rules/makefile_detected", template_ref = "-" }, + { id = "1.1.4", name = "editorconfig", desc = ".editorconfig present", tier = "silver", gate = "universal", detect = "manual", template_ref = ".editorconfig" }, + { id = "1.2.1", name = "guix-primary", desc = "Guix manifest (guix.scm) as the reproducible build (Guix only)", tier = "silver", gate = "reproducible-build", detect = "build_system_rules/guix_primary", template_ref = "build/" }, + { id = "1.2.2", name = "git-hooks", desc = ".pre-commit-config.yaml with real hooks", tier = "silver", gate = "universal", detect = "manual", template_ref = "ci/.pre-commit-config.yaml" }, + { id = "1.2.3", name = "container-rootless", desc = "Container config uses rootless Podman + Chainguard/Wolfi base", tier = "gold", gate = "container", detect = "cicd_rules/containerfile_base", template_ref = "build/container/" }, + { id = "1.2.4", name = "tool-versions", desc = ".tool-versions pins toolchain versions", tier = "silver", gate = "universal", detect = "manual", template_ref = ".tool-versions" }, +] + +[[category]] +id = 2 +key = "documentation-standards" +name = "Documentation Standards" +weight = 12 +criteria = [ + { id = "2.1.1", name = "readme-adoc", desc = "README.adoc present (.adoc primary per estate doc policy)", tier = "bronze", gate = "universal", detect = "root_hygiene/readme", template_ref = "README.adoc" }, + { id = "2.1.2", name = "license-file", desc = "LICENSE present + LICENSES/ REUSE texts (NOT LICENSE.txt)", tier = "bronze", gate = "universal", detect = "root_hygiene/license", template_ref = "LICENSE, LICENSES/" }, + { id = "2.1.3", name = "security-md", desc = "SECURITY.md with a vulnerability-disclosure policy", tier = "bronze", gate = "universal", detect = "root_hygiene/security", template_ref = ".github/SECURITY.md" }, + { id = "2.1.4", name = "coc-md", desc = "CODE_OF_CONDUCT.md", tier = "silver", gate = "universal", detect = "root_hygiene/coc", template_ref = ".github/CODE_OF_CONDUCT.md" }, + { id = "2.1.5", name = "contributing-md", desc = "CONTRIBUTING.md", tier = "silver", gate = "universal", detect = "root_hygiene/contributing", template_ref = ".github/CONTRIBUTING.md" }, + { id = "2.1.6", name = "changelog", desc = "CHANGELOG.adoc, or .md (Keep a Changelog)", tier = "silver", gate = "universal", detect = "root_hygiene/changelog", template_ref = "CHANGELOG.adoc" }, + { id = "2.1.7", name = "maintainers", desc = "MAINTAINERS.adoc", tier = "silver", gate = "governance-tier", detect = "root_hygiene/maintainers", template_ref = "docs/MAINTAINERS.adoc" }, + { id = "2.1.8", name = "governance", desc = "0-canon/GOVERNANCE.adoc", tier = "gold", gate = "governance-tier", detect = "root_hygiene/governance", template_ref = "docs/GOVERNANCE.adoc" }, + { id = "2.1.9", name = "funding", desc = "FUNDING.yml / .github/FUNDING.yml", tier = "gold", gate = "governance-tier", detect = "manual", template_ref = ".github/FUNDING.yml" }, + { id = "2.1.10", name = "gitignore", desc = ".gitignore and .gitattributes present", tier = "bronze", gate = "universal", detect = "manual", template_ref = ".gitignore, .gitattributes" }, + { id = "2.2.1", name = "wellknown-core", desc = ".well-known/{security.txt,ai.txt,humans.txt}", tier = "silver", gate = "universal", detect = "structural_drift/SD020", template_ref = ".well-known/" }, + { id = "2.2.2", name = "wellknown-ext", desc = ".well-known/{consent-required.txt,provenance.json}", tier = "gold", gate = "web-ui", detect = "manual", template_ref = "-" }, + { id = "2.3.1", name = "ai-manifest", desc = "0-AI-MANIFEST.a2ml agent front door present", tier = "silver", gate = "universal", detect = "structural_drift/SD002", template_ref = "0-AI-MANIFEST.a2ml" }, +] + +[[category]] +id = 3 +key = "machine-readable-substrate" +name = "Machine-Readable Substrate (Descriptiles)" +weight = 14 +criteria = [ + { id = "3.1.1", name = "descriptiles-dir", desc = ".machine_readable/descriptiles/ present (NOT 6a2/, which is deprecated 2026-06-30)", tier = "gold", gate = "universal", detect = "structural_drift/SD003", template_ref = ".machine_readable/descriptiles/" }, + { id = "3.1.2", name = "state", desc = "STATE.a2ml — current state/progress (valid record dialect)", tier = "gold", gate = "universal", detect = "structural_drift/SD004", template_ref = ".machine_readable/descriptiles/STATE.a2ml" }, + { id = "3.1.3", name = "meta", desc = "META.a2ml — ADRs / constitutional authority", tier = "gold", gate = "universal", detect = "structural_drift/SD005", template_ref = ".machine_readable/descriptiles/META.a2ml" }, + { id = "3.1.4", name = "ecosystem", desc = "ECOSYSTEM.a2ml — estate position + what-this-is-not", tier = "gold", gate = "universal", detect = "structural_drift/SD006", template_ref = ".machine_readable/descriptiles/ECOSYSTEM.a2ml" }, + { id = "3.1.5", name = "agentic", desc = "AGENTIC.a2ml — agent permissions / risk gating", tier = "gold", gate = "universal", detect = "structural_drift/SD007", template_ref = ".machine_readable/descriptiles/AGENTIC.a2ml" }, + { id = "3.1.6", name = "neurosym", desc = "NEUROSYM.a2ml — proof obligations / meaning of ops", tier = "gold", gate = "universal", detect = "structural_drift/SD008", template_ref = ".machine_readable/descriptiles/NEUROSYM.a2ml" }, + { id = "3.1.7", name = "playbook", desc = "PLAYBOOK.a2ml — operational runbook", tier = "gold", gate = "universal", detect = "structural_drift/SD009", template_ref = ".machine_readable/descriptiles/PLAYBOOK.a2ml" }, + { id = "3.1.8", name = "anchor", desc = "ANCHOR.a2ml — semantic authority + golden path", tier = "gold", gate = "universal", detect = "structural_drift/SD010", template_ref = ".machine_readable/descriptiles/anchors/ANCHOR.a2ml" }, + { id = "3.1.9", name = "clade", desc = "CLADE.a2ml — identity/lineage (registers into gv-clade-index)", tier = "gold", gate = "governance-tier", detect = "structural_drift/SD011", template_ref = ".machine_readable/descriptiles/CLADE.a2ml" }, + { id = "3.2.1", name = "a2ml-valid", desc = "All .a2ml files parse + validate against their record-dialect profile", tier = "gold", gate = "universal", detect = "rsr-conformance/a2ml_valid", template_ref = "-" }, + { id = "3.2.2", name = "rsr-profile", desc = ".machine_readable/rsr-profile.a2ml declares capabilities (drives applicable set)", tier = "silver", gate = "universal", detect = "rsr-conformance/profile_present", template_ref = ".machine_readable/rsr-profile.a2ml" }, +] + +[[category]] +id = 4 +key = "security-architecture" +name = "Security Architecture" +weight = 14 +criteria = [ + { id = "4.1.1", name = "spdx-headers", desc = "SPDX-License-Identifier headers on all source files", tier = "bronze", gate = "universal", detect = "cicd_rules/missing_spdx", template_ref = "-" }, + { id = "4.1.2", name = "no-secrets", desc = "No hardcoded secrets (secret scanner clean)", tier = "bronze", gate = "universal", detect = "security_errors/secret_detected", template_ref = "-" }, + { id = "4.1.3", name = "sha-pinned", desc = "GitHub Actions + dependencies SHA-pinned", tier = "silver", gate = "universal", detect = "supply_chain/unpinned_action", template_ref = ".github/workflows/" }, + { id = "4.1.4", name = "https-only", desc = "No plaintext HTTP URLs; HTTPS only", tier = "silver", gate = "universal", detect = "security_errors/http_url", template_ref = "-" }, + { id = "4.1.5", name = "no-weak-hash", desc = "No MD5/SHA1 for security purposes", tier = "silver", gate = "universal", detect = "security_errors/weak_hash", template_ref = "-" }, + { id = "4.2.1", name = "scorecard-7", desc = "OpenSSF Scorecard >= 7.0", tier = "silver", gate = "universal", detect = "scorecard_compliance/aggregate", template_ref = ".github/workflows/scorecard.yml" }, + { id = "4.2.2", name = "scorecard-9", desc = "OpenSSF Scorecard >= 9.0", tier = "gold", gate = "universal", detect = "scorecard_compliance/aggregate", template_ref = ".github/workflows/scorecard.yml" }, + { id = "4.3.1", name = "deno-perms", desc = "Explicit Deno permission flags (no bare --allow-all)", tier = "silver", gate = "deno", detect = "cicd_rules/deno_permissions", template_ref = "-" }, + { id = "4.3.2", name = "sbom", desc = "SBOM generated on release", tier = "gold", gate = "published-package", detect = "supply_chain/sbom", template_ref = ".github/workflows/release.yml" }, + { id = "4.3.3", name = "rootless", desc = "Containers run rootless", tier = "gold", gate = "container", detect = "cicd_rules/rootless_container", template_ref = "build/container/" }, +] + +[[category]] +id = 5 +key = "language-policy" +name = "Language Policy Conformance" +weight = 12 +criteria = [ + { id = "5.1.1", name = "no-python", desc = "No Python (fully banned)", tier = "bronze", gate = "universal", detect = "cicd_rules/python_detected", template_ref = ".github/workflows/estate-rules.yml" }, + { id = "5.1.2", name = "no-typescript", desc = "No new TypeScript outside approved carve-outs", tier = "bronze", gate = "universal", detect = "cicd_rules/typescript_detected", template_ref = ".github/workflows/runtime-policy.yml" }, + { id = "5.1.3", name = "no-rescript", desc = "No new ReScript outside approved carve-outs", tier = "bronze", gate = "universal", detect = "cicd_rules/rescript_detected", template_ref = "-" }, + { id = "5.1.4", name = "no-vlang", desc = "No V-lang (migration completed 2026-05-28)", tier = "bronze", gate = "universal", detect = "cicd_rules/vlang_detected", template_ref = "-" }, + { id = "5.1.5", name = "no-go", desc = "No Go (use Rust/SPARK)", tier = "bronze", gate = "universal", detect = "cicd_rules/go_detected", template_ref = "-" }, + { id = "5.1.6", name = "no-node-npm", desc = "No Node/npm/bun runtime deps (use Deno)", tier = "bronze", gate = "universal", detect = "cicd_rules/nodejs_detected", template_ref = ".github/workflows/runtime-policy.yml" }, + { id = "5.2.1", name = "spark-ready", desc = "Rust projects designed to admit SPARK/Ada modules", tier = "rhodium", gate = "rust", detect = "manual", template_ref = "-" }, + { id = "5.2.2", name = "proofs-clean", desc = "No believe_me / sorry / Admitted in load-bearing proofs", tier = "gold", gate = "formal-proofs", detect = "proof_obligation/no_holes", template_ref = "verification/" }, +] + +[[category]] +id = 6 +key = "cicd-enforcement" +name = "CI/CD & Enforcement" +weight = 8 +criteria = [ + { id = "6.1.1", name = "ci-present", desc = "CI pipeline present (GitHub Actions)", tier = "bronze", gate = "universal", detect = "structural_drift/SD012", template_ref = ".github/workflows/" }, + { id = "6.1.2", name = "hypatia-scan", desc = "hypatia-scan workflow wired (self-scans on push)", tier = "silver", gate = "universal", detect = "workflow_audit/hypatia_scan", template_ref = ".github/workflows/hypatia-scan.yml" }, + { id = "6.1.3", name = "governance-wf", desc = "governance workflow enforces estate policies", tier = "silver", gate = "universal", detect = "workflow_audit/governance", template_ref = ".github/workflows/governance.yml" }, + { id = "6.1.4", name = "workflow-hardened", desc = "Workflows least-privilege + no injection (workflow linter)", tier = "silver", gate = "universal", detect = "workflow_hardening/aggregate", template_ref = ".github/workflows/workflow-linter.yml" }, + { id = "6.1.5", name = "no-silent-skip", desc = "No '|| echo SKIP' silent-green in build/test recipes", tier = "gold", gate = "universal", detect = "honest_completion/silent_skip", template_ref = "-" }, + { id = "6.2.1", name = "dogfood-gate", desc = "dogfood-gate runs the RSR oracle against itself", tier = "gold", gate = "governance-tier", detect = "workflow_audit/dogfood", template_ref = ".github/workflows/dogfood-gate.yml" }, +] + +[[category]] +id = 7 +key = "foss-licensing" +name = "FOSS & Licensing" +weight = 6 +criteria = [ + { id = "7.1.1", name = "licence-classified", desc = "Licence matches the estate 5-way classification (detection FLAG-ONLY / :review; never auto-edited)", tier = "bronze", gate = "universal", detect = "cicd_rules/license_finding", template_ref = "LICENSE" }, + { id = "7.1.2", name = "reuse-compliant", desc = "REUSE-style LICENSES/ dir with full texts", tier = "silver", gate = "universal", detect = "cicd_rules/reuse_lint", template_ref = "LICENSES/" }, + { id = "7.1.3", name = "spdx-audit", desc = "SPDX identifiers resolve to real licences (no scrambled ids)", tier = "silver", gate = "universal", detect = "cicd_rules/spdx_valid", template_ref = "-" }, + { id = "7.2.1", name = "dco", desc = "DCO sign-off or CLA on contributions", tier = "gold", gate = "governance-tier", detect = "manual", template_ref = ".github/CONTRIBUTING.md" }, +] + +[[category]] +id = 8 +key = "lifecycle-management" +name = "Lifecycle Management" +weight = 4 +criteria = [ + { id = "8.1.1", name = "semver", desc = "Semantic versioning for releases", tier = "silver", gate = "published-package", detect = "manual", template_ref = "CHANGELOG.adoc" }, + { id = "8.1.2", name = "pinned-deps", desc = "Dependency versions pinned/locked", tier = "silver", gate = "universal", detect = "supply_chain/unpinned_dep", template_ref = "-" }, + { id = "8.1.3", name = "deprecation", desc = "Deprecation + sunset policy documented", tier = "gold", gate = "published-package", detect = "manual", template_ref = "-" }, + { id = "8.1.4", name = "no-scaffold-stub", desc = "No scaffold-stub guix.scm (placeholders / empty inputs / source #f)", tier = "silver", gate = "reproducible-build", detect = "structural_drift/SD021", template_ref = "-" }, +] + +[[category]] +id = 9 +key = "web-service-standards" +name = "Web & Service Standards" +weight = 3 +criteria = [ + { id = "9.1.1", name = "tls13", desc = "TLS 1.3 only for deployed endpoints", tier = "gold", gate = "api-service", detect = "manual", template_ref = "-" }, + { id = "9.1.2", name = "csp-hsts", desc = "Content-Security-Policy + HSTS on web surfaces", tier = "gold", gate = "web-ui", detect = "manual", template_ref = "-" }, + { id = "9.1.3", name = "wcag-aa", desc = "WCAG 2.1 AA on web UI", tier = "gold", gate = "web-ui", detect = "manual", template_ref = "-" }, + { id = "9.1.4", name = "wellknown-served", desc = "Deployed site serves its .well-known/ over HTTPS", tier = "gold", gate = "docs-site", detect = "manual", template_ref = ".github/workflows/wellknown-enforcement.yml" }, +] + +[[category]] +id = 10 +key = "community-governance" +name = "Community & Governance" +weight = 2 +criteria = [ + { id = "10.1.1", name = "governance-model", desc = "Governance model documented (not just a stub)", tier = "gold", gate = "governance-tier", detect = "manual", template_ref = "docs/GOVERNANCE.adoc" }, + { id = "10.1.2", name = "succession", desc = "Succession / bus-factor plan", tier = "rhodium", gate = "governance-tier", detect = "manual", template_ref = "docs/MAINTAINERS.adoc" }, + { id = "10.1.3", name = "affirmation", desc = "AFFIRMATION.adoc (no-overclaim ethos) present", tier = "gold", gate = "governance-tier", detect = "manual", template_ref = "docs/AFFIRMATION.adoc" }, +] + +[[category]] +id = 11 +key = "accountability-provenance" +name = "Accountability & Provenance" +weight = 1 +criteria = [ + { id = "11.1.1", name = "audit-doc", desc = "AUDIT.adoc — audit trail / evidence ledger", tier = "gold", gate = "governance-tier", detect = "manual", template_ref = "docs/AUDIT.adoc" }, + { id = "11.1.2", name = "provenance-json", desc = ".well-known/provenance.json provenance chain", tier = "rhodium", gate = "web-ui", detect = "manual", template_ref = "-" }, + { id = "11.1.3", name = "signed-commits", desc = "Commits signed (advisory in agent envs; see estate note)", tier = "rhodium", gate = "universal", detect = "git_state/unsigned_commit", template_ref = "-" }, +] + +[weights-check] +# 12+12+14+14+12+8+6+4+3+2+1 = 88 for categories 1-11 base weights below Gold-exemplary. +# NOTE: weights are the per-category maxima; the effective denominator is always the +# APPLICABLE-criteria weight sum per [scoring], so this total is informative, not a gate. +sum-declared = 88 +note = "The remaining headroom to 100 is the rhodium-only exemplary criteria (gate windows: formal-proofs, succession, provenance chains, spark-ready). A repo with no optional capabilities is scored purely on its applicable universal set." diff --git a/bots/rhodibot/src/canon.rs b/bots/rhodibot/src/canon.rs new file mode 100644 index 00000000..14be3976 --- /dev/null +++ b/bots/rhodibot/src/canon.rs @@ -0,0 +1,984 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! The RSR criteria, read from the canon instead of remembered here. +//! +//! # Why this module exists +//! +//! Rhodibot's compliance rules used to be a hand-written table compiled into +//! [`crate::rsr`]: paths, points and severities decided once, by hand, and +//! edited by hand ever after. The canon (`standards/0-canon/rsr/ +//! rsr-criteria-v2.a2ml`) is the single source of truth for those criteria, and +//! it says so explicitly: +//! +//! > This file is the sole normative source for RSR v2.0 criteria. Every other +//! > artefact — the prose checklist, the one normative checker's rule table, +//! > and the badge thresholds — is GENERATED from this file. +//! +//! So the rule table belongs downstream of the canon, not beside it. This module +//! reads the canon's A2ML record dialect into typed data, validates it, and +//! refuses to load a copy that has drifted from its pin. +//! +//! # What this module is not +//! +//! RSR v2.0 designates exactly one *normative* checker — hypatia's +//! `rsr-conformance` rule family — and rhodibot is not it. The canon retires +//! most of what came before (`rsr-audit.sh` demoted to a non-normative +//! reference, `rsr-check.scm` retired with the `.scm` era, +//! `rsr-compliance-checklist.k9.ncl` retired) and names `rsr-certifier` +//! explicitly as "product, not the spec's oracle". +//! +//! Rhodibot is therefore a consumer: it evaluates what it can see from a +//! repository tree, reports against canon criterion ids rather than inventing +//! its own, and stays advisory. Nothing here should be read as authority. +//! +//! # Fail-closed, on purpose +//! +//! Parsing is validated rather than trusted. A canon copy that loses a category, +//! repeats a criterion id, invents a tier, or whose weights stop adding up to +//! the total the canon declares for itself is an error, not a smaller rule set. +//! The failure mode to avoid is the quiet one: a rule set that silently shrinks +//! and reports every repository as compliant. + +pub mod requirement; + +use std::collections::HashSet; +use std::fmt; +use std::path::Path; + +use anyhow::{Context, Result, bail, ensure}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// The vendored canon, embedded at compile time. +/// +/// Embedded rather than read at run time so that the rule set a binary applies +/// is fixed when it is built: a deployment cannot end up evaluating against +/// whatever the filesystem happens to contain. +pub const VENDORED_CRITERIA: &str = include_str!("../canon/rsr-criteria-v2.a2ml"); + +/// The pin describing which canon revision [`VENDORED_CRITERIA`] came from. +const VENDORED_PIN: &str = include_str!("../canon/pin.toml"); + +/// SHA-256 of a canon source, hex encoded. +pub fn digest_of(source: &str) -> String { + hex::encode(Sha256::digest(source.as_bytes())) +} + +/// The lowest tier at which a criterion is required. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Bronze, + Silver, + Gold, + Rhodium, +} + +impl Tier { + /// Parse a tier name as the canon spells it. + pub fn parse(value: &str) -> Result { + match value { + "bronze" => Ok(Self::Bronze), + "silver" => Ok(Self::Silver), + "gold" => Ok(Self::Gold), + "rhodium" => Ok(Self::Rhodium), + other => bail!("unknown tier {other:?} (expected bronze, silver, gold or rhodium)"), + } + } + + /// The canon's spelling, for reports. + pub fn as_str(self) -> &'static str { + match self { + Self::Bronze => "bronze", + Self::Silver => "silver", + Self::Gold => "gold", + Self::Rhodium => "rhodium", + } + } +} + +impl fmt::Display for Tier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One criterion from the canon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Criterion { + /// Stable dotted id (`category.group.item`), stable across minor releases. + pub id: String, + /// Short slug. + pub name: String, + /// What the criterion requires, in the canon's own words. + pub desc: String, + /// Lowest tier at which it is required. + pub tier: Tier, + /// `universal`, or a capability the repository must declare. + pub gate: String, + /// The hypatia rule that detects it, or `manual`. + pub detect: String, + /// Where the template satisfies it, or `-`. + pub template_ref: String, +} + +impl Criterion { + /// Whether the canon admits there is no automated check for this yet. + pub fn is_manual(&self) -> bool { + self.detect == "manual" + } + + /// The capability this criterion is gated on, or `None` when universal. + pub fn capability(&self) -> Option<&str> { + (self.gate != "universal").then_some(self.gate.as_str()) + } +} + +/// A weighted category of criteria. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Category { + pub id: u32, + pub key: String, + pub name: String, + pub weight: u32, + pub criteria: Vec, +} + +/// Tier thresholds, as percentages of the applicable weighted set. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Tiers { + pub bronze: u32, + pub silver: u32, + pub gold: u32, + pub rhodium: u32, +} + +/// The canon, parsed and validated. +#[derive(Debug, Clone)] +pub struct Canon { + pub version: String, + pub status: String, + pub tiers: Tiers, + pub categories: Vec, + /// The total the canon declares for its own category weights, if it says. + pub declared_weight_sum: Option, + /// SHA-256 of the exact source this was parsed from. + pub digest: String, +} + +impl Canon { + /// Parse and validate canon source. + pub fn parse(source: &str) -> Result { + let mut builder = Builder::default(); + let mut section = String::new(); + let mut category: Option = None; + let mut in_list = false; + let mut skipping_list = false; + let mut line_no = 0; + + for raw in source.lines() { + line_no += 1; + let line = strip_comment(raw).trim().to_string(); + if line.is_empty() { + continue; + } + + // Inside an open `criteria = [` list, only records and the closing + // bracket are valid. Anything else -- a section header among them -- + // means the list was never closed, which is what a truncated or + // mis-edited canon looks like. Swallowing it would drop criteria + // silently, which is the failure this module exists to prevent. + if in_list { + if line == "]" { + in_list = false; + continue; + } + ensure!( + line.starts_with('{'), + "line {line_no}: expected a criteria record or `]`, found {line:?}" + ); + let target = category.as_mut().with_context(|| { + format!("line {line_no}: a criteria record outside any category") + })?; + target.criteria.push(parse_record(&line, line_no)?); + continue; + } + + // Prose lists (`[oracle]` has `retired = [ "rsr-audit.sh ...", ... ]`) + // are skipped rather than parsed: nothing here consumes them, and + // refusing to read the canon over one would make this consumer + // brittle for no gain. Their contents are not assignments, so they + // cannot be read as any. + if skipping_list { + if line == "]" { + skipping_list = false; + } + continue; + } + + if line == "[[category]]" { + if let Some(previous) = category.take() { + builder.categories.push(previous); + } + category = Some(Category { + id: 0, + key: String::new(), + name: String::new(), + weight: 0, + criteria: Vec::new(), + }); + section = "category".to_string(); + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + section = line.trim_matches(['[', ']']).trim().to_string(); + continue; + } + + let Some((key, value)) = split_assignment(&line) else { + bail!("line {line_no}: expected `key = value`, found {line:?}"); + }; + + // A multi-line list other than a category's criteria: skip it. + if value == "[" && !(section == "category" && key == "criteria") { + ensure!( + section != "category", + "line {line_no}: unknown category field {key:?}; \ + this parser is older than the canon it is reading" + ); + skipping_list = true; + continue; + } + + match section.as_str() { + "meta" => match key { + "version" => builder.version = unquote(value, line_no)?, + "status" => builder.status = unquote(value, line_no)?, + // The rest of [meta] is prose about the spec. A new key + // there is harmless to a consumer, so it is ignored rather + // than treated as a reason to refuse the file. + _ => {} + }, + // The four thresholds are numbers; the same section also + // carries prose (`scale = "X F E D C B A maps onto ..."`), so + // only the known keys are parsed. That all four are present and + // in order is checked once, at the end. + "tiers" => { + if matches!(key, "bronze" | "silver" | "gold" | "rhodium") { + let parsed: u32 = value + .parse() + .with_context(|| format!("line {line_no}: {key} is not a number"))?; + match key { + "bronze" => builder.tiers.bronze = parsed, + "silver" => builder.tiers.silver = parsed, + "gold" => builder.tiers.gold = parsed, + _ => builder.tiers.rhodium = parsed, + } + } + } + "weights-check" => { + if key == "sum-declared" { + builder.declared_weight_sum = + Some(value.parse().with_context(|| { + format!("line {line_no}: {key} is not a number") + })?); + } + } + "category" => { + let target = category.as_mut().with_context(|| { + format!("line {line_no}: a category field before any [[category]]") + })?; + match key { + "id" => { + target.id = value.parse().with_context(|| { + format!("line {line_no}: category id is not a number") + })? + } + "key" => target.key = unquote(value, line_no)?, + "name" => target.name = unquote(value, line_no)?, + "weight" => { + target.weight = value.parse().with_context(|| { + format!("line {line_no}: category weight is not a number") + })? + } + "criteria" => { + ensure!( + value == "[", + "line {line_no}: expected `criteria = [`, found {value:?}" + ); + in_list = true; + } + // A field this parser does not know about, inside the + // structural part of the canon, means the parser and the + // canon disagree about what a category is. Refusing is + // the point: a typo such as `weigth = 12` would + // otherwise drop a weight and quietly change every score. + other => bail!( + "line {line_no}: unknown category field {other:?}; \ + this parser is older than the canon it is reading" + ), + } + } + // [scoring], [oracle] and [versioning] are prose for humans and + // for hypatia; nothing here consumes them yet. + _ => {} + } + } + + ensure!( + !in_list, + "the canon ends inside a `criteria = [` list: the closing `]` is missing" + ); + + if let Some(last) = category.take() { + builder.categories.push(last); + } + + builder.finish(digest_of(source)) + } + + /// The canon vendored in this repository. + pub fn vendored() -> Result { + Self::parse(VENDORED_CRITERIA) + } + + /// Parse a canon file from disk. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let source = std::fs::read_to_string(path) + .with_context(|| format!("reading canon at {}", path.display()))?; + Self::parse(&source) + } + + /// Every criterion, in canon order. + pub fn criteria(&self) -> impl Iterator { + self.categories.iter().flat_map(|c| c.criteria.iter()) + } + + /// How many criteria the canon defines. + pub fn criterion_count(&self) -> usize { + self.criteria().count() + } + + /// The sum of category weights as written. + pub fn weight_sum(&self) -> u32 { + self.categories.iter().map(|c| c.weight).sum() + } + + /// Criteria a repository must satisfy at the given tier or below, for the + /// criteria that are universal. Capability-gated criteria are excluded: + /// they apply only where the repository declares the capability. + pub fn universal_criteria_up_to(&self, tier: Tier) -> impl Iterator { + self.criteria() + .filter(move |c| c.tier <= tier && c.capability().is_none()) + } +} + +/// A pin: which canon revision a vendored copy came from. +#[derive(Debug, Clone, Deserialize)] +pub struct Pin { + pub source: PinSource, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PinSource { + pub repo: String, + pub path: String, + pub canon_version: String, + pub criteria_version: String, + pub released: String, + pub sha256: String, + /// How many categories the pinned revision has. + pub categories: usize, + /// How many criteria it has. + pub criteria: usize, + /// The total its category weights sum to. + pub weight_sum: u32, +} + +impl Pin { + /// Parse a pin file. + pub fn parse(source: &str) -> Result { + toml::from_str(source).context("parsing the canon pin") + } + + /// The pin shipped beside the vendored canon. + pub fn vendored() -> Result { + Self::parse(VENDORED_PIN) + } + + /// Check a parsed canon against the shape this pin describes. + /// + /// The digest proves the bytes are the ones recorded; these counts catch a + /// copy that is byte-identical to *nothing* -- a hand-edited file whose + /// digest was casually updated along with it, or a parse that silently lost + /// records. A rule set that shrinks is the failure that matters here, so the + /// size of the rule set is pinned too. + pub fn verify_counts(&self, canon: &Canon) -> Result<()> { + let source = &self.source; + ensure!( + canon.categories.len() == source.categories, + "pinned canon has {} categories, this copy has {}", + source.categories, + canon.categories.len() + ); + ensure!( + canon.criterion_count() == source.criteria, + "pinned canon has {} criteria, this copy has {}", + source.criteria, + canon.criterion_count() + ); + ensure!( + canon.weight_sum() == source.weight_sum, + "pinned canon weights sum to {}, this copy sums to {}", + source.weight_sum, + canon.weight_sum() + ); + Ok(()) + } + + /// Check a canon source against this pin. + pub fn verify(&self, source: &str) -> Result<()> { + let actual = digest_of(source); + let expected = self.source.sha256.to_lowercase(); + if actual != expected { + bail!( + "vendored canon does not match its pin: {} says {} at {}, this copy hashes {}. \ + Either re-pin (copy {}, update sha256 in canon/pin.toml) or restore the copy.", + self.source.path, + expected, + self.source.released, + actual, + self.source.path + ); + } + Ok(()) + } +} + +impl fmt::Display for Pin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}@{} ({})", + self.source.repo, self.source.path, self.source.sha256 + ) + } +} + +#[derive(Default)] +struct Builder { + version: String, + status: String, + tiers: Tiers, + categories: Vec, + declared_weight_sum: Option, +} + +impl Builder { + fn finish(self, digest: String) -> Result { + ensure!( + !self.version.is_empty(), + "the canon declares no [meta] version; a rule set without a version cannot be pinned" + ); + ensure!( + !self.categories.is_empty(), + "the canon declares no [[category]] blocks; refusing to apply an empty rule set" + ); + + let mut seen_keys = HashSet::new(); + let mut seen_ids = HashSet::new(); + + for (index, category) in self.categories.iter().enumerate() { + let expected_category_id = index as u32 + 1; + ensure!( + category.id == expected_category_id, + "category ids must run consecutively from 1; expected {expected_category_id}, found {}", + category.id + ); + + ensure!( + !category.key.is_empty() && !category.name.is_empty(), + "category {} has no key or name", + category.id + ); + ensure!( + seen_keys.insert(category.key.clone()), + "category key {:?} appears twice", + category.key + ); + ensure!( + category.weight > 0, + "category {} has a zero weight; every weight must be positive", + category.id + ); + ensure!( + !category.criteria.is_empty(), + "category {} ({}) declares no criteria", + category.id, + category.key + ); + + for criterion in &category.criteria { + let mut parts = criterion.id.split('.'); + let category_part = parts.next().unwrap_or_default(); + ensure!( + parts.next().is_some() && parts.next().is_some() && parts.next().is_none(), + "criterion id {:?} is not `category.group.item`", + criterion.id + ); + let declared_category: u32 = category_part.parse().with_context(|| { + format!("criterion id {:?} has a non-numeric category", criterion.id) + })?; + ensure!( + declared_category == category.id, + "criterion {:?} sits in category {}", + criterion.id, + category.id + ); + ensure!( + seen_ids.insert(criterion.id.clone()), + "criterion id {:?} appears twice", + criterion.id + ); + ensure!( + !criterion.name.is_empty() + && !criterion.desc.is_empty() + && !criterion.gate.is_empty() + && !criterion.detect.is_empty() + && !criterion.template_ref.is_empty(), + "criterion {:?} is missing a field", + criterion.id + ); + } + } + + // Every threshold must be stated, and the ladder must ascend. A tier + // left at zero because its key was misspelt would make everything pass. + let tiers = self.tiers; + ensure!( + tiers.bronze > 0 + && tiers.bronze <= tiers.silver + && tiers.silver <= tiers.gold + && tiers.gold <= tiers.rhodium + && tiers.rhodium <= 100, + "tier thresholds are missing or out of order: bronze={} silver={} gold={} rhodium={}", + tiers.bronze, + tiers.silver, + tiers.gold, + tiers.rhodium + ); + + // The canon checks its own arithmetic in [weights-check]. That section + // is required, not optional: it sits at the end of the file, so a + // truncated copy loses it along with whatever was cut -- which is + // exactly the case where a missing check would go unnoticed. Refusing a + // canon that does not state its own total means truncation cannot hide. + let declared = self.declared_weight_sum.with_context(|| { + "the canon states no [weights-check] sum-declared total, so its own weights cannot be \ + checked; refusing to apply a rule set that cannot be verified" + })?; + + let weight_sum: u32 = self.categories.iter().map(|c| c.weight).sum(); + ensure!( + declared == weight_sum, + "the canon declares its category weights sum to {declared}, but they sum to {weight_sum}; \ + a mismatch means this copy lost or gained a category" + ); + + Ok(Canon { + version: self.version, + status: self.status, + tiers: self.tiers, + categories: self.categories, + declared_weight_sum: self.declared_weight_sum, + digest, + }) + } +} + +/// Parse `{ field = "value", ... }` into a criterion. +fn parse_record(line: &str, line_no: usize) -> Result { + // List entries are comma-separated, so the closing brace is normally + // followed by one. The brace that matters is the last on the line: several + // `desc` values carry braces of their own + // (`.well-known/{security.txt,ai.txt,humans.txt}`). + let record = line.trim().trim_end_matches(',').trim(); + let inner = record + .strip_prefix('{') + .and_then(|rest| rest.strip_suffix('}')) + .with_context(|| { + format!("line {line_no}: a criteria record must be one `{{ ... }}` line") + })?; + + let (mut id, mut name, mut desc, mut tier, mut gate, mut detect, mut template_ref) = + (None, None, None, None, None, None, None); + + for field in split_fields(inner) { + let field = field.trim(); + if field.is_empty() { + bail!("line {line_no}: empty field in a criteria record"); + } + let (key, value) = split_assignment(field) + .with_context(|| format!("line {line_no}: field {field:?} is not `key = value`"))?; + let value = unquote(value, line_no)?; + match key { + "id" => id = Some(value), + "name" => name = Some(value), + "desc" => desc = Some(value), + "tier" => tier = Some(Tier::parse(&value).with_context(|| format!("line {line_no}"))?), + "gate" => gate = Some(value), + "detect" => detect = Some(value), + "template_ref" => template_ref = Some(value), + other => bail!("line {line_no}: unknown criterion field {other:?}"), + } + } + + let missing = [ + ("id", id.is_none()), + ("name", name.is_none()), + ("desc", desc.is_none()), + ("tier", tier.is_none()), + ("gate", gate.is_none()), + ("detect", detect.is_none()), + ("template_ref", template_ref.is_none()), + ] + .iter() + .filter(|(_, absent)| *absent) + .map(|(name, _)| *name) + .collect::>(); + ensure!( + missing.is_empty(), + "line {line_no}: criteria record is missing {missing:?}" + ); + + Ok(Criterion { + id: id.unwrap_or_default(), + name: name.unwrap_or_default(), + desc: desc.unwrap_or_default(), + tier: tier.unwrap_or(Tier::Bronze), + gate: gate.unwrap_or_default(), + detect: detect.unwrap_or_default(), + template_ref: template_ref.unwrap_or_default(), + }) +} + +/// Drop a `#` comment, ignoring a `#` inside a quoted value. +fn strip_comment(line: &str) -> &str { + let mut in_string = false; + for (index, ch) in line.char_indices() { + match ch { + '"' => in_string = !in_string, + '#' if !in_string => return &line[..index], + _ => {} + } + } + line +} + +/// Split `key = value`, requiring whitespace or nothing around the `=`. +fn split_assignment(line: &str) -> Option<(&str, &str)> { + let (key, value) = line.split_once('=')?; + Some((key.trim(), value.trim())) +} + +/// Remove the surrounding quotes from a quoted value. +fn unquote(value: &str, line_no: usize) -> Result { + value + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .map(str::to_string) + .with_context(|| format!("line {line_no}: expected a quoted value, found {value:?}")) +} + +/// Split a record body on commas that are not inside a quoted value. +/// +/// The canon's `desc` fields contain commas (`.well-known/{security.txt,ai.txt, +/// humans.txt}` is one value), so splitting naively loses fields -- which is how +/// a "missing tier" appears where the canon is perfectly well formed. +fn split_fields(inner: &str) -> Vec<&str> { + let mut fields = Vec::new(); + let mut start = 0; + let mut in_string = false; + for (index, ch) in inner.char_indices() { + match ch { + '"' => in_string = !in_string, + ',' if !in_string => { + fields.push(&inner[start..index]); + start = index + 1; + } + _ => {} + } + } + fields.push(&inner[start..]); + fields +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vendored() -> Canon { + Canon::vendored().expect("the vendored canon parses") + } + + #[test] + fn parses_the_vendored_canon() { + let canon = vendored(); + assert_eq!(canon.categories.len(), 11, "the canon has 11 categories"); + assert_eq!(canon.criterion_count(), 74); + assert_eq!(canon.weight_sum(), 88); + assert_eq!(canon.declared_weight_sum, Some(88)); + assert_eq!(canon.tiers.bronze, 75); + assert_eq!(canon.tiers.rhodium, 100); + assert_eq!(canon.status, "draft"); + } + + #[test] + fn the_vendored_canon_matches_its_pin() { + let pin = Pin::vendored().expect("pin parses"); + pin.verify(VENDORED_CRITERIA).expect("pin verifies"); + pin.verify_counts(&vendored()).expect("shape verifies"); + assert_eq!(pin.source.repo, "hyperpolymath/standards"); + assert_eq!(pin.source.sha256, digest_of(VENDORED_CRITERIA)); + assert_eq!(pin.source.categories, 11); + assert_eq!(pin.source.criteria, 74); + assert_eq!(pin.source.weight_sum, 88); + } + + #[test] + fn criteria_are_grouped_under_their_category() { + let canon = vendored(); + for category in &canon.categories { + for criterion in &category.criteria { + let prefix: u32 = criterion + .id + .split('.') + .next() + .and_then(|p| p.parse().ok()) + .expect("numeric prefix"); + assert_eq!( + prefix, category.id, + "{} is in the wrong category", + criterion.id + ); + } + } + } + + #[test] + fn the_canon_is_honest_about_what_it_cannot_detect() { + // Not a rule about the canon so much as a guard on the pilot: if this + // number moves, the automated coverage story changes with it. + let canon = vendored(); + let manual = canon.criteria().filter(|c| c.is_manual()).count(); + assert_eq!(manual, 19, "criteria with no automated detection"); + let gated = canon + .criteria() + .filter(|c| c.capability().is_some()) + .count(); + assert_eq!(gated, 26, "criteria gated on a declared capability"); + } + + // ---- fail-closed --------------------------------------------------------- + + #[test] + fn a_duplicate_criterion_id_is_rejected() { + let source = VENDORED_CRITERIA.replacen("{ id = \"1.1.3\"", "{ id = \"1.1.1\"", 1); + let error = Canon::parse(&source).expect_err("duplicates must not parse"); + assert!(format!("{error:#}").contains("appears twice"), "{error:#}"); + } + + #[test] + fn a_criterion_in_the_wrong_category_is_rejected() { + let source = VENDORED_CRITERIA.replacen("{ id = \"2.1.1\"", "{ id = \"7.1.1\"", 1); + let error = Canon::parse(&source).expect_err("a misfiled criterion must not parse"); + assert!( + format!("{error:#}").contains("sits in category"), + "{error:#}" + ); + } + + #[test] + fn an_unknown_tier_is_rejected() { + let source = VENDORED_CRITERIA.replacen("tier = \"bronze\"", "tier = \"platinum\"", 1); + let error = Canon::parse(&source).expect_err("an invented tier must not parse"); + assert!(format!("{error:#}").contains("unknown tier"), "{error:#}"); + } + + #[test] + fn weights_that_disagree_with_the_declared_total_are_rejected() { + let source = VENDORED_CRITERIA.replacen("sum-declared = 88", "sum-declared = 100", 1); + let error = + Canon::parse(&source).expect_err("arithmetic that no longer holds must not parse"); + assert!(format!("{error:#}").contains("sum to"), "{error:#}"); + } + + #[test] + fn a_lost_category_is_caught_by_the_arithmetic() { + // Drop category 11 entirely, as a truncated copy would. The remaining + // weights no longer match the declared total, so the file is refused + // rather than quietly evaluated as a smaller rule set. + // The canon holds 11 `[[category]]` headers, so splitting on them gives + // 12 pieces: the preamble plus 11 categories. Keeping 11 drops the last. + let truncated: String = VENDORED_CRITERIA + .split("[[category]]") + .take(11) + .collect::>() + .join("[[category]]"); + let error = Canon::parse(&truncated).expect_err("a truncated canon must not parse"); + let message = format!("{error:#}"); + assert!( + message.contains("weights-check"), + "truncation must be refused for the right reason: {message}" + ); + } + + #[test] + fn an_unterminated_criteria_list_is_rejected() { + // Written out rather than mutated from the vendored file so the test + // does not depend on that file's formatting. The first category's list + // is never closed; the next header therefore arrives with it still open. + let source = "\ +[meta] +version = \"2.0.0\" + +[[category]] +id = 1 +key = \"first\" +name = \"First\" +weight = 1 +criteria = [ + { id = \"1.1.1\", name = \"n\", desc = \"d\", tier = \"bronze\", gate = \"universal\", detect = \"manual\", template_ref = \"-\" } + +[[category]] +id = 2 +key = \"second\" +name = \"Second\" +weight = 1 +criteria = [ + { id = \"2.1.1\", name = \"n\", desc = \"d\", tier = \"bronze\", gate = \"universal\", detect = \"manual\", template_ref = \"-\" } +] +"; + let error = Canon::parse(source).expect_err("an unterminated list must not parse"); + assert!( + format!("{error:#}").contains("expected a criteria record or `]`"), + "{error:#}" + ); + } + + #[test] + fn an_unknown_category_field_is_rejected() { + let source = VENDORED_CRITERIA.replacen("weight = 12", "weigth = 12", 1); + let error = Canon::parse(&source).expect_err("a misspelt field must not parse"); + assert!( + format!("{error:#}").contains("unknown category field"), + "{error:#}" + ); + } + + #[test] + fn an_empty_rule_set_is_rejected() { + let error = Canon::parse("[meta]\nversion = \"2.0.0\"\n").expect_err("no categories"); + assert!( + format!("{error:#}").contains("no [[category]]"), + "{error:#}" + ); + } + + #[test] + fn a_canon_without_a_version_is_rejected() { + let source = VENDORED_CRITERIA.replacen("version = \"2.0.0-draft\"", "", 1); + let error = Canon::parse(&source).expect_err("a versionless canon must not parse"); + assert!( + format!("{error:#}").contains("no [meta] version"), + "{error:#}" + ); + } + + #[test] + fn a_canon_that_parses_but_is_smaller_than_the_pin_is_caught() { + // The second line of defence, for a copy that is internally consistent + // -- it declares its own weights correctly -- but is not the revision + // the pin describes. A digest comparison alone would also catch this; + // the counts catch a copy whose digest was updated by hand along with + // its contents, which is how a rule set quietly loses a category. + let source = "\ +[meta] +version = \"2.0.0-draft\" + +[tiers] +bronze = 75 +silver = 90 +gold = 100 +rhodium = 100 + +[[category]] +id = 1 +key = \"only\" +name = \"Only\" +weight = 1 +criteria = [ + { id = \"1.1.1\", name = \"n\", desc = \"d\", tier = \"bronze\", gate = \"universal\", detect = \"manual\", template_ref = \"-\" } +] + +[weights-check] +sum-declared = 1 +"; + let canon = Canon::parse(source).expect("the small canon is internally consistent"); + let pin = Pin::vendored().expect("pin parses"); + let error = pin + .verify_counts(&canon) + .expect_err("a smaller rule set must not pass the pin"); + assert!(format!("{error:#}").contains("categories"), "{error:#}"); + } + + #[test] + fn a_mismatched_pin_is_reported_with_both_hashes() { + let pin = Pin::vendored().expect("pin parses"); + let error = pin + .verify(&format!("{VENDORED_CRITERIA}\n# edited")) + .expect_err("an edited canon must fail its pin"); + let message = format!("{error:#}"); + assert!(message.contains("does not match its pin"), "{message}"); + assert!(message.contains(&pin.source.sha256), "{message}"); + assert!( + message.contains(&digest_of(&format!("{VENDORED_CRITERIA}\n# edited"))), + "{message}" + ); + } + + #[test] + fn commas_inside_a_description_do_not_split_the_record() { + let canon = vendored(); + let wellknown = canon + .criteria() + .find(|c| c.id == "2.2.1") + .expect("2.2.1 exists"); + assert_eq!( + wellknown.desc, + ".well-known/{security.txt,ai.txt,humans.txt}" + ); + assert_eq!(wellknown.tier, Tier::Silver); + assert_eq!(wellknown.gate, "universal"); + } + + #[test] + fn comments_do_not_truncate_quoted_hashes() { + assert_eq!( + strip_comment("sha256 = \"abc#def\" # trailing"), + "sha256 = \"abc#def\" " + ); + assert_eq!(strip_comment("weight = 12 # twelve"), "weight = 12 "); + } + + #[test] + fn universal_criteria_below_gold_are_counted_as_the_canon_states() { + let canon = vendored(); + let bronze_universal = canon.universal_criteria_up_to(Tier::Bronze).count(); + assert_eq!( + bronze_universal, 17, + "universal criteria required at bronze" + ); + } +} diff --git a/bots/rhodibot/src/canon/requirement.rs b/bots/rhodibot/src/canon/requirement.rs new file mode 100644 index 00000000..45e3d5c4 --- /dev/null +++ b/bots/rhodibot/src/canon/requirement.rs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! What a criterion requires, read from its own description. +//! +//! # Why not `template_ref` +//! +//! The pilot read `template_ref` as the path a criterion requires. Over five +//! repositories that invented violations: criterion 1.2.2 asks for a +//! ".pre-commit-config.yaml with real hooks", the template keeps its copy in +//! `ci/`, and repositories that keep one at the root were reported as missing +//! it. `template_ref` records where the *template* satisfies a criterion; it is +//! a traceability pointer, not a requirement. +//! +//! The description is what the criterion asks for, so requirements are read +//! from there. +//! +//! # Deliberately conservative +//! +//! This is a parser for prose, which is a good reason to keep it narrow. It +//! handles the shapes the canon actually uses and gives up on everything else: +//! +//! - `README.adoc present` -> one requirement +//! - `.gitignore and .gitattributes present` -> both, because one file does not +//! satisfy it +//! - `FUNDING.yml / .github/FUNDING.yml` -> either location +//! - `.well-known/{security.txt,ai.txt,humans.txt}` -> three paths, braces +//! expanded +//! - `LICENSE present + LICENSES/ REUSE texts (NOT LICENSE.txt)` -> two, with +//! the parenthesised exclusion dropped and `NOT` never read as a requirement +//! - `CHANGELOG.adoc, or .md (Keep a Changelog)` -> **no requirement at all** +//! +//! Giving up matters as much as parsing. That last one offers `.md` as an +//! alternative, and a checker that keeps only `CHANGELOG.adoc` would report +//! every repository using `CHANGELOG.md` as non-compliant. Where a description +//! names an alternative this parser cannot turn into a path, the criterion is +//! left to a human rather than guessed at. + +/// Groups of paths, every group of which must be satisfied. +/// +/// A group is satisfied when any one of its paths exists: the canon offers +/// alternative locations, not alternatives to the requirement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Requirement { + pub all_of: Vec>, +} + +impl Requirement { + /// Every path the requirement names, in order. + pub fn paths(&self) -> impl Iterator { + self.all_of.iter().flatten() + } + + /// How many files have to be present, at minimum. + pub fn group_count(&self) -> usize { + self.all_of.len() + } + + /// Is this path anchored to a directory by the description? + /// + /// The canon names some files by their bare name -- "STATE.a2ml", "Justfile" + /// -- and gives the directory only in a neighbouring criterion. A check that + /// reads a bare basename as a root path reports every repository as missing + /// it, which is the mistake the pilot was built to find. Callers that care + /// about location should treat an unanchored path as "this file, anywhere" + /// and say so in the report rather than assert a path the canon never gave. + pub fn is_anchored(path: &str) -> bool { + path.contains('/') + } +} + +/// Read a requirement from a criterion description. +/// +/// `None` means the description does not name files this can resolve, which is +/// an answer: the criterion is not a file-presence question. +pub fn requirement_from(desc: &str) -> Option { + // Parentheses hold explanation or exclusion -- "(NOT LICENSE.txt)", + // "(Keep a Changelog)", "(.adoc primary per estate doc policy)". None of it + // names something that must exist. + let bare = strip_parenthetical(desc); + + // " and " and " + " join things that are all required. " or ", " / " and + // "," offer alternatives. + let mut all_of = Vec::new(); + for group_text in split_on(&bare, &[" and ", " + "]) { + let mut any_of = Vec::new(); + let mut gave_up = false; + for alternative in split_on(group_text, &[" or ", " / ", ","]) { + let paths = expand(alternative); + if paths.is_empty() { + // A fragment that is not a path, in a position where the + // description was offering one. `.md` in + // "CHANGELOG.adoc, or .md" is the case this exists for: keeping + // only the sibling would fault every repository that chose the + // other option. + if looks_like_a_partial_path(alternative) { + gave_up = true; + break; + } + continue; + } + any_of.extend(paths); + } + + if gave_up { + return None; + } + if any_of.is_empty() { + continue; + } + all_of.push(any_of); + } + + (!all_of.is_empty()).then_some(Requirement { all_of }) +} + +/// Remove text inside parentheses, including the parentheses. +fn strip_parenthetical(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut depth = 0usize; + for ch in text.chars() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(ch), + _ => {} + } + } + out +} + +/// Split on the given separators, but never inside `{...}`. +/// +/// The brace form is how the canon lists the files of a directory +/// (`.well-known/{security.txt,ai.txt,humans.txt}`). Splitting on the commas +/// first would tear that into `.well-known/{security.txt`, `ai.txt` and +/// `humans.txt}`, and two of the three would be lost. +fn split_on<'a>(text: &'a str, separators: &[&str]) -> Vec<&'a str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut depth = 0usize; + let mut index = 0; + + // Iterated by character, not by byte: the canon's descriptions carry em + // dashes, and stepping through bytes would cut them in half. + while index < text.len() { + let ch = text[index..] + .chars() + .next() + .expect("index is a char boundary"); + + match ch { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + _ => {} + } + + if depth == 0 + && let Some(separator) = separators + .iter() + .find(|separator| text[index..].starts_with(**separator)) + { + parts.push(&text[start..index]); + index += separator.len(); + start = index; + continue; + } + + index += ch.len_utf8(); + } + + parts.push(&text[start..]); + parts +} + +/// The paths a fragment names, with `{a,b}` expanded. +fn expand(fragment: &str) -> Vec { + // "No Makefile (Mustfile/justfile only)" is a criterion about the absence + // of a file. Reading the filename out of it and requiring the file would + // invert the criterion -- the worst kind of wrong, because the report would + // confidently ask for the opposite of what the canon says. + let opening = fragment.trim_start().to_lowercase(); + if opening.starts_with("no ") || opening.starts_with("not ") || opening.starts_with("never ") { + return Vec::new(); + } + + let words: Vec<&str> = fragment + .split_whitespace() + .map(|word| { + word.trim_start_matches(['`', '"', '\'']) + .trim_end_matches(['`', '"', ',', ';', '.', ':']) + }) + .filter(|word| !word.is_empty()) + .collect(); + + let mut paths = Vec::new(); + for word in words { + if let Some((prefix, group, suffix)) = split_braces(word) { + for option in group.split(',') { + let candidate = format!("{prefix}{}{suffix}", option.trim()); + if is_path(&candidate) { + paths.push(candidate); + } + } + continue; + } + if is_path(word) { + paths.push(word.to_string()); + } + } + paths +} + +/// `a/{x,y}.txt` -> (`a/`, `x,y`, `.txt`) +fn split_braces(word: &str) -> Option<(&str, &str, &str)> { + let open = word.find('{')?; + let close = word.find('}')?; + (close > open).then(|| (&word[..open], &word[open + 1..close], &word[close + 1..])) +} + +/// Files the canon names without an extension. +/// +/// Kept as a list rather than inferred from capitalisation, so that prose +/// ("present", "texts", "dir") cannot qualify by being short and shouty. +const EXTENSIONLESS: &[&str] = &[ + "LICENSE", + "LICENCES", + "NOTICE", + "README", + "Justfile", + "Mustfile", + "Makefile", + "Dockerfile", + "CODEOWNERS", + "AUTHORS", + "CONTRIBUTORS", +]; + +/// Does this look like a path a repository could be checked for? +/// +/// A directory ends in a slash. Anything else needs an extension, or to be one +/// of the handful of files the canon names without one. This is what separates +/// `descriptiles/STATE.a2ml` from the prose `state/progress`, and `README.adoc` +/// from the version number `7.0` -- a description such as "Scorecard >= 7.0" +/// must not become a requirement for a file called `7.0`. +fn is_path(word: &str) -> bool { + if word.is_empty() || word == "/" || word.starts_with("NOT") { + return false; + } + // A bare number, or a dotted one: a version, a score, a threshold. + if word + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) + { + return false; + } + match word.rsplit_once('/') { + // A directory. + Some((_, "")) => true, + // A path: judge its last segment, which carries the extension. + Some((_, last)) => has_extension(last) || EXTENSIONLESS.contains(&last), + None => has_extension(word) || EXTENSIONLESS.contains(&word), + } +} + +/// Extensions that are extensions, not filenames. +/// +/// Criterion 3.2.1 says "All .a2ml files parse + validate …". `.a2ml` there is a +/// bare extension, and treating it as a filename asks every repository for a +/// file called `.a2ml`. A leading-dot token is read as a file only when what +/// follows is not one of these. +const EXTENSIONS_ONLY: &[&str] = &[ + "a2ml", "adoc", "k9", "md", "ncl", "json", "scm", "sh", "toml", "txt", "yaml", "yml", +]; + +/// `README.adoc` yes; `progress` no. +/// +/// A leading dot splits two ways: `.gitignore` and `.editorconfig` are files, +/// while `.md` and `.a2ml` are extensions with no name of their own. Treating +/// `.md` as a file would turn "CHANGELOG.adoc, or .md" into a requirement for a +/// file called `.md`. +fn has_extension(word: &str) -> bool { + match word.rsplit_once('.') { + Some(("", rest)) => { + rest.len() >= 3 + && !EXTENSIONS_ONLY.contains(&rest) + && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + } + Some((stem, extension)) => { + !stem.is_empty() + && (1..=8).contains(&extension.len()) + && extension.chars().all(|c| c.is_ascii_alphanumeric()) + } + None => false, + } +} + +/// A fragment that was offered as an alternative but is not a usable path. +/// +/// `.md` is the case: an extension with no stem, meaningful only next to the +/// sibling it modifies. +fn looks_like_a_partial_path(fragment: &str) -> bool { + fragment + .split_whitespace() + .any(|word| word.starts_with('.') && word.len() > 1 && !word.contains('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths(desc: &str) -> Vec { + requirement_from(desc) + .map(|r| r.paths().cloned().collect()) + .unwrap_or_default() + } + + #[test] + fn a_named_file_is_a_requirement() { + assert_eq!( + paths("README.adoc present (.adoc primary per estate doc policy)"), + ["README.adoc"] + ); + assert_eq!( + paths("SECURITY.md with a vulnerability-disclosure policy"), + ["SECURITY.md"] + ); + } + + #[test] + fn a_named_path_keeps_its_directories() { + assert_eq!( + paths(".machine_readable/descriptiles/STATE.a2ml — current state/progress"), + [".machine_readable/descriptiles/STATE.a2ml"] + ); + } + + #[test] + fn braces_expand_into_the_files_the_criterion_lists() { + assert_eq!( + paths(".well-known/{security.txt,ai.txt,humans.txt}"), + [ + ".well-known/security.txt", + ".well-known/ai.txt", + ".well-known/humans.txt" + ] + ); + } + + #[test] + fn both_sides_of_an_and_are_required() { + let requirement = + requirement_from(".gitignore and .gitattributes present").expect("a requirement"); + assert_eq!(requirement.group_count(), 2); + assert_eq!( + paths(".gitignore and .gitattributes present"), + [".gitignore", ".gitattributes"] + ); + } + + #[test] + fn a_slash_offers_locations_not_a_choice_of_requirement() { + // FUNDING.yml satisfies it as surely as .github/FUNDING.yml does. + let requirement = + requirement_from("FUNDING.yml / .github/FUNDING.yml").expect("a requirement"); + assert_eq!( + requirement.group_count(), + 1, + "one requirement, two locations" + ); + assert_eq!(requirement.all_of[0].len(), 2); + } + + #[test] + fn the_template_directory_convention_is_not_read_as_a_requirement() { + // The description names the file and no directory; that is the + // criterion. Anything about `ci/` is the template's business. + let requirement = + requirement_from(".pre-commit-config.yaml with real hooks").expect("a requirement"); + assert_eq!( + requirement.all_of, + vec![vec![".pre-commit-config.yaml".to_string()]] + ); + } + + #[test] + fn an_excluded_name_is_not_a_requirement() { + // "LICENSE present + LICENSES/ REUSE texts (NOT LICENSE.txt)" + let requirement = + requirement_from("LICENSE present + LICENSES/ REUSE texts").expect("a requirement"); + assert_eq!(requirement.group_count(), 2); + assert!( + !requirement.paths().any(|p| p == "LICENSE.txt"), + "an excluded spelling must never be required" + ); + } + + #[test] + fn a_directory_is_a_requirement_when_the_description_names_one() { + assert_eq!( + paths("REUSE-style LICENSES/ dir with full texts"), + ["LICENSES/"] + ); + } + + #[test] + fn an_alternative_this_cannot_resolve_gives_up_rather_than_guessing() { + // Keeping only CHANGELOG.adoc here would fault every repository that + // chose CHANGELOG.md, which the description allows. + assert!(requirement_from("CHANGELOG.adoc, or .md (Keep a Changelog)").is_none()); + } + + #[test] + fn a_criterion_about_absence_does_not_become_a_requirement_for_the_file() { + // 1.1.3 is "no-makefile". Reading `Makefile` out of it would require + // every repository to have the file the canon forbids. + assert!(requirement_from("No Makefile (Mustfile/justfile only)").is_none()); + assert!(requirement_from("No hardcoded secrets (secret scanner clean)").is_none()); + assert!(requirement_from("No plaintext HTTP URLs; HTTPS only").is_none()); + } + + #[test] + fn prose_with_a_slash_is_not_a_path() { + assert!(requirement_from("STATE.a2ml — current state/progress").is_some()); + let requirement = + requirement_from("STATE.a2ml — current state/progress").expect("a requirement"); + assert_eq!( + requirement.all_of, + vec![vec!["STATE.a2ml".to_string()]], + "`state/progress` is prose, not a directory" + ); + } + + #[test] + fn a_bare_extension_is_not_a_filename() { + // "All .a2ml files parse + validate against their record-dialect profile" + assert!( + requirement_from( + "All .a2ml files parse + validate against their record-dialect profile" + ) + .is_none(), + "`.a2ml` is an extension; requiring a file of that name would be nonsense" + ); + } + + #[test] + fn a_bare_basename_is_reported_as_unanchored() { + let requirement = + requirement_from("STATE.a2ml — current state/progress").expect("a requirement"); + let paths: Vec<&String> = requirement.paths().collect(); + assert_eq!(paths, [&"STATE.a2ml".to_string()]); + assert!( + !Requirement::is_anchored(paths[0]), + "the description names no directory" + ); + + let anchored = requirement_from(".well-known/{security.txt}").expect("a requirement"); + assert!(Requirement::is_anchored( + anchored.paths().next().expect("a path") + )); + } + + #[test] + fn a_description_that_names_no_files_has_no_requirement() { + for desc in [ + "No Python (fully banned)", + "Repository canonical on GitHub (GitLab is mirror-only)", + "OpenSSF Scorecard >= 7.0", + "TLS 1.3 only for deployed endpoints", + "No believe_me / sorry / Admitted in load-bearing proofs", + ] { + assert!( + requirement_from(desc).is_none(), + "{desc:?} names no file a repository can be checked for" + ); + } + } + + #[test] + fn the_deprecated_location_is_read_as_an_exclusion_not_a_requirement() { + // ".machine_readable/descriptiles/ present (NOT 6a2/, which is deprecated 2026-06-30)" + let requirement = + requirement_from(".machine_readable/descriptiles/ present").expect("a requirement"); + assert_eq!( + requirement.all_of, + vec![vec![".machine_readable/descriptiles/".to_string()]] + ); + assert!(!requirement.paths().any(|p| p.contains("6a2"))); + } +} diff --git a/bots/rhodibot/src/lib.rs b/bots/rhodibot/src/lib.rs index ac9bde04..9e3613fe 100644 --- a/bots/rhodibot/src/lib.rs +++ b/bots/rhodibot/src/lib.rs @@ -12,6 +12,7 @@ #![forbid(unsafe_code)] pub mod app_auth; +pub mod canon; pub mod config; pub mod fleet; pub mod github; diff --git a/bots/rhodibot/tests/canon_lockstep.rs b/bots/rhodibot/tests/canon_lockstep.rs new file mode 100644 index 00000000..a2b322f0 --- /dev/null +++ b/bots/rhodibot/tests/canon_lockstep.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! The canon lockstep, exercised from outside the crate. +//! +//! The unit tests in `src/canon.rs` check the parser against the canon. These +//! check the *binding*: that the copy in the repository is the revision the pin +//! names, that the copy a binary would apply is the same file, and that the rule +//! set has the shape the pilot's numbers are quoted from. +//! +//! The last test is the one to update deliberately rather than accidentally: it +//! records what the rule set contains, so a canon change that shrinks it fails +//! here instead of producing a quieter scorecard somewhere downstream. + +use std::path::PathBuf; + +use rhodibot::canon::{Canon, Pin, Tier, VENDORED_CRITERIA, digest_of}; + +/// The vendored canon as it sits on disk. +fn canon_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("canon/rsr-criteria-v2.a2ml") +} + +#[test] +fn the_file_on_disk_is_the_copy_compiled_into_the_binary() { + let on_disk = std::fs::read_to_string(canon_path()).expect("the vendored canon is readable"); + + // If these differ, a binary would apply one rule set and `Canon::load` + // another -- the two would agree on the digest and disagree on the rules. + assert_eq!( + digest_of(&on_disk), + digest_of(VENDORED_CRITERIA), + "the vendored file and the embedded copy have diverged" + ); + + let from_disk = Canon::load(canon_path()).expect("the file parses"); + let embedded = Canon::vendored().expect("the embedded copy parses"); + assert_eq!(from_disk.criterion_count(), embedded.criterion_count()); + assert_eq!(from_disk.weight_sum(), embedded.weight_sum()); +} + +#[test] +fn the_pin_describes_the_vendored_copy() { + let pin = Pin::vendored().expect("the pin parses"); + pin.verify(VENDORED_CRITERIA).expect("digest matches"); + pin.verify_counts(&Canon::vendored().expect("parses")) + .expect("shape matches"); +} + +#[test] +fn the_pin_refuses_a_copy_that_changed() { + let pin = Pin::vendored().expect("the pin parses"); + // One keystroke: an apostrophe in a description is enough to change what + // every repository is measured against. + let edited = VENDORED_CRITERIA.replacen(".gitignore and .gitattributes present", "x", 1); + + let error = pin + .verify(&edited) + .expect_err("an edited canon must be refused"); + assert!(format!("{error:#}").contains("does not match its pin")); +} + +#[test] +fn the_rule_set_the_pilot_is_quoted_from() { + let canon = Canon::vendored().expect("the canon parses"); + + assert_eq!(canon.categories.len(), 11, "weighted categories"); + assert_eq!(canon.criterion_count(), 74, "criteria in total"); + assert_eq!( + canon.weight_sum(), + 88, + "category weights, as the canon states" + ); + + let bronze = canon.criteria().filter(|c| c.tier == Tier::Bronze).count(); + let gold = canon.criteria().filter(|c| c.tier == Tier::Gold).count(); + let rhodium = canon.criteria().filter(|c| c.tier == Tier::Rhodium).count(); + assert_eq!((bronze, gold, rhodium), (17, 29, 4)); + + // The honest half of the coverage story: the canon says outright that these + // have no automated detection. A tool that reported them as passing would + // be inventing results. + assert_eq!(canon.criteria().filter(|c| c.is_manual()).count(), 19); + + // Capability-gated criteria apply only where a repository declares the + // capability, so they are excluded from a universal denominator rather than + // counted as failures. + let gated = canon + .criteria() + .filter(|c| c.capability().is_some()) + .count(); + assert_eq!(gated, 26); + + // What a repository with no declared capabilities is measured on at bronze. + assert_eq!(canon.universal_criteria_up_to(Tier::Bronze).count(), 17); +} + +#[test] +fn requirements_are_derivable_from_descriptions() { + use rhodibot::canon::requirement::requirement_from; + + let canon = Canon::vendored().expect("the canon parses"); + let mut derived = Vec::new(); + + for criterion in canon.criteria() { + if let Some(requirement) = requirement_from(&criterion.desc) { + derived.push(format!( + " {:<7} {:<8} {}", + criterion.id, + criterion.tier, + requirement.paths().cloned().collect::>().join(" | ") + )); + } + } + + for line in &derived { + println!("{line}"); + } + println!( + "filed presence requirements: {} of {} criteria", + derived.len(), + canon.criterion_count() + ); + + assert!( + derived.len() > 20 && derived.len() < canon.criterion_count(), + "some criteria name files and some do not; {} derived looks wrong", + derived.len() + ); +} diff --git a/scripts/check-canon-drift.sh b/scripts/check-canon-drift.sh new file mode 100755 index 00000000..5b496c6f --- /dev/null +++ b/scripts/check-canon-drift.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Has the canon moved since the copy was pinned? +# +# Rhodibot applies the RSR rule set from a copy of +# `0-canon/rsr/rsr-criteria-v2.a2ml`, pinned in `bots/rhodibot/canon/pin.toml`. +# The tests check that copy against the pin; this script asks the other +# question -- whether the canon itself has moved since. A canon revision changes +# what every repository is measured against, so it should arrive as a +# reviewable commit, not as a surprise on the next run. +# +# Exit status +# 0 the pin matches the canon, and the canon's own lock agrees with the file +# 1 drift, or the canon could not be read +# +# Environment +# CANON_PIN path to the pin file (default: the vendored pin) +# CANON_REF standards ref to compare against (default: main) +# CANON_REMOTE base URL for raw files (default: raw.githubusercontent) + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PIN="${CANON_PIN:-$ROOT/bots/rhodibot/canon/pin.toml}" +REF="${CANON_REF:-main}" +REMOTE="${CANON_REMOTE:-https://raw.githubusercontent.com/hyperpolymath/standards}" +TMPDIR_CHECK="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_CHECK"' EXIT + +fail() { + printf 'canon drift: %s\n' "$1" >&2 + exit 1 +} + +# Read a quoted value from the pin without assuming a TOML parser is installed. +read_pin() { + awk -F'"' -v key="$1" '$1 ~ "^" key " *=" { print $2; exit }' "$PIN" +} + +[ -f "$PIN" ] || fail "no pin file at $PIN" + +path="$(read_pin path)" +pinned="$(read_pin sha256)" +repo="$(read_pin repo)" +version="$(read_pin canon_version)" + +[ -n "$path" ] || fail "the pin names no path" +[ -n "$pinned" ] || fail "the pin names no sha256" + +printf 'canon pin check\n' +printf ' pin %s@%s\n' "$repo" "$REF" +printf ' file %s\n' "$path" +printf ' pinned %s (canon %s)\n' "$pinned" "$version" + +live_file="$TMPDIR_CHECK/criteria.a2ml" +curl -sSfL --max-time 30 "$REMOTE/$REF/$path" -o "$live_file" \ + || fail "could not fetch $REMOTE/$REF/$path (is the ref right, and the repo readable?)" +live="$(sha256sum "$live_file" | cut -d' ' -f1)" +printf ' upstream %s\n' "$live" + +if [ "$live" = "$pinned" ]; then + printf ' -> unchanged since it was pinned\n' +else + cat >&2 < ok\n'