From 4d7eea47ee0e36f7a707b238581cc9b4995ff33b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:32:29 +0000 Subject: [PATCH] rhodibot: score gated criteria only when the repository declares the capability 26 of the canon's 74 criteria are gated. Asked as universal questions they demand files a repository has no reason to carry -- an FFI seam from a docs site, a container from a library -- which is the same mistake as reading `template_ref` as a requirement, one level up. `src/canon/profile.rs` reads `.machine_readable/rsr-profile.a2ml` and answers whether a criterion applies: - effective set = (direct list | preset expansion) + add - remove, per the gate table's own model - applicability is Universal, Declared(capability) or NotDeclared(capability); `na` leaves the denominator rather than counting against the repository - a misspelt capability, an unknown preset, a misspelt key and removing something never declared are all refused: `capabilities = ["russt"]` quietly collapsing to an empty set would switch a whole gate of checks off and report the repository as clean The gate table is vendored and pinned like the criteria -- same release, same rule -- and `scripts/check-canon-drift.sh` now checks both artefacts against the canon and against `canon.lock`. `GateTable::unknown_gates` fails if a criterion gates on a capability the vocabulary does not define, because such a criterion can never become applicable: that is how `deno` came to be listed. Against the five pilot repositories, which declare nothing: of the 31 criteria whose description names files, 22 are scored and 9 are `na` (6 governance-tier, 2 web-ui, 1 docs-site). The pilot's 11 universal criteria are all in the scored set, so the earlier pilot numbers still hold. --- bots/rhodibot/canon/pin.toml | 21 + .../canon/template-capability-gates.toml | 139 ++++ bots/rhodibot/src/canon.rs | 68 ++ bots/rhodibot/src/canon/profile.rs | 685 ++++++++++++++++++ bots/rhodibot/tests/canon_lockstep.rs | 97 ++- scripts/check-canon-drift.sh | 146 +++- 6 files changed, 1120 insertions(+), 36 deletions(-) create mode 100644 bots/rhodibot/canon/template-capability-gates.toml create mode 100644 bots/rhodibot/src/canon/profile.rs diff --git a/bots/rhodibot/canon/pin.toml b/bots/rhodibot/canon/pin.toml index b980472c..598bdbcd 100644 --- a/bots/rhodibot/canon/pin.toml +++ b/bots/rhodibot/canon/pin.toml @@ -38,3 +38,24 @@ sha256 = "37cb5f679b414f6ee99c6bb62c460fd5349ff7d50cc1dab25b1e1a8e8d3c7bb9" categories = 11 criteria = 74 weight_sum = 88 + +# The release's second artefact: the gate table. It says which capabilities a +# profile may declare, what a preset expands to, and which module paths belong +# to which capability. Same rule as the copy above -- taken verbatim, pinned by +# digest, and pinned by shape. `slot` is the name `canon.lock` gives it; the +# drift script checks that all three agree, because a pin nothing verifies is +# not a binding. +[gates] +repo = "hyperpolymath/standards" +path = ".machine_readable/template-capability-gates.toml" +slot = "gates" +version = "0.2.0" +released = "2026-09-17" +pinned = "2026-09-19" +sha256 = "b65ce75438c42d01bedf0325b9a97f1a575064866158b4340d7427fca911dd1b" + +# The shape of the pinned table. A vocabulary that shrank would leave profiles +# declaring the lost words unparseable, and criteria gated on them permanently +# inapplicable -- the failure mode is quiet, so the numbers are asserted. +known = 28 +presets = 11 diff --git a/bots/rhodibot/canon/template-capability-gates.toml b/bots/rhodibot/canon/template-capability-gates.toml new file mode 100644 index 00000000..de111b2f --- /dev/null +++ b/bots/rhodibot/canon/template-capability-gates.toml @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Machine-readable source of truth for the RSR template-applicability model. +# Human policy: ../TEMPLATE-APPLICABILITY-POLICY.adoc +# Reference checker: ../scripts/check-rsr-profile.sh +# +# A repo carries a gated module iff its rsr-profile's effective capability set +# (preset capabilities + add - remove) contains the module's gating capability. +# Arrays are kept single-line so the checker can parse them with grep. + +[meta] +version = "0.2.0" +policy = "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc" + +[capabilities] +# Every capability a profile may declare. +# "plugin" = code hosted inside a third-party extension host (WordPress, +# Zotero, a userscript manager): the host owns the runtime and the packaging +# rules, so host-mandated files are exempt from the usual language gates the +# way interop-target bindings are. +known = ["rust", "zig", "agda", "idris2", "haskell", "gleam", "elixir", "affinescript", "julia", "ocaml", "bash", "cli", "library", "ffi", "abi", "api-service", "formal-proofs", "mobile", "web-ui", "docs-site", "published-package", "container", "reproducible-build", "governance-tier", "benchmarks", "plugin", "deno", + "canon"] # "deno": criterion 4.3.1 gates on it; without it here that criterion could never become applicable. +# "canon": the ROLE of hyperpolymath/standards itself (see [canon] below). +# Without it in `known`, a profile declaring role = "canon" is rejected as an +# unknown CAPABILITY rather than accepted as an unknown role. + +[baseline] +# Always carried (gate = empty). Globs/dirs allowed; not capability-gated. +# '|' offers alternatives; a path is satisfied if ANY alternative exists. +# GitHub resolves community-health files from .github/ as readily as the root, +# and the estate's canonical location for them IS .github/ - naming only the +# root form marked every conforming repo as missing them. +paths = ["README.adoc", "EXPLAINME.adoc|docs/EXPLAINME.adoc", "LICENSE", "SECURITY.md|.github/SECURITY.md", "CONTRIBUTING.md|.github/CONTRIBUTING.md", "CODE_OF_CONDUCT.md|.github/CODE_OF_CONDUCT.md", "CHANGELOG.adoc|CHANGELOG.md", "0-AI-MANIFEST.a2ml", ".machine_readable/descriptiles/|machine-readable/descriptiles/", ".machine_readable/rsr-profile.a2ml|machine-readable/rsr-profile.a2ml", ".well-known/", ".gitignore", "Justfile"] + +[gates] +# "module path (file, dir/, or glob)" = "gating capability" +"Cargo.toml" = "rust" +"Cargo.lock" = "rust" +"src/**/*.rs" = "rust" +".github/workflows/rust-ci.yml" = "rust" +"src/interface/ffi/" = "ffi" +"abi.ipkg" = "abi" +"src/interface/abi/|src/interface/Abi/" = "abi" +"src/interface/generated/" = "abi" +"verification/proofs/" = "formal-proofs" +".github/workflows/e2e.yml" = "api-service" +"build/container/|container/" = "container" +"build/container/Containerfile|container/Containerfile|Containerfile" = "container" +# Guix is primary; Nix is fallback-only (criterion 1.2.1). As two separate rows +# this demanded BOTH, so every repo that correctly retired Nix failed the gate. +"guix.scm|build/guix.scm|flake.nix" = "reproducible-build" +"affinescript/" = "affinescript" +"benches/" = "benchmarks" +".github/workflows/release.yml" = "published-package" +"docs/AUDIT.adoc|AUDIT.adoc" = "governance-tier" +"docs/AFFIRMATION.adoc|AFFIRMATION.adoc" = "governance-tier" +"docs/GOVERNANCE.adoc|0-canon/GOVERNANCE.adoc|.github/GOVERNANCE.md" = "governance-tier" +"docs/MAINTAINERS.adoc|MAINTAINERS.adoc|3-practice/MAINTAINERS.adoc" = "governance-tier" + +[carrier] +# Paths a SPINE (template) repo may carry WITHOUT declaring the gating +# capability, because it carries them for the repos minted from it. +# +# The spine is the one repo for which "present but not declared" is correct +# rather than drift: rsr-template-repo ships rust-ci.yml so that a Rust project +# minted from it has one, while itself carrying no Rust. Before this section the +# model had no way to express that, so it reported the template's entire reason +# for existing as VESTIGIAL - and the template's own profile [notes] had already +# argued, correctly, that declaring capabilities it lacks would be worse. +# +# Applies ONLY where the profile declares role = "spine". A minted repo carrying +# these without the capability is still drift, which is the behaviour that +# matters for the other ~300 repos. +paths = [".github/workflows/rust-ci.yml", ".github/workflows/release.yml", ".github/workflows/e2e.yml", ".github/workflows/boj-build.yml"] + +[canon] +# Paths a CANON repo may carry WITHOUT declaring the gating capability, because +# they are the LAW the capabilities are defined BY, not an instance of them. +# +# The [carrier] section above exists because the spine legitimately carries +# modules whose capability it does not have: rsr-template-repo ships +# rust-ci.yml so a Rust project minted from it has one, while itself carrying +# no Rust. Before [carrier] existed the model reported the template's entire +# reason for existing as VESTIGIAL. +# +# The canon has the same shape of problem one layer up, and it has had it for +# longer. hyperpolymath/standards cannot currently be scored by the checker it +# ships (scripts/check-rsr-profile.sh exits 2 on this repo), because: +# +# * it carries the criteria SSOT, which no capability gates — the criteria +# are not an instance of `formal-proofs` or anything else, they are the +# document that DEFINES those criteria; +# * it carries docs/proofs/ (283 files) but `formal-proofs` means "contains +# mechanised proofs IN TREE, of its own code" — the canon is prose and has +# no code to prove, so it fails a criterion that is meaningless for it; +# * it carries .github/workflows/*-reusable.yml, which every other repo CALLS +# and the canon SERVES, which no capability describes. +# +# Applies ONLY where the profile declares role = "canon" +# (.machine_readable/rsr-profile.a2ml here and in the spine — the root rename +# landed 2026-09-17; machine-readable/rsr-profile.a2ml is the minority spelling +# and is still resolved). +# +# A minted repo declaring role = "canon" to evade a gate is still drift, which +# is the behaviour that matters for the other ~441 repos. +paths = [ + "0-canon/rsr/rsr-criteria-v2.a2ml", # the criteria SSOT + "0-canon/rsr/archive/", # frozen prior majors + "0-canon/rsr/RSR-SPEC-v2.adoc", # prose authority + ".machine_readable/template-capability-gates.toml", # this file + "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc", + "0-canon/constitution/", + ".github/workflows/governance-reusable.yml", # gates other repos CALL + ".machine_readable/REGISTRY.a2ml", # generated index + "docs/proofs/", # proof artefacts OF THE ESTATE + "canon.lock", # the release identity + "standards-map.toml", +] + +[presets] +# OPTIONAL shorthands. A profile may declare `capabilities = [...]` directly +# instead of a preset; presets are pure sugar that expands to a capability set. +# preset name = [ base capabilities ] +# canon = the role of hyperpolymath/standards itself. A one-line shorthand for +# the honest capability set of a prose-and-tooling repo that owns the criteria. +canon = ["bash", "docs-site", "governance-tier"] +rust-cli = ["rust", "cli", "library"] +rust-ffi-lib = ["rust", "zig", "ffi", "abi", "library"] +rust-service = ["rust", "api-service", "container", "reproducible-build"] +formal-proof-lib = ["formal-proofs", "library"] +docs-site = ["docs-site"] +affinescript-app = ["affinescript", "web-ui"] +# Archetype presets (rsr-template-repo archetypes/ carry the matching name; +# see spec/SCAFFOLD-LIFECYCLE.adoc and rsr-template-repo ADR-0003). +julia-library = ["julia", "library", "docs-site", "published-package"] +wordpress-plugin = ["plugin", "web-ui"] +zotero-plugin = ["plugin"] +userscript = ["plugin"] diff --git a/bots/rhodibot/src/canon.rs b/bots/rhodibot/src/canon.rs index 14be3976..008c2ea3 100644 --- a/bots/rhodibot/src/canon.rs +++ b/bots/rhodibot/src/canon.rs @@ -39,6 +39,7 @@ //! The failure mode to avoid is the quiet one: a rule set that silently shrinks //! and reports every repository as compliant. +pub mod profile; pub mod requirement; use std::collections::HashSet; @@ -56,6 +57,14 @@ use sha2::{Digest, Sha256}; /// whatever the filesystem happens to contain. pub const VENDORED_CRITERIA: &str = include_str!("../canon/rsr-criteria-v2.a2ml"); +/// The canon's gate table, copied verbatim from the same release. +/// +/// This is what says which capabilities exist, what a profile's preset expands +/// to, and which module paths belong to which capability. Its pin sits beside +/// the criteria pin in `canon/pin.toml`, under `[gates]`, and +/// `scripts/check-canon-drift.sh` checks both against the canon. +pub const VENDORED_GATES: &str = include_str!("../canon/template-capability-gates.toml"); + /// The pin describing which canon revision [`VENDORED_CRITERIA`] came from. const VENDORED_PIN: &str = include_str!("../canon/pin.toml"); @@ -378,6 +387,25 @@ impl Canon { #[derive(Debug, Clone, Deserialize)] pub struct Pin { pub source: PinSource, + pub gates: GatePin, +} + +/// The gate table's pin. +/// +/// Deliberately does not repeat `canon_version` and `released`: those belong to +/// the release, and a second copy of them can only ever disagree with the +/// first. `slot` names the artefact in `canon.lock` whose hash this must match. +#[derive(Debug, Clone, Deserialize)] +pub struct GatePin { + pub repo: String, + pub path: String, + pub slot: String, + pub version: String, + pub sha256: String, + /// How many capabilities the pinned table defines. + pub known: usize, + /// How many presets it defines. + pub presets: usize, } #[derive(Debug, Clone, Deserialize)] @@ -437,6 +465,46 @@ impl Pin { Ok(()) } + /// Check the vendored gate table against its pin. + pub fn verify_gates(&self, source: &str) -> Result<()> { + let actual = digest_of(source); + let expected = self.gates.sha256.to_lowercase(); + if actual != expected { + bail!( + "vendored gate table does not match its pin: {} (slot {:?}) pins {}, this copy \ + hashes {}. Either re-pin (copy {}, update [gates] sha256 in canon/pin.toml) or \ + restore the copy.", + self.gates.path, + self.gates.slot, + expected, + actual, + self.gates.path + ); + } + Ok(()) + } + + /// Check a parsed gate table against the shape this pin describes. + /// + /// The vocabulary is what makes a profile's declaration checkable. If it + /// silently shrinks, profiles declaring the lost words stop parsing and + /// criteria gated on them can never apply -- so its size is pinned too. + pub fn verify_gate_counts(&self, table: &profile::GateTable) -> Result<()> { + ensure!( + table.known_count() == self.gates.known, + "pinned gate table defines {} capabilities, this copy defines {}", + self.gates.known, + table.known_count() + ); + ensure!( + table.preset_count() == self.gates.presets, + "pinned gate table defines {} presets, this copy defines {}", + self.gates.presets, + table.preset_count() + ); + Ok(()) + } + /// Check a canon source against this pin. pub fn verify(&self, source: &str) -> Result<()> { let actual = digest_of(source); diff --git a/bots/rhodibot/src/canon/profile.rs b/bots/rhodibot/src/canon/profile.rs new file mode 100644 index 00000000..4f163526 --- /dev/null +++ b/bots/rhodibot/src/canon/profile.rs @@ -0,0 +1,685 @@ +// SPDX-License-Identifier: MPL-2.0 + +//! Which capabilities a repository declares, and whether a criterion applies. +//! +//! # The rule +//! +//! A criterion's `gate` is either `"universal"` -- every repository is scored +//! against it -- or the name of a capability. A gated criterion is scored only +//! when the repository's `.machine_readable/rsr-profile.a2ml` declares that +//! capability; otherwise it is `na` and leaves the denominator rather than +//! counting against the repository. +//! +//! This is not a nicety. Of the canon's 74 criteria, 26 are gated. Asked as +//! universal questions they demand files a repository has no reason to carry: +//! an FFI seam from a docs site, a container from a library. The pilot's +//! finding was that a check must ask the question the canon asks. +//! +//! # Effective set +//! +//! Per `template-capability-gates.toml`, a profile's effective set is +//! +//! ```text +//! (capabilities | preset expansion) + add - remove +//! ``` +//! +//! # Fail loudly +//! +//! A misspelt capability is refused, never ignored. `capabilities = ["russt"]` +//! collapsing to an empty set would switch off a whole gate of checks and +//! report the repository as clean -- the exact failure this module exists to +//! prevent. The same goes for an unknown preset, a misspelt key, a preset that +//! expands to nothing, and removing a capability that was never declared. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, ensure}; + +use super::{Canon, Criterion, VENDORED_GATES, split_assignment, strip_comment, unquote}; + +/// The canon's gate table: the capabilities that exist, and what each preset +/// expands to. +/// +/// `[baseline]`, `[gates]`, `[carrier]` and `[canon]` in the same file map +/// module paths to capabilities. Those answer a different question -- "is this +/// file legitimately here?" -- and are read by the classification step, not +/// here. +#[derive(Debug)] +pub struct GateTable { + version: String, + known: BTreeSet, + presets: BTreeMap>, +} + +impl GateTable { + /// Parse the gate table. + pub fn parse(source: &str) -> Result { + let mut version = None; + let mut known: Option> = None; + let mut presets: BTreeMap> = BTreeMap::new(); + + for field in field_list(source)? { + match (field.section.as_str(), field.key.as_str()) { + ("meta", "version") => version = Some(unquote(&field.value, field.line_no)?), + ("capabilities", "known") => { + ensure!( + known.is_none(), + "line {}: `known` is declared twice", + field.line_no + ); + known = Some(parse_string_array(&field.value, field.line_no)?); + } + ("presets", name) => { + let expanded = parse_string_array(&field.value, field.line_no)?; + ensure!( + presets.insert(name.to_string(), expanded).is_none(), + "line {}: preset {name:?} is declared twice", + field.line_no + ); + } + _ => {} + } + } + + let version = version.context("the gate table states no [meta] version")?; + let known = known.context("the gate table declares no [capabilities] known list")?; + ensure!(!known.is_empty(), "the gate table's `known` list is empty"); + + let mut set = BTreeSet::new(); + for name in known { + ensure!( + set.insert(name.clone()), + "capability {name:?} is listed twice in `known`" + ); + } + + let table = Self { + version, + known: set, + presets, + }; + + for (name, expanded) in &table.presets { + ensure!(!expanded.is_empty(), "preset {name:?} expands to nothing"); + for capability in expanded { + ensure!( + table.knows(capability), + "preset {name:?} expands to {capability:?}, which is not a known capability" + ); + } + } + + Ok(table) + } + + /// The gate table vendored beside the criteria, as pinned by `pin.toml`. + pub fn vendored() -> Result { + Self::parse(VENDORED_GATES) + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn knows(&self, capability: &str) -> bool { + self.known.contains(capability) + } + + pub fn known(&self) -> impl Iterator { + self.known.iter() + } + + pub fn known_count(&self) -> usize { + self.known.len() + } + + pub fn preset_count(&self) -> usize { + self.presets.len() + } + + pub fn preset(&self, name: &str) -> Option<&[String]> { + self.presets.get(name).map(Vec::as_slice) + } + + /// Criteria whose gate names a capability this table does not define. + /// + /// Such a criterion can never become applicable: no profile can declare a + /// capability the vocabulary has no word for. `deno` was added to the table + /// for exactly this reason -- criterion 4.3.1 gates on it. + pub fn unknown_gates<'a>(&'a self, canon: &'a Canon) -> Vec<(&'a str, &'a str)> { + canon + .criteria() + .filter_map(|criterion| { + let capability = criterion.capability()?; + (!self.knows(capability)).then_some((criterion.id.as_str(), capability)) + }) + .collect() + } +} + +/// What a repository declares about itself in +/// `.machine_readable/rsr-profile.a2ml`. +#[derive(Debug)] +pub struct Profile { + declared: BTreeSet, + role: Option, +} + +/// The keys `[rsr-profile]` may carry. +/// +/// Anything else is refused. `capabilites = ["rust"]` would otherwise parse as +/// a profile declaring nothing, and a repository would be reported clean +/// against a gate of checks that never ran. +const PROFILE_KEYS: &[&str] = &[ + "version", + "spec", + "declares-against", + "role", + "capabilities", + "preset", + "add", + "remove", +]; + +impl Profile { + /// Parse a profile, resolving its preset and validating every capability + /// against the gate table. + pub fn parse(source: &str, gates: &GateTable) -> Result { + let mut declared_direct: Option> = None; + let mut preset: Option = None; + let mut add: Vec = Vec::new(); + let mut remove: Vec = Vec::new(); + let mut role = None; + let mut saw_section = false; + + for field in field_list(source)? { + let in_profile = field.section == "rsr-profile" || field.section.is_empty(); + if !in_profile { + // [canon] carries the hash binding, [notes] carries prose. + continue; + } + if field.section == "rsr-profile" { + saw_section = true; + } + + ensure!( + PROFILE_KEYS.contains(&field.key.as_str()), + "line {}: unknown key {:?} in [rsr-profile]; a misspelt key would declare no \ + capabilities and silently switch off every gated check", + field.line_no, + field.key + ); + + match field.key.as_str() { + "capabilities" => { + declared_direct = Some(parse_string_array(&field.value, field.line_no)?); + } + "preset" => preset = Some(unquote(&field.value, field.line_no)?), + "add" => add = parse_string_array(&field.value, field.line_no)?, + "remove" => remove = parse_string_array(&field.value, field.line_no)?, + "role" => role = Some(unquote(&field.value, field.line_no)?), + // version, spec and declares-against record which canon the + // profile was written against; the hash binding that makes + // that checkable lives in [canon]. + _ => {} + } + } + + ensure!( + saw_section || declared_direct.is_some() || preset.is_some(), + "no [rsr-profile] section, capabilities or preset: this is not a profile" + ); + + let mut declared = BTreeSet::new(); + + if let Some(name) = &preset { + let base = gates + .preset(name) + .with_context(|| format!("the gate table defines no preset {name:?}"))?; + declared.extend(base.iter().cloned()); + } + + if let Some(direct) = &declared_direct { + declared.extend(direct.iter().cloned()); + } + + for capability in &add { + declared.insert(capability.clone()); + } + + for capability in &remove { + ensure!( + declared.remove(capability), + "line: capability {capability:?} is removed but never declared; removing \ + something that is not there is a typo, not a no-op" + ); + } + + for capability in &declared { + ensure!( + gates.knows(capability), + "capability {capability:?} is not in the gate table's `known` list; a misspelt \ + capability would switch its gated checks off and report the repository as clean" + ); + } + + Ok(Self { declared, role }) + } + + pub fn declares(&self, capability: &str) -> bool { + self.declared.contains(capability) + } + + pub fn declared(&self) -> impl Iterator { + self.declared.iter() + } + + pub fn capability_count(&self) -> usize { + self.declared.len() + } + + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Whether the canon scores this criterion against this repository. + pub fn applicability(&self, criterion: &Criterion) -> Applicability { + match criterion.capability() { + None => Applicability::Universal, + Some(capability) if self.declares(capability) => { + Applicability::Declared(capability.to_string()) + } + Some(capability) => Applicability::NotDeclared(capability.to_string()), + } + } + + pub fn is_applicable(&self, criterion: &Criterion) -> bool { + self.applicability(criterion).is_applicable() + } +} + +/// A repository's profile-less state: nothing declared, so only the universal +/// criteria are scored. +impl Default for Profile { + fn default() -> Self { + Self { + declared: BTreeSet::new(), + role: None, + } + } +} + +/// Why a criterion is or is not scored against a repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Applicability { + /// Names no capability: every repository is scored against it. + Universal, + /// Gated on a capability this repository declares. + Declared(String), + /// Gated on a capability this repository does not declare. Reported `na` + /// and excluded from the denominator -- not a finding. + NotDeclared(String), +} + +impl Applicability { + pub fn is_applicable(&self) -> bool { + !matches!(self, Self::NotDeclared(_)) + } + + pub fn capability(&self) -> Option<&str> { + match self { + Self::Universal => None, + Self::Declared(capability) | Self::NotDeclared(capability) => Some(capability), + } + } + + /// The word a report uses. `na` is the canon's term for a criterion that + /// does not apply. + pub fn as_str(&self) -> &'static str { + match self { + Self::Universal => "universal", + Self::Declared(_) => "declared", + Self::NotDeclared(_) => "na", + } + } +} + +/// One `key = value` line of a table, with its section and where it was found. +struct Field { + section: String, + key: String, + /// The value with comments stripped, and with any list continuation lines + /// joined onto it. + value: String, + line_no: usize, +} + +/// Read a TOML-ish table line by line. +/// +/// Only what this module needs: section headers, `key = value`, string values, +/// string arrays, and arrays that run over several lines -- `known` does, and +/// so does the spine's own capability list. Anything else in the file is +/// skipped rather than refused: the gate table is the canon's, and this is a +/// consumer of it, not its parser of record. +fn field_list(source: &str) -> Result> { + let lines: Vec<&str> = source.lines().collect(); + let mut fields = Vec::new(); + let mut section = String::new(); + let mut index = 0; + + while index < lines.len() { + let line_no = index + 1; + let line = strip_comment(lines[index]).trim(); + + if line.is_empty() { + index += 1; + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + section = line.trim_matches(['[', ']']).trim().to_string(); + index += 1; + continue; + } + + if let Some((key, value)) = split_assignment(line) { + let mut value = value.to_string(); + + if value.starts_with('[') && !brackets_balanced(&value) { + loop { + index += 1; + ensure!( + index < lines.len(), + "line {line_no}: {key:?} has no closing `]`" + ); + value.push(' '); + value.push_str(strip_comment(lines[index]).trim()); + if brackets_balanced(&value) { + break; + } + } + } + + fields.push(Field { + section: section.clone(), + key: key.to_string(), + value, + line_no, + }); + } + + index += 1; + } + + Ok(fields) +} + +fn brackets_balanced(text: &str) -> bool { + let mut depth = 0i32; + let mut in_string = false; + for ch in text.chars() { + match ch { + '"' => in_string = !in_string, + '[' if !in_string => depth += 1, + ']' if !in_string => depth -= 1, + _ => {} + } + } + depth <= 0 +} + +/// Parse `[ "a", "b", ]` into its quoted strings. A trailing comma is fine; +/// anything unquoted is refused. +fn parse_string_array(value: &str, line_no: usize) -> Result> { + let inner = value + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .with_context(|| format!("line {line_no}: expected a [ ... ] list, found {value:?}"))?; + + let mut names = Vec::new(); + for piece in inner.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + continue; + } + names.push(unquote(piece, line_no)?); + } + Ok(names) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canon::VENDORED_CRITERIA; + + fn gates() -> GateTable { + GateTable::vendored().expect("the vendored gate table parses") + } + + fn canon() -> Canon { + Canon::vendored().expect("the vendored canon parses") + } + + /// The capability list the spine declares, verbatim from + /// `rsr-template-repo/.machine_readable/rsr-profile.a2ml`. + const SPINE_PROFILE: &str = "\ +[rsr-profile] +version = \"1.0.0\" +spec = \"rsr-criteria-v2\" +declares-against = \"2.0.0-draft\" +role = \"spine\" +capabilities = [ + \"zig\", # src/interface/ffi/build.zig + Zig FFI layer + \"idris2\", # src/interface/abi.ipkg + src/interface/Abi/ + \"bash\", # scripts/*.sh automation + \"cli\", # src/ command-line entry scaffolding + \"library\", # consumed/published as a library surface + \"ffi\", # C-ABI FFI seam (Zig), src/interface/ffi/ + \"abi\", # formally specified ABI + \"formal-proofs\", # verification/proofs/ + \"docs-site\", # .github/workflows/pages.yml (casket/Pages) + \"container\", # build/container/ + .devcontainer/Containerfile + \"reproducible-build\", # build/guix.scm + \"benchmarks\", # benches/ + \"governance-tier\", # AUDIT/AFFIRMATION/GOVERNANCE/MAINTAINERS +] + +[canon] +version = \"2.0.4\" +"; + + #[test] + fn the_vendored_gate_table_has_the_expected_shape() { + let table = gates(); + assert_eq!(table.version(), "0.2.0"); + assert_eq!( + table.known_count(), + 28, + "the capability vocabulary changed; profiles declaring the new words would be \ + refused, and criteria gated on them could never apply" + ); + assert_eq!(table.preset_count(), 11); + assert!( + table.knows("deno"), + "criterion 4.3.1 gates on `deno`; without it in `known` that criterion can never \ + become applicable" + ); + } + + #[test] + fn every_gate_the_canon_uses_is_a_known_capability() { + let table = gates(); + let canon = canon(); + let unknown = table.unknown_gates(&canon); + assert!( + unknown.is_empty(), + "these criteria gate on capabilities the table does not define: {unknown:?}" + ); + + // The check has to be able to fail, or it proves nothing. Doctor the + // *canon* -- gating a criterion on a word the vocabulary does not have + // -- because that is the drift the check exists to catch: a canon that + // gates on a capability nobody can declare. + let drifted = + Canon::parse(&VENDORED_CRITERIA.replacen("gate = \"rust\"", "gate = \"rust-2024\"", 1)) + .expect("a canon gating on an unknown capability still parses"); + assert_eq!( + table.unknown_gates(&drifted), + vec![("5.2.1", "rust-2024")], + "the canon's rust-gated criterion must be found" + ); + } + + #[test] + fn the_spine_profile_parses_to_its_declared_set() { + let profile = Profile::parse(SPINE_PROFILE, &gates()).expect("the spine's profile parses"); + assert_eq!(profile.capability_count(), 13); + assert_eq!(profile.role(), Some("spine")); + assert!(profile.declares("zig") && profile.declares("governance-tier")); + assert!( + !profile.declares("rust"), + "the spine carries no Rust; its [notes] say so, and scoring it against the Rust \ + gate is the mistake the gate table's [carrier] section exists to correct" + ); + } + + #[test] + fn a_preset_expands_to_its_base_capabilities() { + let profile = Profile::parse( + "[rsr-profile]\nrole = \"spine\"\npreset = \"rust-cli\"\n", + &gates(), + ) + .expect("a preset parses"); + let declared: Vec<&str> = profile.declared().map(String::as_str).collect(); + assert_eq!(declared, ["cli", "library", "rust"]); + } + + #[test] + fn add_and_remove_adjust_a_preset() { + let profile = Profile::parse( + "[rsr-profile]\npreset = \"rust-cli\"\nadd = [\"container\",]\nremove = [\"library\"]\n", + &gates(), + ) + .expect("preset + add - remove parses"); + let declared: Vec<&str> = profile.declared().map(String::as_str).collect(); + assert_eq!(declared, ["cli", "container", "rust"]); + } + + #[test] + fn a_preset_and_a_direct_list_are_combined() { + let profile = Profile::parse( + "[rsr-profile]\npreset = \"docs-site\"\ncapabilities = [\"bash\"]\n", + &gates(), + ) + .expect("preset plus a direct list parses"); + let declared: Vec<&str> = profile.declared().map(String::as_str).collect(); + assert_eq!(declared, ["bash", "docs-site"]); + } + + #[test] + fn a_misspelt_capability_is_refused() { + let error = Profile::parse("[rsr-profile]\ncapabilities = [\"russt\"]\n", &gates()) + .expect_err("a capability that is not in the vocabulary must be refused"); + let message = format!("{error:#}"); + assert!( + message.contains("russt") && message.contains("clean"), + "the refusal must name the token and say why it matters: {message}" + ); + } + + #[test] + fn an_unknown_preset_is_refused() { + let error = Profile::parse("[rsr-profile]\npreset = \"rust-clli\"\n", &gates()) + .expect_err("a preset that does not exist must be refused"); + assert!(format!("{error:#}").contains("rust-clli"), "{error:#}"); + } + + #[test] + fn a_preset_naming_an_unknown_capability_is_refused() { + // A hand-written table, so the test does not depend on which presets + // the canon happens to carry. + let text = "\ +[meta] +version = \"0.0.0\" + +[capabilities] +known = [\"rust\"] + +[presets] +rust-cli = [\"rust\", \"cli\"] +"; + let error = GateTable::parse(text).expect_err("a preset may not invent a capability"); + let message = format!("{error:#}"); + assert!( + message.contains("rust-cli") && message.contains("cli"), + "{message}" + ); + } + + #[test] + fn a_misspelt_key_is_refused() { + let error = Profile::parse("[rsr-profile]\ncapabilites = [\"rust\"]\n", &gates()) + .expect_err("a misspelt key must not parse as a profile declaring nothing"); + let message = format!("{error:#}"); + assert!( + message.contains("capabilites") && message.contains("unknown key"), + "the refusal must name the key it did not recognise: {message}" + ); + } + + #[test] + fn removing_a_capability_that_was_never_declared_is_refused() { + let error = Profile::parse( + "[rsr-profile]\ncapabilities = [\"bash\"]\nremove = [\"rust\"]\n", + &gates(), + ) + .expect_err("removing something absent is a typo, not a no-op"); + assert!(format!("{error:#}").contains("never declared"), "{error:#}"); + } + + #[test] + fn a_file_that_is_not_a_profile_is_refused() { + let error = Profile::parse("[notes]\n# prose only\n", &gates()) + .expect_err("a file with no profile content must not parse as an empty one"); + assert!(format!("{error:#}").contains("not a profile"), "{error:#}"); + } + + #[test] + fn a_gated_criterion_applies_only_when_its_capability_is_declared() { + let canon = canon(); + let rust_gated = canon + .criteria() + .find(|criterion| criterion.capability() == Some("rust")) + .expect("the canon gates at least one criterion on rust"); + let universal = canon + .criteria() + .find(|criterion| criterion.capability().is_none()) + .expect("the canon has universal criteria"); + + let declared = Profile::parse("[rsr-profile]\ncapabilities = [\"rust\"]\n", &gates()) + .expect("a profile declaring rust"); + assert_eq!( + declared.applicability(rust_gated), + Applicability::Declared("rust".to_string()) + ); + assert!(declared.is_applicable(rust_gated)); + + // No profile at all: the repository has declared nothing, so every + // gated criterion is `na` -- not a finding. + let undeclared = Profile::default(); + assert_eq!( + undeclared.applicability(rust_gated), + Applicability::NotDeclared("rust".to_string()) + ); + assert_eq!(undeclared.applicability(rust_gated).as_str(), "na"); + assert!(!undeclared.is_applicable(rust_gated)); + + // A universal criterion is scored either way. + assert_eq!( + undeclared.applicability(universal), + Applicability::Universal + ); + assert_eq!(declared.applicability(universal), Applicability::Universal); + assert!(undeclared.is_applicable(universal)); + } +} diff --git a/bots/rhodibot/tests/canon_lockstep.rs b/bots/rhodibot/tests/canon_lockstep.rs index a2b322f0..19f1ccfa 100644 --- a/bots/rhodibot/tests/canon_lockstep.rs +++ b/bots/rhodibot/tests/canon_lockstep.rs @@ -13,7 +13,9 @@ use std::path::PathBuf; -use rhodibot::canon::{Canon, Pin, Tier, VENDORED_CRITERIA, digest_of}; +use rhodibot::canon::profile::{Applicability, GateTable, Profile}; +use rhodibot::canon::requirement::requirement_from; +use rhodibot::canon::{Canon, Pin, Tier, VENDORED_CRITERIA, VENDORED_GATES, digest_of}; /// The vendored canon as it sits on disk. fn canon_path() -> PathBuf { @@ -127,3 +129,96 @@ fn requirements_are_derivable_from_descriptions() { derived.len() ); } + +/// The vendored gate table as it sits on disk. +fn gates_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("canon/template-capability-gates.toml") +} + +#[test] +fn the_gate_table_on_disk_is_the_copy_compiled_into_the_binary() { + let on_disk = + std::fs::read_to_string(gates_path()).expect("the vendored gate table is readable"); + + // Same failure as the criteria: a binary would ask one vocabulary and the + // file would answer another. + assert_eq!( + digest_of(&on_disk), + digest_of(VENDORED_GATES), + "the vendored gate table and the embedded copy have diverged" + ); + + let from_disk = GateTable::parse(&on_disk).expect("the file parses"); + let embedded = GateTable::vendored().expect("the embedded copy parses"); + assert_eq!(from_disk.known_count(), embedded.known_count()); + assert_eq!(from_disk.preset_count(), embedded.preset_count()); + assert_eq!(from_disk.version(), embedded.version()); +} + +#[test] +fn the_pin_describes_the_vendored_gate_table() { + let pin = Pin::vendored().expect("the pin parses"); + pin.verify_gates(VENDORED_GATES).expect("digest matches"); + pin.verify_gate_counts(&GateTable::vendored().expect("parses")) + .expect("shape matches"); +} + +#[test] +fn the_gate_pin_refuses_a_table_that_changed() { + let pin = Pin::vendored().expect("the pin parses"); + // Removing one capability is enough: a profile declaring it would stop + // parsing, and criteria gated on it could never apply again. + let edited = VENDORED_GATES.replacen("\"plugin\",", "", 1); + + let error = pin + .verify_gates(&edited) + .expect_err("an edited gate table must be refused"); + assert!(format!("{error:#}").contains("does not match its pin")); +} + +#[test] +fn capability_gates_split_the_file_presence_criteria() { + let canon = Canon::vendored().expect("the canon parses"); + let profile = Profile::default(); + + let mut scored = Vec::new(); + let mut inapplicable = Vec::new(); + + for criterion in canon.criteria() { + if requirement_from(&criterion.desc).is_none() { + continue; + } + match profile.applicability(criterion) { + Applicability::Universal => scored.push(criterion.id.clone()), + Applicability::NotDeclared(capability) => { + inapplicable.push((criterion.id.clone(), capability)); + } + Applicability::Declared(capability) => { + unreachable!("nothing is declared, so {capability} cannot be") + } + } + } + + println!("with no profile -- the pilot's five repositories:"); + println!(" scored {}", scored.join(" ")); + for (id, capability) in &inapplicable { + println!(" na {id} (needs {capability})"); + } + println!( + "scored {} na {} of {} derived", + scored.len(), + inapplicable.len(), + scored.len() + inapplicable.len() + ); + + assert_eq!( + scored.len() + inapplicable.len(), + 31, + "the number of criteria whose description names files changed" + ); + assert!( + !inapplicable.is_empty(), + "the canon gates some file-presence criteria; a check that scored them anyway would \ + demand files these repositories have no reason to carry" + ); +} diff --git a/scripts/check-canon-drift.sh b/scripts/check-canon-drift.sh index 5b496c6f..bef846af 100755 --- a/scripts/check-canon-drift.sh +++ b/scripts/check-canon-drift.sh @@ -1,17 +1,26 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # -# Has the canon moved since the copy was pinned? +# Has the canon moved since the copies were 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 +# Rhodibot applies the RSR rule set from two artefacts, both copied verbatim and +# both pinned in `bots/rhodibot/canon/pin.toml`: +# +# criteria `0-canon/rsr/rsr-criteria-v2.a2ml` -- the rules +# gates `.machine_readable/template-capability-gates.toml` -- which +# capabilities exist, what a preset expands to, which module paths +# belong to which capability +# +# The tests check the copies 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. +# what every repository in the estate is measured against, so it should arrive +# as a reviewable commit, not as a surprise on the next run. The gate table +# matters for the same reason: if a capability is added upstream and this copy +# does not have it, profiles declaring it are refused, and criteria gated on it +# can never apply. # # Exit status -# 0 the pin matches the canon, and the canon's own lock agrees with the file +# 0 both pins match the canon, and the canon's own lock agrees with both files # 1 drift, or the canon could not be read # # Environment @@ -34,40 +43,65 @@ fail() { } # Read a quoted value from the pin without assuming a TOML parser is installed. +# Section-aware: both artefacts have a `path` and a `sha256`, so a flat read +# would hand back whichever came first. read_pin() { - awk -F'"' -v key="$1" '$1 ~ "^" key " *=" { print $2; exit }' "$PIN" + awk -F'"' -v section="$1" -v key="$2" ' + /^[[:space:]]*\[/ { + current = $0 + sub(/^[[:space:]]*\[/, "", current) + sub(/\].*$/, "", current) + next + } + current == section && $1 ~ "^[[:space:]]*" key "[[:space:]]*=" { print $2; exit } + ' "$PIN" +} + +# The hash the canon's own lock records for a slot. +lock_hash() { + grep -A2 "$1 = {" "$2" | grep -oE '[0-9a-f]{64}' | head -1 } [ -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)" +repo="$(read_pin source repo)" +version="$(read_pin source canon_version)" +criteria_path="$(read_pin source path)" +criteria_pin="$(read_pin source sha256)" +gates_path="$(read_pin gates path)" +gates_pin="$(read_pin gates sha256)" +gates_slot="$(read_pin gates slot)" +gates_version="$(read_pin gates version)" -[ -n "$path" ] || fail "the pin names no path" -[ -n "$pinned" ] || fail "the pin names no sha256" +[ -n "$criteria_path" ] || fail "the pin names no criteria path" +[ -n "$criteria_pin" ] || fail "the pin names no criteria sha256" +[ -n "$gates_path" ] || fail "the pin names no gate table path" +[ -n "$gates_pin" ] || fail "the pin names no gate table sha256" +[ -n "$gates_slot" ] || fail "the pin names no slot for the gate table" 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" +# --- criteria --------------------------------------------------------------- + +printf ' rules %s\n' "$criteria_path" +printf ' pinned %s (canon %s)\n' "$criteria_pin" "$version" + +live_criteria="$TMPDIR_CHECK/criteria.a2ml" +curl -sSfL --max-time 30 "$REMOTE/$REF/$criteria_path" -o "$live_criteria" \ + || fail "could not fetch $REMOTE/$REF/$criteria_path (is the ref right, and the repo readable?)" +live_criteria_hash="$(sha256sum "$live_criteria" | cut -d' ' -f1)" +printf ' upstream %s\n' "$live_criteria_hash" -if [ "$live" = "$pinned" ]; then +if [ "$live_criteria_hash" = "$criteria_pin" ]; then printf ' -> unchanged since it was pinned\n' else cat >&2 < unchanged since it was pinned\n' +else + cat >&2 <&2 + stale=1 + else + printf ' canon.lock agrees (%s)\n' "$slot" + fi + done + [ "$stale" -eq 0 ] || exit 1 else printf ' canon.lock: not readable at this ref, skipping that comparison\n' fi