diff --git a/.github/workflows/deed-read.yml b/.github/workflows/deed-read.yml new file mode 100644 index 0000000..9c61b70 --- /dev/null +++ b/.github/workflows/deed-read.yml @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: MPL-2.0 +name: deed-read + +# Builds and tests rs/deed-read, the Rust reader for .deed files and the +# (updates …) vocabulary. +# +# Deliberately NO `paths:` filter: a path-filtered workflow never reports on +# PRs outside its paths, so its check could never be made required without +# deadlocking those PRs. The build takes seconds. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: rs/deed-read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Toolchain + run: rustc --version && cargo --version + - name: Test + run: cargo test --locked + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + - name: Docs + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --no-deps diff --git a/rs/deed-read/.gitignore b/rs/deed-read/.gitignore new file mode 100644 index 0000000..a3c4961 --- /dev/null +++ b/rs/deed-read/.gitignore @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: MPL-2.0 +/target/ diff --git a/rs/deed-read/Cargo.lock b/rs/deed-read/Cargo.lock new file mode 100644 index 0000000..3cfded3 --- /dev/null +++ b/rs/deed-read/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deed-read" +version = "0.1.0" +dependencies = [ + "anyhow", + "hex", + "sha2", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/rs/deed-read/Cargo.toml b/rs/deed-read/Cargo.toml new file mode 100644 index 0000000..0c27c9c --- /dev/null +++ b/rs/deed-read/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 + +[package] +name = "deed-read" +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +authors = ["Jonathan D.A. Jewell "] +license = "MPL-2.0" +description = "Reader for the estate DEED (.deed) format and its vocabularies" +repository = "https://github.com/hyperpolymath/deed-ecosystem/tree/main/rs/deed-read" +readme = "README.adoc" +publish = false + +# An independent root. `rs/` is the legacy `a2ml` package, not a workspace; +# this crate does not join or depend on it. +[workspace] + +[dependencies] +anyhow = "1" + +[dev-dependencies] +sha2 = "0.10" +hex = "0.4" diff --git a/rs/deed-read/README.adoc b/rs/deed-read/README.adoc new file mode 100644 index 0000000..dca084d --- /dev/null +++ b/rs/deed-read/README.adoc @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 += deed-read +:toc: macro + +Rust reader for the estate's `.deed` format and its vocabularies. + +toc::[] + +== What it does + +[cols="1,3"] +|=== +| Module | Purpose + +| `syntax` +| Parses any deed against the normative grammar, +`1-formats/deed/spec/abnf/deed.abnf` in `hyperpolymath/standards`. It rejects +what that grammar rejects: tabs, `true`/`false` instead of `#t`/`#f`, a missing +`:schema-version`, an unknown document head, unbalanced forms. + +| `updates` +| Reads the `(updates …)` repo-deed clause: the per-repo switch for automated +dependency updates. Normative text: `1-formats/deed/vocabulary/updates.adoc`; +policy: `docs/DEPENDABOT-POLICY.adoc` (owner ruling D269). +|=== + +[source,rust] +---- +use deed_read::updates; + +let policy = updates::from_path(std::path::Path::new("my-repo_chora.deed"))?; +if !policy.enabled { /* arm nothing */ } +---- + +== Fails closed + +The `(updates …)` clause is how a repo turns automation *off*. A reader that +skipped what it did not recognise would read the typo `:enabeld #f` as "on". +So any of the following is an `UpdatesError`, and the caller must arm nothing +for that repo and report the error: + +* an unknown or repeated field; +* an unknown nested clause; +* a wrongly typed value (no coercion: `"no"` is not `#f`); +* a negative `:soak-days`; +* a `(hold …)` without `:reason`; +* an impossible `:until` date; +* the clause outside a `repo-deed`, or appearing twice; +* a deed that does not parse, or cannot be read. + +A *missing* deed, or one with no clause, is not an error: the defaults apply +(enabled, majors on, no soak). + +== Provenance and the duplication window + +`src/syntax.rs` and `tests/deed_corpus.rs` are copied from +`hyperpolymath/launch-scaffolder` `crates/launcher-common` at `154b9b61`. The +header, the import path and added one-line `///` docstrings differ; the code +does not. Until launch-scaffolder replaces its +copy with a dependency on this crate, there are two copies. Each is checked +against the same vendored corpus (`tests/fixtures/deed/MANIFEST.sha256`), so if +one drifts, the manifest test catches it. + +This crate is its own Cargo root. The `rs/` directory around it holds the +legacy `a2ml` package, which this crate does not join or depend on. + +== Tests + +[source,sh] +---- +cargo test # syntax corpus, hub conformance/, (updates …) rules +---- + +`tests/hub_conformance.rs` also checks this hub's `conformance/*.deed` files: +the 4 valid ones must parse and the 5 invalid ones must be rejected. The +`(updates …)` fixture lives in `tests/fixtures/`, not `conformance/`, because +`conformance/run-deed-tests.sh` asserts exact file counts. + +CI: `.github/workflows/deed-read.yml`. diff --git a/rs/deed-read/src/lib.rs b/rs/deed-read/src/lib.rs new file mode 100644 index 0000000..066de95 --- /dev/null +++ b/rs/deed-read/src/lib.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Reader for the estate's `.deed` format. +//! +//! * [`syntax`] parses any deed against the normative grammar +//! (`1-formats/deed/spec/abnf/deed.abnf` in `hyperpolymath/standards`). +//! * [`updates`] reads the `(updates …)` repo-deed vocabulary +//! (`1-formats/deed/vocabulary/updates.adoc`), failing closed. + +pub mod syntax; +pub mod updates; + +pub use syntax::{parse, Node, Value}; diff --git a/rs/deed-read/src/syntax.rs b/rs/deed-read/src/syntax.rs new file mode 100644 index 0000000..154d617 --- /dev/null +++ b/rs/deed-read/src/syntax.rs @@ -0,0 +1,1082 @@ +// SPDX-License-Identifier: MPL-2.0 +//! DEED reader — a hand-rolled tokenizer and recursive-descent parser for the +//! estate's `.deed` format. +//! +//! The normative grammar is `1-formats/deed/spec/abnf/deed.abnf` (DEED v1.0.0) +//! in `hyperpolymath/standards`. This module implements **syntax only**; +//! vocabulary readers such as [`crate::updates`] sit on top of it. Keeping the +//! two apart is what lets the corpus fixtures exercise the parser without +//! dragging any vocabulary in. +//! +//! # Provenance +//! +//! Extracted from `hyperpolymath/launch-scaffolder` +//! `crates/launcher-common/src/deed.rs` at commit +//! `154b9b61c5162edab026119e54ce540bfffe7dff` (#36). Only this header and added +//! one-line `///` docstrings differ; the code does not. This crate is the +//! intended single home; launch-scaffolder is to switch to it and delete its +//! copy. Until then a fix here must be mirrored there, or the two drift. +//! +//! # Why hand-rolled +//! +//! The grammar is small and closed, and `launch-scaffolder` already vendors no +//! parser-generator. A `nom`/`pest` dependency would buy nothing here and would +//! put a third-party crate between us and a normative estate grammar. +//! +//! # The one ambiguity, and how it is resolved +//! +//! `(a b c)` is a **list of symbols** after a `:keyword`, and a **clause** with +//! head `a` in body position. The ABNF disambiguates by position, not by +//! lookahead: +//! +//! ```text +//! field = keyword token-sep value ; "(" here starts a list +//! clause = "(" symbol *(token-sep (field / clause)) [token-sep] ")" +//! ``` +//! +//! So `Parser::parse_value` reads `(` as a list and `Parser::parse_body` +//! reads `(` as a clause. There is no backtracking anywhere in this parser. +//! +//! # Order is not semantic +//! +//! Per the grammar's closing note, file order carries no meaning in DEED. +//! Where precedence matters it is carried by an explicit `:priority` integer. +//! Consumers MUST sort; see [`Node::children_by_priority`]. + +use anyhow::{Result, bail}; + +/// A DEED value: the right-hand side of a `:keyword value` field, or an +/// element of a list. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + /// `"…"` — a double-quoted string, escapes already decoded. + Str(String), + /// `10`, `-3`, `007`. Leading zeros are decimal, never octal. + Int(i64), + /// A bare symbol such as `nohup`, `yellow`, `MPL-2.0`, `Type.Software`. + Sym(String), + /// `#t` / `#f`. Strict lowercase; `true`/`false` are not booleans. + Bool(bool), + /// `#u5"name"` — carries the *name* input, not a computed UUID. Nothing in + /// `launch-scaffolder` needs the digest, so none is derived here. + Uuid5(String), + /// `( … )` in value position, including the empty list `()`. + List(Vec), + /// `'sym` or `'(a b)`. Only symbols and lists may be quoted. + Quoted(Box), +} + +impl Value { + /// The string payload, for `Str` only. A symbol is deliberately not + /// coerced: `:mode "--auto"` and `:mode auto` are different documents. + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + _ => None, + } + } + + /// The symbol name, for `Sym` only. + pub fn as_sym(&self) -> Option<&str> { + match self { + Value::Sym(s) => Some(s), + _ => None, + } + } + + /// The integer, if this is an integer. + pub fn as_int(&self) -> Option { + match self { + Value::Int(i) => Some(*i), + _ => None, + } + } + + /// The boolean, if this is `#t` or `#f`. + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + /// The elements of a list, for `List` only. + pub fn as_list(&self) -> Option<&[Value]> { + match self { + Value::List(v) => Some(v), + _ => None, + } + } + + /// Every element of a list that is a string, as `&str`. Non-string + /// elements are skipped rather than erroring — callers that care use + /// [`Value::as_list`] directly. + pub fn str_list(&self) -> Vec<&str> { + match self { + Value::List(v) => v.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + } + } + + /// A human-readable type name, for error messages. + fn kind(&self) -> &'static str { + match self { + Value::Str(_) => "string", + Value::Int(_) => "integer", + Value::Sym(_) => "symbol", + Value::Bool(_) => "boolean", + Value::Uuid5(_) => "uuid5", + Value::List(_) => "list", + Value::Quoted(_) => "quoted", + } + } +} + +/// A DEED form or clause: a head symbol, its `:keyword value` fields, and its +/// nested clauses. +/// +/// Fields and clauses are held separately because their relative order is not +/// semantic. Within `clauses`, source order is preserved so that a consumer +/// which sorts by `:priority` can be shown to differ from one which does not. +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + /// The head symbol: `praxis-deed`, `resolution`, `path`, … + pub head: String, + pub fields: Vec<(String, Value)>, + pub clauses: Vec, +} + +impl Node { + /// The value of a `:keyword` field on this node, if present. + pub fn field(&self, key: &str) -> Option<&Value> { + self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + + /// The value of a `:keyword` field that must be a string. + pub fn str_field(&self, key: &str) -> Option<&str> { + self.field(key).and_then(Value::as_str) + } + + /// The first direct child clause with this head. + pub fn clause(&self, head: &str) -> Option<&Node> { + self.clauses.iter().find(|c| c.head == head) + } + + /// Every direct child clause with this head, in source order. + pub fn clauses_named<'a>(&'a self, head: &'a str) -> impl Iterator { + self.clauses.iter().filter(move |c| c.head == head) + } + + /// Every direct child clause with this head, **sorted ascending by its + /// `:priority` integer**. + /// + /// This is the only correct way to read a DEED ladder. File order is not + /// semantic (see the module docs), so a consumer that iterates `clauses` + /// directly is relying on a lint convention rather than on the document. + /// + /// A child with no `:priority`, or a non-integer one, sorts after every + /// child that has one; ties keep source order (the sort is stable). + pub fn children_by_priority<'a>(&'a self, head: &'a str) -> Vec<&'a Node> { + let mut out: Vec<&Node> = self.clauses_named(head).collect(); + out.sort_by_key(|c| { + c.field("priority") + .and_then(Value::as_int) + .unwrap_or(i64::MAX) + }); + out + } +} + +// --------------------------------------------------------------------------- +// Lexing / parsing +// --------------------------------------------------------------------------- + +/// The four legal document heads, and the filename suffix each dispatches from. +/// Dispatch is exact-stem-first: `estate_chora.deed` is an `estate-deed`, never +/// a `repo-deed`, even though it also matches the `*_chora.deed` shape. +pub const DOC_HEADS: [&str; 4] = [ + "estate-deed", + "repo-deed", + "estate-atlas-deed", + "praxis-deed", +]; + +struct Parser { + src: Vec, + pos: usize, + line: usize, +} + +/// Parse a complete deed document and return its single top-level form. +/// +/// The whole input must be consumed: trailing non-whitespace after the closing +/// `)` is a parse error, per the grammar's EOF enforcement note. +pub fn parse(text: &str) -> Result { + // A tab is invalid *anywhere* in a deed, not merely as a separator — this + // matches `deed_lint.py`, which tests the raw text before lexing. A literal + // tab inside a string is therefore also rejected; `\t` (two characters) is + // the legal way to write one. + if let Some(idx) = text.find('\t') { + let line = text[..idx].matches('\n').count() + 1; + bail!("line {line}: HTAB (tab) is an invalid separator anywhere in a deed (K9-consistent)"); + } + + let mut p = Parser { + src: text.chars().collect(), + pos: 0, + line: 1, + }; + p.parse_header()?; + p.skip_sep()?; + let form = p.parse_form()?; + p.skip_sep()?; + if p.pos < p.src.len() { + let c = p.src[p.pos]; + bail!( + "line {}: trailing content after the closing ')': {c:?} — a deed holds exactly one form", + p.line + ); + } + + // The ABNF states this as a side condition on `form`, not as a separate + // production: "Exactly one field MUST have keyword ':schema-version' with a + // STRING value." It is therefore part of DEED *syntax* and belongs here, + // not in the praxis-schema layer — a document without it is not a deed at + // all, whatever its doc-head. + // + // Note the value need only be a string, not a semver: the upstream corpus + // carries :schema-version "not-string-issue" as a VALID fixture. + let versions: Vec<&(String, Value)> = form + .fields + .iter() + .filter(|(k, _)| k == "schema-version") + .collect(); + match versions.as_slice() { + [(_, Value::Str(_))] => {} + [(_, other)] => bail!( + "a deed form's :schema-version must be a STRING value, got {}", + other.kind() + ), + other => bail!( + "a deed form must carry exactly one :schema-version STRING field (found {})", + other.len() + ), + } + + Ok(form) +} + +impl Parser { + /// The next character, without consuming it. + fn peek(&self) -> Option { + self.src.get(self.pos).copied() + } + + /// The character `k` places ahead, without consuming anything. + fn peek_at(&self, k: usize) -> Option { + self.src.get(self.pos + k).copied() + } + + /// Consume and return the next character, tracking line and column. + fn bump(&mut self) -> Option { + let c = self.peek()?; + self.pos += 1; + if c == '\n' { + self.line += 1; + } + Some(c) + } + + /// Whether the unread input starts with `s`. + fn starts_with(&self, s: &str) -> bool { + s.chars() + .enumerate() + .all(|(i, c)| self.peek_at(i) == Some(c)) + } + + /// `header = 1*spdx-line`, `spdx-line = ";;" SP "SPDX-" 1*text-char line-end`. + /// + /// Only the leading run of SPDX lines is the header. Any further `;;` lines + /// (the launcher deed has fourteen of them) are ordinary comments and are + /// consumed later as `token-sep`. + fn parse_header(&mut self) -> Result<()> { + let mut seen = 0usize; + while self.starts_with(";; SPDX-") { + for _ in 0..8 { + self.bump(); + } + let mut payload = 0usize; + loop { + match self.peek() { + None => bail!("line {}: SPDX header line has no line-end", self.line), + Some('\n') => { + self.bump(); + break; + } + Some('\r') => { + if self.peek_at(1) == Some('\n') { + self.bump(); + self.bump(); + break; + } + bail!( + "line {}: bare CR is not a line-end (CRLF or LF only)", + self.line + ); + } + Some(_) => { + self.bump(); + payload += 1; + } + } + } + if payload == 0 { + bail!( + "line {}: SPDX header line is empty after ';; SPDX-'", + self.line - 1 + ); + } + seen += 1; + } + if seen == 0 { + bail!("line 1: a deed must open with at least one ';; SPDX-…' header line"); + } + Ok(()) + } + + /// `token-sep = 1*(SP / line-end / comment)`, but zero repetitions are + /// tolerated here; callers that require a separator check the return value. + fn skip_sep(&mut self) -> Result { + let start = self.pos; + loop { + match self.peek() { + Some(' ') | Some('\n') => { + self.bump(); + } + Some('\r') => { + if self.peek_at(1) == Some('\n') { + self.bump(); + self.bump(); + } else { + bail!( + "line {}: bare CR is not a line-end (CRLF or LF only)", + self.line + ); + } + } + // `comment = ";" *text-char line-end`. A single ";" opens it, + // so ";;" is a comment whose text begins with ";". + Some(';') => { + while let Some(c) = self.peek() { + if c == '\n' { + break; + } + self.bump(); + } + // EOF closes a trailing comment; the grammar wants a + // line-end but rejecting here would only punish a missing + // final newline, which `deed_lint.py` also tolerates. + self.bump(); + } + _ => return Ok(self.pos != start), + } + } + } + + /// Whether the next character can start a separator (whitespace or comment). + fn at_sep(&self) -> bool { + matches!(self.peek(), Some(' ') | Some('\n') | Some('\r') | Some(';')) + } + + /// `form = "(" doc-head 1*(token-sep (field / clause)) [token-sep] ")"` + fn parse_form(&mut self) -> Result { + if self.peek() != Some('(') { + bail!("line {}: a deed form must start with '('", self.line); + } + self.bump(); + let head = self.lex_symbol()?; + if !DOC_HEADS.contains(&head.as_str()) { + bail!( + "line {}: invalid doc-head {head:?}; valid heads: {}", + self.line, + DOC_HEADS.join(", ") + ); + } + if self.peek() != Some(')') && !self.at_sep() { + bail!( + "line {}: doc-head {head} must be followed by a separator before the first field", + self.line + ); + } + let (fields, clauses) = self.parse_body(&head)?; + if fields.is_empty() && clauses.is_empty() { + bail!( + "line {}: a deed form must carry at least one field or clause", + self.line + ); + } + Ok(Node { + head, + fields, + clauses, + }) + } + + /// `clause = "(" symbol *(token-sep (field / clause)) [token-sep] ")"` + /// + /// Zero items is legal: `(deployment)` is a well-formed clause. + fn parse_clause(&mut self) -> Result { + debug_assert_eq!(self.peek(), Some('(')); + self.bump(); + // "clause '(' must be followed immediately by a clause symbol (no + // separator)" — this is what keeps `( a b )` from being read as a + // clause in body position. + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => bail!( + "line {}: clause '(' must be followed immediately by a clause symbol (no separator)", + self.line + ), + } + let head = self.lex_symbol()?; + if self.peek() != Some(')') && !self.at_sep() { + bail!( + "line {}: clause ({head}) head must be followed by a separator or ')'", + self.line + ); + } + let (fields, clauses) = self.parse_body(&head)?; + Ok(Node { + head, + fields, + clauses, + }) + } + + /// The shared body of a form and a clause: `:keyword value` fields and + /// nested `(symbol …)` clauses, until the matching `)`. + #[allow(clippy::type_complexity)] + fn parse_body(&mut self, head: &str) -> Result<(Vec<(String, Value)>, Vec)> { + let mut fields = Vec::new(); + let mut clauses = Vec::new(); + let mut parsed_item = false; + loop { + let had_sep = self.skip_sep()?; + match self.peek() { + None => bail!( + "line {}: unbalanced parens: ({head}) never closes", + self.line + ), + Some(')') => { + self.bump(); + return Ok((fields, clauses)); + } + Some(':') => { + if parsed_item && !had_sep { + bail!( + "line {}: fields and clauses in ({head}) must be separated by a separator", + self.line + ); + } + let (k, v) = self.parse_field()?; + fields.push((k, v)); + parsed_item = true; + } + // In BODY position "(" opens a clause, never a list. + Some('(') => { + if parsed_item && !had_sep { + bail!( + "line {}: fields and clauses in ({head}) must be separated by a separator", + self.line + ); + } + clauses.push(self.parse_clause()?); + parsed_item = true; + } + Some(c) => bail!( + "line {}: expected field (':keyword …') or clause ('(symbol …)') in ({head}), got {c:?}", + self.line + ), + } + } + } + + /// `field = keyword token-sep value` + fn parse_field(&mut self) -> Result<(String, Value)> { + debug_assert_eq!(self.peek(), Some(':')); + self.bump(); + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => bail!( + "line {}: malformed keyword: ':' must be followed by a symbol", + self.line + ), + } + let key = self.lex_symbol()?; + if !self.at_sep() { + bail!( + "line {}: keyword :{key} must be followed by a separator before its value", + self.line + ); + } + self.skip_sep()?; + let val = self.parse_value()?; + // The grammar's boolean production is `#t` / `#f` only, and says + // explicitly: "Never true, false, yes, no, 1, 0". A bare `true` lexes + // as a perfectly legal symbol, so this is the rule that catches the + // author who meant a boolean — it is a real check, not a formality. + if let Value::Sym(s) = &val { + if matches!(s.as_str(), "true" | "false" | "yes" | "no") { + bail!( + "line {}: :{key} has bare symbol {s} — booleans are #t / #f only; \ + true/false/yes/no are parse errors", + self.line + ); + } + } + Ok((key, val)) + } + + /// `value = string / symbol / integer / boolean / uuid5 / quoted / list` + fn parse_value(&mut self) -> Result { + match self.peek() { + None => bail!( + "line {}: unexpected end of input, expected a value", + self.line + ), + Some('"') => Ok(Value::Str(self.lex_string()?)), + // In VALUE position "(" opens a list, never a clause. + Some('(') => self.lex_list(), + Some('#') => self.lex_hash(), + Some('\'') => { + self.bump(); + let inner = self.parse_value()?; + match inner { + Value::Sym(_) | Value::List(_) => Ok(Value::Quoted(Box::new(inner))), + other => bail!( + "line {}: only symbols and lists may be quoted, not {}", + self.line, + other.kind() + ), + } + } + Some(':') => bail!( + "line {}: stray keyword — a keyword may only lead a field, not stand as a value", + self.line + ), + Some(c) if c == '-' || c.is_ascii_digit() => self.lex_integer(), + Some(c) if c.is_ascii_alphabetic() => Ok(Value::Sym(self.lex_symbol()?)), + Some(c) => bail!( + "line {}: cannot lex a value starting at {c:?} \ + ('=' as a field separator is not a deed; '[section]' is not a deed)", + self.line + ), + } + } + + /// `list = "(" [value *(token-sep value)] [token-sep] ")"` + fn lex_list(&mut self) -> Result { + debug_assert_eq!(self.peek(), Some('(')); + self.bump(); + let mut items = Vec::new(); + let mut parsed_item = false; + loop { + let had_sep = self.skip_sep()?; + match self.peek() { + None => bail!("line {}: unbalanced parens: list never closes", self.line), + Some(')') => { + self.bump(); + return Ok(Value::List(items)); + } + _ => { + if parsed_item && !had_sep { + bail!( + "line {}: list values must be separated by a separator", + self.line + ); + } + items.push(self.parse_value()?); + parsed_item = true; + } + } + } + } + + /// `boolean = "#t" / "#f"` and `uuid5 = "#u5" string`. + fn lex_hash(&mut self) -> Result { + debug_assert_eq!(self.peek(), Some('#')); + if self.starts_with("#u5") { + for _ in 0..3 { + self.bump(); + } + if self.peek() != Some('"') { + bail!( + "line {}: uuid5 must be followed immediately by a string: #u5\"name\"", + self.line + ); + } + return Ok(Value::Uuid5(self.lex_string()?)); + } + let b = match (self.peek_at(1), self.peek_at(2)) { + // A trailing identifier character means this is not a boolean at + // all (`#true`), so it falls through to the catch-all below. + (Some('t'), n) if !is_sym_continue(n) => true, + (Some('f'), n) if !is_sym_continue(n) => false, + _ => bail!( + "line {}: unrecognised #-form: only #t, #f and #u5\"…\" are legal \ + (#T and #F are invalid — booleans are strict lowercase)", + self.line + ), + }; + self.bump(); + self.bump(); + Ok(Value::Bool(b)) + } + + /// `integer = ["-"] 1*DIGIT`. Leading zeros are decimal, never octal. + fn lex_integer(&mut self) -> Result { + let start = self.line; + let mut s = String::new(); + if self.peek() == Some('-') { + s.push('-'); + self.bump(); + } + let mut digits = 0usize; + while let Some(c) = self.peek() { + if c.is_ascii_digit() { + s.push(c); + self.bump(); + digits += 1; + } else { + break; + } + } + if digits == 0 { + bail!("line {start}: '-' must be followed by at least one digit"); + } + if is_sym_continue(self.peek()) { + bail!("line {start}: malformed token: number followed by identifier characters"); + } + let n: i64 = s + .parse() + .map_err(|_| anyhow::anyhow!("line {start}: integer {s} does not fit in i64"))?; + Ok(Value::Int(n)) + } + + /// `symbol = ALPHA *( ALPHA / DIGIT / "." / "*" / "/" / "<" / ">" / "=" / + /// "!" / "?" / "+" / "-" / "_" )` + fn lex_symbol(&mut self) -> Result { + match self.peek() { + Some(c) if c.is_ascii_alphabetic() => {} + Some(c) => bail!( + "line {}: a symbol must start with a letter, got {c:?}", + self.line + ), + None => bail!( + "line {}: unexpected end of input, expected a symbol", + self.line + ), + } + let mut s = String::new(); + while is_sym_continue(self.peek()) { + s.push(self.bump().expect("peeked")); + } + Ok(s) + } + + /// `string = DQUOTE *( str-char / escape ) DQUOTE`, with exactly four + /// escapes: `\"` `\\` `\n` `\t`. `\r` and `\uXXXX` are invalid. + fn lex_string(&mut self) -> Result { + debug_assert_eq!(self.peek(), Some('"')); + let start = self.line; + self.bump(); + let mut out = String::new(); + loop { + let c = match self.bump() { + None => bail!("line {start}: unterminated string"), + Some(c) => c, + }; + match c { + '"' => return Ok(out), + '\\' => match self.bump() { + None => bail!("line {start}: dangling backslash"), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some(bad) => bail!( + "line {}: invalid escape \\{bad} — exactly four escapes are legal: \ + \\\" \\\\ \\n \\t", + self.line + ), + }, + c if (c as u32) < 0x20 => bail!( + "line {}: raw control character U+{:04X} inside string (use legal escapes)", + self.line, + c as u32 + ), + c => out.push(c), + } + } + } +} + +/// Whether `c` may continue a symbol after its first character. +fn is_sym_continue(c: Option) -> bool { + matches!( + c, + Some(c) if c.is_ascii_alphanumeric() + || matches!(c, '.' | '*' | '/' | '<' | '>' | '=' | '!' | '?' | '+' | '-' | '_') + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Wrap a form body in a minimal legal document. + fn doc(body: &str) -> String { + format!( + ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version \"1.0.0\" {body})\n" + ) + } + + /// Parse `body` in a minimal valid deed, expecting success. + fn ok(body: &str) -> Node { + parse(&doc(body)).unwrap_or_else(|e| panic!("expected {body:?} to parse, got: {e:#}")) + } + + /// Parse `body` in a minimal valid deed, expecting failure; return the message. + fn err(body: &str) -> String { + match parse(&doc(body)) { + Ok(_) => panic!("expected {body:?} to be rejected, but it parsed"), + Err(e) => format!("{e:#}"), + } + } + + /// **The central design point.** The same four bytes `(a b)` are a LIST + /// after a keyword and a CLAUSE in body position. Nothing but position + /// distinguishes them, and getting this backwards is the single most + /// likely way to write a DEED parser that is subtly wrong rather than + /// obviously broken. + /// Paren is a list in value position and a clause in body position. + #[test] + fn paren_is_a_list_in_value_position_and_a_clause_in_body_position() { + let as_value = ok(r#":names ("a" "b")"#); + assert_eq!( + as_value + .field("names") + .and_then(Value::as_list) + .map(<[Value]>::len), + Some(2), + "after a keyword, '(' must open a LIST" + ); + assert!(as_value.clauses.is_empty(), "a value list is not a clause"); + + let as_clause = ok("(names :first \"a\")"); + assert!(as_clause.fields.iter().all(|(k, _)| k == "schema-version")); + assert_eq!( + as_clause.clauses.len(), + 1, + "in body position, '(' must open a CLAUSE" + ); + assert_eq!(as_clause.clauses[0].head, "names"); + } + + /// Empty list is legal but empty clause is not. + #[test] + fn empty_list_is_legal_but_empty_clause_is_not() { + assert_eq!( + ok(":previous-names ()").field("previous-names"), + Some(&Value::List(vec![])) + ); + // `clause = "(" symbol …` — there is no headless clause production. + assert!(err("()").contains("must be followed immediately by a clause symbol")); + } + + /// A clause's `(` must be followed *immediately* by its head symbol. This + /// is what stops `( a b )` being read as a clause in body position. + /// Clause head admits no leading separator. + #[test] + fn clause_head_admits_no_leading_separator() { + assert!(err("( names :x 1)").contains("must be followed immediately by a clause symbol")); + } + + /// Keyword must be separated from its value. + #[test] + fn keyword_must_be_separated_from_its_value() { + assert!(err(":names(\"a\")").contains("must be followed by a separator")); + assert!(err(":names\"a\"").contains("must be followed by a separator")); + } + + /// Body items must be separated. + #[test] + fn body_items_must_be_separated() { + for bad in [ + r#":a "x":b "y""#, + r#":a "x"(nested)"#, + r#"(first):a "x""#, + "(first)(second)", + ] { + assert!( + err(bad).contains("fields and clauses in (repo-deed) must be separated"), + "{bad:?} should require a separator between body items" + ); + } + } + + /// List values must be separated. + #[test] + fn list_values_must_be_separated() { + for bad in [r#":items ("a""b")"#, ":items (1#t)", ":items (#t(foo))"] { + assert!( + err(bad).contains("list values must be separated"), + "{bad:?} should require a separator between list values" + ); + } + + assert_eq!( + ok(r#":items ("a")"#).field("items"), + Some(&Value::List(vec![Value::Str("a".into())])) + ); + } + + /// `=` is a legal symbol *character*, never a field separator. A parser + /// that splits on `=` reads TOML and calls it a deed. + /// + /// Note the asymmetry, which is easy to get wrong in the permissive + /// direction: `symbol = ALPHA *( ALPHA / DIGIT / … / "=" / … )`, so `=` may + /// appear INSIDE a symbol but a symbol must still START with a letter. + /// `>=` is therefore not a symbol either. + /// Equals is a symbol character not a separator. + #[test] + fn equals_is_a_symbol_character_not_a_separator() { + assert_eq!(ok(":op a=b").field("op"), Some(&Value::Sym("a=b".into()))); + assert_eq!( + ok(":op v1.2-rc").field("op"), + Some(&Value::Sym("v1.2-rc".into())) + ); + assert!(err(":name = \"x\"").contains("'=' as a field separator is not a deed")); + assert!( + err(":op >=").contains("cannot lex a value"), + "a symbol must start with a letter" + ); + } + + /// Exactly four escapes are legal. + #[test] + fn exactly_four_escapes_are_legal() { + assert_eq!( + ok(r#":s "a\n b\t c\\ d\"e""#).str_field("s"), + Some("a\n b\t c\\ d\"e") + ); + assert!(err(r#":s "bad \r""#).contains("invalid escape")); + // Written this way deliberately: a literal backslash-u sequence in this + // source has been silently decoded by tooling in transit before now, + // which turned this case into `"bad A"` — a string that parses fine and + // made the assertion vacuous. Composing the backslash at runtime cannot + // be mangled that way. + let u_escape = format!(r#":s "bad {}u0041""#, '\\'); + assert!( + format!("{:#}", parse(&doc(&u_escape)).unwrap_err()).contains("invalid escape"), + "\\uXXXX is not one of the four legal escapes" + ); + // A trailing backslash must not swallow the structure that follows it. + // Here the next character is the form's closing paren, so the parser is + // required to report an invalid escape rather than quietly consuming + // the `)` and hunting for a closing quote that no longer exists. + assert!(err(r#":s "dangling \"#).contains("invalid escape")); + + // A raw LF inside a string is rejected on its own terms, so a string + // can only run to EOF if the input stops with no trailing newline at + // all. `doc()` cannot produce that — it always appends the closer. + let truncated = concat!( + ";; SPDX-License-Identifier: MPL-2.0\n", + "(repo-deed :schema-version \"1.0.0\" :s \"never closed" + ); + assert!( + format!("{:#}", parse(truncated).unwrap_err()).contains("unterminated string"), + "a string running to EOF must be reported as unterminated" + ); + + // …and the stricter rule that makes the above hard to reach in the + // first place: a literal newline inside a string is not a str-char. + assert!( + format!( + "{:#}", + parse(concat!( + ";; SPDX-License-Identifier: MPL-2.0\n", + "(repo-deed :schema-version \"1.0.0\" :s \"two\nlines\")\n" + )) + .unwrap_err() + ) + .contains("raw control character"), + "a raw LF inside a string must be rejected, not absorbed" + ); + } + + /// Booleans are strict lowercase hash forms. + #[test] + fn booleans_are_strict_lowercase_hash_forms() { + assert_eq!(ok(":a #t :b #f").field("a"), Some(&Value::Bool(true))); + assert_eq!(ok(":a #t :b #f").field("b"), Some(&Value::Bool(false))); + for bad in [":a #T", ":a #true", ":a #F", ":a #x"] { + assert!( + err(bad).contains("unrecognised #-form"), + "{bad} should be rejected" + ); + } + // `true` lexes as a legal SYMBOL; it is rejected as a value because the + // grammar says booleans are #t/#f only — not because it fails to lex. + for bad in [":a true", ":a false", ":a yes", ":a no"] { + assert!( + err(bad).contains("booleans are #t / #f only"), + "{bad} should be rejected" + ); + } + } + + /// Uuid5 must be followed immediately by a string. + #[test] + fn uuid5_must_be_followed_immediately_by_a_string() { + assert_eq!( + ok(r#":id #u5"estate/chora""#).field("id"), + Some(&Value::Uuid5("estate/chora".into())) + ); + assert!(err(r#":id #u5 "estate/chora""#).contains("immediately by a string")); + assert!(err(":id #u5 x").contains("immediately by a string")); + } + + /// Only symbols and lists may be quoted. + #[test] + fn only_symbols_and_lists_may_be_quoted() { + assert_eq!( + ok(":q 'sym").field("q"), + Some(&Value::Quoted(Box::new(Value::Sym("sym".into())))) + ); + assert!( + matches!(ok(":q '(a b)").field("q"), Some(Value::Quoted(b)) if matches!(**b, Value::List(_))) + ); + assert!(err(r#":q '"str""#).contains("only symbols and lists may be quoted")); + assert!(err(":q '1").contains("only symbols and lists may be quoted")); + } + + /// Integers are decimal with leading zeros permitted. + #[test] + fn integers_are_decimal_with_leading_zeros_permitted() { + assert_eq!( + ok(":n 007").field("n"), + Some(&Value::Int(7)), + "leading zeros are NOT octal" + ); + assert_eq!(ok(":n -3").field("n"), Some(&Value::Int(-3))); + assert_eq!(ok(":n 0").field("n"), Some(&Value::Int(0))); + assert!(err(":n 12abc").contains("number followed by identifier characters")); + } + + /// A keyword may only lead a field. + #[test] + fn a_keyword_may_only_lead_a_field() { + assert!(err(":outer :inner").contains("stray keyword")); + assert!(err(":list (:a)").contains("stray keyword")); + } + + /// `token-sep` is SP / line-end / comment. A single `;` opens a comment, so + /// `;;` is a comment too — including in the middle of a form. + /// Comments are separators anywhere in the form. + #[test] + fn comments_are_separators_anywhere_in_the_form() { + let n = ok(":a \"x\" ; trailing comment\n :b \"y\""); + assert_eq!(n.str_field("a"), Some("x")); + assert_eq!(n.str_field("b"), Some("y")); + } + + /// Bare cr is not a line end. + #[test] + fn bare_cr_is_not_a_line_end() { + let text = ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed\r:schema-version \"1.0.0\")\n"; + assert!(format!("{:#}", parse(text).unwrap_err()).contains("bare CR")); + // CRLF is fine. + let crlf = + ";; SPDX-License-Identifier: MPL-2.0\r\n(repo-deed\r\n:schema-version \"1.0.0\")\r\n"; + assert!(parse(crlf).is_ok(), "CRLF is a legal line-end"); + } + + /// The whole input must be consumed. + #[test] + fn the_whole_input_must_be_consumed() { + let text = ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version \"1\") extra\n"; + assert!(format!("{:#}", parse(text).unwrap_err()).contains("trailing content")); + // …but trailing whitespace and comments are fine. + let tidy = + ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version \"1\")\n;; bye\n"; + assert!(parse(tidy).is_ok()); + } + + /// Only the four doc heads are accepted. + #[test] + fn only_the_four_doc_heads_are_accepted() { + for head in DOC_HEADS { + let text = + format!(";; SPDX-License-Identifier: MPL-2.0\n({head} :schema-version \"1\")\n"); + assert!(parse(&text).is_ok(), "{head} is a legal doc-head"); + } + let text = ";; SPDX-License-Identifier: MPL-2.0\n(chora-deed :schema-version \"1\")\n"; + assert!(format!("{:#}", parse(text).unwrap_err()).contains("invalid doc-head")); + } + + /// Only the LEADING run of `;; SPDX-` lines is the header. The launcher + /// deed carries fourteen further `;;` comment lines before its form, and + /// they must be consumed as separators, not mistaken for header lines. + /// Comments between header and form are separators. + #[test] + fn comments_between_header_and_form_are_separators() { + let text = ";; SPDX-FileCopyrightText: © 2026 someone\n\ + ;; SPDX-License-Identifier: MPL-2.0\n\ + ;;\n\ + ;; A note about provenance.\n\ + (repo-deed :schema-version \"1.0.0\")\n"; + assert_eq!(parse(text).expect("parses").head, "repo-deed"); + } + + /// Schema version must be present exactly once and be a string. + #[test] + fn schema_version_must_be_present_exactly_once_and_be_a_string() { + let one = ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version \"1.0.0\")\n"; + assert!(parse(one).is_ok()); + + let none = ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :canonical-name \"x\")\n"; + assert!(format!("{:#}", parse(none).unwrap_err()).contains("(found 0)")); + + let two = ";; SPDX-License-Identifier: MPL-2.0\n\ + (repo-deed :schema-version \"1\" :schema-version \"2\")\n"; + assert!(format!("{:#}", parse(two).unwrap_err()).contains("(found 2)")); + + let sym = ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version v1)\n"; + assert!(format!("{:#}", parse(sym).unwrap_err()).contains("must be a STRING")); + } + + /// A tab is invalid anywhere, *including inside a string*, matching + /// `deed_lint.py`, which tests the raw text before lexing. + /// Tabs are invalid even inside a string. + #[test] + fn tabs_are_invalid_even_inside_a_string() { + assert!(err(":a \"x\ty\"").contains("HTAB")); + assert!(err(":a\t\"x\"").contains("HTAB")); + // The two-character escape is the legal way to write one. + assert_eq!(ok(r#":a "x\ty""#).str_field("a"), Some("x\ty")); + } + + /// Missing priority sorts after every rung that has one. + #[test] + fn missing_priority_sorts_after_every_rung_that_has_one() { + let n = ok("(ladder (p :priority 20 :v \"b\") (p :v \"none\") (p :priority 10 :v \"a\"))"); + let ladder = n.clause("ladder").expect("(ladder …)"); + let vs: Vec<&str> = ladder + .children_by_priority("p") + .iter() + .map(|c| c.str_field("v").unwrap()) + .collect(); + assert_eq!(vs, vec!["a", "b", "none"]); + } +} diff --git a/rs/deed-read/src/updates.rs b/rs/deed-read/src/updates.rs new file mode 100644 index 0000000..a8c8c2f --- /dev/null +++ b/rs/deed-read/src/updates.rs @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The `(updates …)` repo-deed vocabulary. +//! +//! Normative text: `1-formats/deed/vocabulary/updates.adoc` in +//! `hyperpolymath/standards`; policy: `docs/DEPENDABOT-POLICY.adoc` (owner +//! ruling D269). +//! +//! # Fail closed +//! +//! The clause is how a repo turns automated updates *off*. A reader that +//! skipped an unknown term would turn a typo (`:enabeld #f`) into "updates on", +//! so every deviation is an [`UpdatesError`]: an unknown field or clause, a +//! duplicate, a wrongly typed value, a missing required field. The caller must +//! treat any error as "arm nothing for this repo, and report". +//! +//! No deed, or a deed with no `(updates …)` clause, is **not** an error: the +//! defaults ([`UpdatesPolicy::default`]) apply. + +use std::fmt; +use std::path::Path; + +use crate::syntax::{self, Node, Value}; + +/// Why a deed's `(updates …)` clause could not be read. Always fail closed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdatesError(pub String); + +impl fmt::Display for UpdatesError { + /// Render the reason, prefixed so a log line names the vocabulary. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "(updates …): {}", self.0) + } +} + +impl std::error::Error for UpdatesError {} + +/// Shorthand for building an [`UpdatesError`] result. +fn fail(msg: impl Into) -> Result { + Err(UpdatesError(msg.into())) +} + +/// A calendar date written `YYYY-MM-DD`. Ordering is chronological. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date { + pub year: u16, + pub month: u8, + pub day: u8, +} + +impl Date { + /// Parse exactly `YYYY-MM-DD`, rejecting impossible dates (`2026-02-30`). + pub fn parse(s: &str) -> Result { + let b = s.as_bytes(); + let shape_ok = b.len() == 10 + && b[4] == b'-' + && b[7] == b'-' + && b.iter() + .enumerate() + .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()); + if !shape_ok { + return fail(format!(":until {s:?} is not YYYY-MM-DD")); + } + let year: u16 = s[0..4].parse().expect("four digits"); + let month: u8 = s[5..7].parse().expect("two digits"); + let day: u8 = s[8..10].parse().expect("two digits"); + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let max_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return fail(format!(":until {s:?} has no month {month}")), + }; + if day == 0 || day > max_day { + return fail(format!(":until {s:?} has no day {day} in month {month}")); + } + Ok(Date { year, month, day }) + } +} + +impl fmt::Display for Date { + /// Write the date back as `YYYY-MM-DD`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day) + } +} + +/// `(hold …)`: keep one package below a version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hold { + /// Dependabot `package-ecosystem`, `-` for `_` (`cargo`, `github-actions`). + pub ecosystem: String, + /// Exact package name; a trailing `*` makes it a prefix match. + pub package: String, + /// The first version that is NOT allowed. + pub below: String, + pub reason: String, + /// `"#N"` or an issue URL. + pub issue: Option, + /// After this date the hold has expired. + pub until: Option, +} + +impl Hold { + /// Whether the hold still applies on `today` (inclusive of `:until`). + pub fn is_active(&self, today: Date) -> bool { + self.until.is_none_or(|u| today <= u) + } + + /// Whether `name` is the held package (prefix match when it ends in `*`). + pub fn matches_package(&self, name: &str) -> bool { + match self.package.strip_suffix('*') { + Some(prefix) => name.starts_with(prefix), + None => name == self.package, + } + } +} + +/// `(exclude …)`: no automated updates for one ecosystem. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Exclude { + pub ecosystem: String, + pub reason: Option, +} + +/// A repo's update policy: the clause's contents, or the defaults. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdatesPolicy { + pub enabled: bool, + pub majors: bool, + pub soak_days: u32, + pub holds: Vec, + pub excludes: Vec, +} + +impl Default for UpdatesPolicy { + /// The policy for a repo with no deed or no `(updates …)` clause. + fn default() -> Self { + UpdatesPolicy { + enabled: true, + majors: true, + soak_days: 0, + holds: Vec::new(), + excludes: Vec::new(), + } + } +} + +impl UpdatesPolicy { + /// Whether `ecosystem` is excluded in this repo. + pub fn excludes_ecosystem(&self, ecosystem: &str) -> bool { + self.excludes.iter().any(|e| e.ecosystem == ecosystem) + } + + /// The holds that still apply on `today` to `package` in `ecosystem`. + pub fn active_holds<'a>( + &'a self, + ecosystem: &'a str, + package: &'a str, + today: Date, + ) -> impl Iterator { + self.holds.iter().filter(move |h| { + h.ecosystem == ecosystem && h.matches_package(package) && h.is_active(today) + }) + } +} + +/// Read the policy from a parsed deed. +/// +/// The clause may appear only in a `repo-deed`, at most once, as a direct child +/// of the form. Any deviation inside it is an error (see the module docs). +pub fn from_deed(doc: &Node) -> Result { + let found: Vec<&Node> = doc.clauses_named("updates").collect(); + if found.is_empty() { + return Ok(UpdatesPolicy::default()); + } + if doc.head != "repo-deed" { + return fail(format!("only a repo-deed may carry it, not a {}", doc.head)); + } + if found.len() > 1 { + return fail(format!("appears {} times; at most once is allowed", found.len())); + } + read_clause(found[0]) +} + +/// Parse deed source text and read its policy. A syntax error is an error. +pub fn from_text(text: &str) -> Result { + let doc = syntax::parse(text).map_err(|e| UpdatesError(format!("deed does not parse: {e:#}")))?; + from_deed(&doc) +} + +/// Read the policy from a deed file. A missing file means the defaults; any +/// other I/O failure is an error, so an unreadable deed never means "on". +pub fn from_path(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => from_text(&text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UpdatesPolicy::default()), + Err(e) => fail(format!("cannot read {}: {e}", path.display())), + } +} + +/// Check that every field name in `node` is in `allowed` and none repeats. +fn check_fields(node: &Node, allowed: &[&str]) -> Result<(), UpdatesError> { + for (i, (key, _)) in node.fields.iter().enumerate() { + if !allowed.contains(&key.as_str()) { + return fail(format!( + "unknown field :{key} in ({}); allowed: {}", + node.head, + allowed.iter().map(|a| format!(":{a}")).collect::>().join(" ") + )); + } + if node.fields[..i].iter().any(|(k, _)| k == key) { + return fail(format!("field :{key} repeated in ({})", node.head)); + } + } + Ok(()) +} + +/// The value of a required field, or an error naming it. +fn required<'a>(node: &'a Node, key: &str) -> Result<&'a Value, UpdatesError> { + node.field(key) + .ok_or_else(|| UpdatesError(format!("({}) requires :{key}", node.head))) +} + +/// The grammar name of a value's type, for error messages. +fn kind_of(v: &Value) -> &'static str { + match v { + Value::Str(_) => "string", + Value::Int(_) => "integer", + Value::Sym(_) => "symbol", + Value::Bool(_) => "boolean", + Value::Uuid5(_) => "uuid5", + Value::List(_) => "list", + Value::Quoted(_) => "quoted", + } +} + +/// A field value that must be a boolean; no coercion from symbols or strings. +fn want_bool(node: &Node, key: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| { + UpdatesError(format!("({}) :{key} must be #t or #f, got a {}", node.head, kind_of(v))) + }) +} + +/// A field value that must be a string. +fn want_str(node: &Node, key: &str, v: &Value) -> Result { + v.as_str().map(str::to_owned).ok_or_else(|| { + UpdatesError(format!("({}) :{key} must be a string, got a {}", node.head, kind_of(v))) + }) +} + +/// A field value that must be a symbol. +fn want_sym(node: &Node, key: &str, v: &Value) -> Result { + v.as_sym().map(str::to_owned).ok_or_else(|| { + UpdatesError(format!("({}) :{key} must be a symbol, got a {}", node.head, kind_of(v))) + }) +} + +/// An optional string field. +fn opt_str(node: &Node, key: &str) -> Result, UpdatesError> { + node.field(key).map(|v| want_str(node, key, v)).transpose() +} + +/// Read the body of one `(updates …)` clause. +fn read_clause(node: &Node) -> Result { + check_fields(node, &["enabled", "majors", "soak-days"])?; + let mut policy = UpdatesPolicy::default(); + if let Some(v) = node.field("enabled") { + policy.enabled = want_bool(node, "enabled", v)?; + } + if let Some(v) = node.field("majors") { + policy.majors = want_bool(node, "majors", v)?; + } + if let Some(v) = node.field("soak-days") { + let n = v.as_int().ok_or_else(|| { + UpdatesError(format!("(updates) :soak-days must be an integer, got a {}", kind_of(v))) + })?; + policy.soak_days = u32::try_from(n) + .map_err(|_| UpdatesError(format!("(updates) :soak-days must be 0 or more, got {n}")))?; + } + for child in &node.clauses { + match child.head.as_str() { + "hold" => policy.holds.push(read_hold(child)?), + "exclude" => policy.excludes.push(read_exclude(child)?), + other => return fail(format!("unknown clause ({other}); allowed: (hold …) (exclude …)")), + } + } + Ok(policy) +} + +/// Read one `(hold …)` clause. +fn read_hold(node: &Node) -> Result { + check_fields(node, &["ecosystem", "package", "below", "reason", "issue", "until"])?; + if !node.clauses.is_empty() { + return fail("(hold) takes fields only, no nested clauses"); + } + let reason = want_str(node, "reason", required(node, "reason")?)?; + if reason.trim().is_empty() { + return fail("(hold) :reason must not be empty"); + } + let package = want_str(node, "package", required(node, "package")?)?; + if package.is_empty() || package == "*" { + return fail(format!("(hold) :package {package:?} names no package")); + } + let below = want_str(node, "below", required(node, "below")?)?; + if below.is_empty() { + return fail("(hold) :below must not be empty"); + } + let until = opt_str(node, "until")?.map(|s| Date::parse(&s)).transpose()?; + Ok(Hold { + ecosystem: want_sym(node, "ecosystem", required(node, "ecosystem")?)?, + package, + below, + reason, + issue: opt_str(node, "issue")?, + until, + }) +} + +/// Read one `(exclude …)` clause. +fn read_exclude(node: &Node) -> Result { + check_fields(node, &["ecosystem", "reason"])?; + if !node.clauses.is_empty() { + return fail("(exclude) takes fields only, no nested clauses"); + } + Ok(Exclude { + ecosystem: want_sym(node, "ecosystem", required(node, "ecosystem")?)?, + reason: opt_str(node, "reason")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Wrap a clause body in a minimal valid repo-deed. + fn deed(body: &str) -> String { + format!(";; SPDX-License-Identifier: MPL-2.0\n(repo-deed :schema-version \"1.0.0\"\n{body})\n") + } + + /// Read a clause body, expecting success. + fn ok(body: &str) -> UpdatesPolicy { + from_text(&deed(body)).unwrap_or_else(|e| panic!("expected ok, got {e}")) + } + + /// Read a clause body, expecting failure; return the message. + fn err(body: &str) -> String { + match from_text(&deed(body)) { + Ok(p) => panic!("expected an error, got {p:?}"), + Err(e) => e.to_string(), + } + } + + /// No clause means defaults. + #[test] + fn no_clause_means_defaults() { + assert_eq!(ok(""), UpdatesPolicy::default()); + assert!(UpdatesPolicy::default().enabled && UpdatesPolicy::default().majors); + } + + /// Missing file means defaults but bad file does not. + #[test] + fn missing_file_means_defaults_but_bad_file_does_not() { + let missing = Path::new("/nonexistent/deed-read/none_chora.deed"); + assert_eq!(from_path(missing).unwrap(), UpdatesPolicy::default()); + assert!(from_text("(repo-deed").is_err()); + } + + /// Full clause reads. + #[test] + fn full_clause_reads() { + let p = ok(r##"(updates :enabled #t :majors #f :soak-days 7 + (hold :ecosystem cargo :package "tokio" :below "2.0.0" + :reason "rework" :issue "#42" :until "2026-12-01") + (exclude :ecosystem npm))"##); + assert!(p.enabled && !p.majors); + assert_eq!(p.soak_days, 7); + assert_eq!(p.holds.len(), 1); + assert_eq!(p.holds[0].until, Some(Date { year: 2026, month: 12, day: 1 })); + assert!(p.excludes_ecosystem("npm") && !p.excludes_ecosystem("cargo")); + } + + /// Opt out is read. + #[test] + fn opt_out_is_read() { + assert!(!ok("(updates :enabled #f)").enabled); + } + + // One mutant per reader obligation. Each must fail, and for its own reason. + + /// Typo field fails closed. + #[test] + fn typo_field_fails_closed() { + assert!(err("(updates :enabeld #f)").contains("unknown field :enabeld")); + } + + /// Unknown clause fails closed. + #[test] + fn unknown_clause_fails_closed() { + assert!(err("(updates (holds :ecosystem cargo))").contains("unknown clause (holds)")); + } + + /// Duplicate field fails closed. + #[test] + fn duplicate_field_fails_closed() { + assert!(err("(updates :enabled #t :enabled #f)").contains("repeated")); + } + + /// Duplicate clause fails closed. + #[test] + fn duplicate_clause_fails_closed() { + assert!(err("(updates :enabled #t)\n(updates :enabled #f)").contains("at most once")); + } + + /// No coercion. + #[test] + fn no_coercion() { + assert!(err(r#"(updates :enabled "no")"#).contains("must be #t or #f")); + assert!(err("(updates :majors off)").contains("must be #t or #f")); + assert!(err(r#"(updates :soak-days "7")"#).contains("must be an integer")); + assert!(err("(updates :soak-days -1)").contains("0 or more")); + assert!(err(r#"(updates (exclude :ecosystem "npm"))"#).contains("must be a symbol")); + } + + /// Hold without reason is rejected. + #[test] + fn hold_without_reason_is_rejected() { + assert!(err(r#"(updates (hold :ecosystem cargo :package "x" :below "2"))"#) + .contains("requires :reason")); + assert!(err(r#"(updates (hold :ecosystem cargo :package "x" :below "2" :reason " "))"#) + .contains("must not be empty")); + } + + /// Hold requires package and below. + #[test] + fn hold_requires_package_and_below() { + assert!(err(r#"(updates (hold :ecosystem cargo :below "2" :reason "r"))"#) + .contains("requires :package")); + assert!(err(r#"(updates (hold :ecosystem cargo :package "*" :below "2" :reason "r"))"#) + .contains("names no package")); + assert!(err(r#"(updates (hold :ecosystem cargo :package "x" :reason "r"))"#) + .contains("requires :below")); + } + + /// Until must be a real date. + #[test] + fn until_must_be_a_real_date() { + let h = |d: &str| { + format!(r#"(updates (hold :ecosystem cargo :package "x" :below "2" :reason "r" :until "{d}"))"#) + }; + assert!(err(&h("2026-12-1")).contains("not YYYY-MM-DD")); + assert!(err(&h("2026-13-01")).contains("no month 13")); + assert!(err(&h("2026-02-29")).contains("no day 29")); + assert_eq!(ok(&h("2028-02-29")).holds[0].until.unwrap().to_string(), "2028-02-29"); + } + + /// Only a repo deed may carry it. + #[test] + fn only_a_repo_deed_may_carry_it() { + let text = ";; SPDX-License-Identifier: MPL-2.0\n\ + (estate-deed :schema-version \"1.0.0\" (updates :enabled #f))\n"; + assert!(from_text(text).unwrap_err().to_string().contains("only a repo-deed")); + } + + /// Holds match prefix and expire. + #[test] + fn holds_match_prefix_and_expire() { + let p = ok(r#"(updates (hold :ecosystem github-actions :package "github/codeql-action*" + :below "4.38.1" :reason "poisoned" :until "2026-12-01"))"#); + let before = Date::parse("2026-12-01").unwrap(); + let after = Date::parse("2026-12-02").unwrap(); + assert_eq!(p.active_holds("github-actions", "github/codeql-action/init", before).count(), 1); + assert_eq!(p.active_holds("github-actions", "github/codeql-action/init", after).count(), 0); + assert_eq!(p.active_holds("cargo", "github/codeql-action", before).count(), 0); + assert_eq!(p.active_holds("github-actions", "github/other", before).count(), 0); + } +} diff --git a/rs/deed-read/tests/deed_corpus.rs b/rs/deed-read/tests/deed_corpus.rs new file mode 100644 index 0000000..fbf5ef9 --- /dev/null +++ b/rs/deed-read/tests/deed_corpus.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The DEED reader is run against the *same* corpus as `deed_lint.py`. +//! +//! This file exists for one reason. The estate has exactly one normative DEED +//! grammar — `1-formats/deed/spec/abnf/deed.abnf` in `hyperpolymath/standards` +//! (owner ruling 2026-09-19, standards#837). A second implementation that is +//! only ever tested against files it was written from will quietly drift into +//! being a *second* authority: it accepts documents the estate rejects, or +//! rejects documents the estate accepts, and nothing notices until a launcher +//! is minted from a file the linter would have refused. +//! +//! Running the upstream corpus is the cheapest available defence. Every file +//! under `fixtures/deed/valid/` must parse; every file under `invalid/` must +//! not. A disagreement here is a real finding about one of the two readers, +//! never a reason to quietly drop the fixture. +//! +//! See `fixtures/deed/MANIFEST.sha256` for provenance and the refresh +//! procedure. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use deed_read::syntax::{self as deed, Value}; +use sha2::{Digest, Sha256}; + +/// The vendored corpus directory. +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/deed") +} + +/// Read a fixture, panicking with its path on failure. +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// Every `.deed` under a subdirectory, sorted, so failures name a stable file. +fn corpus(sub: &str) -> Vec { + let dir = fixtures_dir().join(sub); + let mut out: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("cannot list {}: {e}", dir.display())) + .map(|e| e.expect("dir entry").path()) + .filter(|p| p.extension().is_some_and(|x| x == "deed")) + .collect(); + out.sort(); + out +} + +/// The vendored copies still match the digests recorded when they were taken. +/// +/// This is the drift detector. Without it, "the corpus passes" degrades into +/// "the corpus, as someone later edited it to pass, passes" — which is how a +/// vendored fixture set stops testing anything. +#[test] +fn vendored_corpus_matches_its_manifest() { + let root = fixtures_dir(); + let manifest = read(&root.join("MANIFEST.sha256")); + + let mut recorded: BTreeMap = BTreeMap::new(); + for line in manifest.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (digest, rel) = line + .split_once(" ") + .unwrap_or_else(|| panic!("malformed manifest line: {line:?}")); + recorded.insert(rel.to_string(), digest.to_string()); + } + assert!( + !recorded.is_empty(), + "MANIFEST.sha256 records no files — the drift detector would pass vacuously" + ); + + let mut on_disk: BTreeMap = BTreeMap::new(); + for sub in ["valid", "invalid"] { + for path in corpus(sub) { + let name = path.file_name().expect("file name").to_string_lossy(); + let digest = hex::encode(Sha256::digest(std::fs::read(&path).expect("read"))); + on_disk.insert(format!("{sub}/{name}"), digest); + } + } + + // Compare the SETS, not the counts: a file added and a file deleted in the + // same edit leaves the count unchanged. + let recorded_names: Vec<&String> = recorded.keys().collect(); + let on_disk_names: Vec<&String> = on_disk.keys().collect(); + assert_eq!( + recorded_names, on_disk_names, + "the set of vendored fixtures differs from the set the manifest records" + ); + + for (name, want) in &recorded { + assert_eq!( + on_disk.get(name), + Some(want), + "{name} has been edited in place since it was vendored — \ + refresh it from standards rather than adjusting it to pass" + ); + } +} + +/// Every upstream-valid document parses. +#[test] +fn every_valid_fixture_parses() { + let files = corpus("valid"); + assert!(files.len() >= 5, "valid corpus is suspiciously small"); + for path in files { + let text = read(&path); + if let Err(e) = deed::parse(&text) { + panic!( + "{} is VALID upstream but this reader rejected it: {e:#}\n\ + The ABNF is the authority. Extend the parser; do not drop the fixture.", + path.display() + ); + } + } +} + +/// Every upstream-invalid document is rejected, **and for the defect it is +/// named after**. +/// +/// Rejection alone is a weak assertion: a fixture can fail for an accidental +/// reason (a tab check firing before the `[section]` it was written to catch) +/// and the test still passes, so the rule it was meant to cover is never +/// exercised. Each fixture therefore pins a fragment of its expected message. +/// If a refactor changes the wording, update the fragment *after* confirming +/// the new message still describes the same defect. +#[test] +fn every_invalid_fixture_is_rejected_for_the_right_reason() { + // fixture stem -> a distinctive fragment of the message it must produce + let expected: BTreeMap<&str, &str> = BTreeMap::from([ + ("inequals", "'=' as a field separator is not a deed"), + ("inescape-u", "invalid escape"), + ("inhead", "invalid doc-head"), + ( + "inmissing-schema", + "exactly one :schema-version STRING field", + ), + ("inno-header", "must open with at least one"), + ("insection", "expected field"), + ("intab", "HTAB (tab) is an invalid separator"), + ("intrailing", "trailing content after the closing"), + ("intrue-literal", "booleans are #t / #f only"), + ("inunbalanced", "never closes"), + ]); + + let files = corpus("invalid"); + assert!(files.len() >= 10, "invalid corpus is suspiciously small"); + + for path in files { + let text = read(&path); + let name = path + .file_name() + .expect("file name") + .to_string_lossy() + .to_string(); + let stem = name.trim_end_matches("_chora.deed").to_string(); + + let err = match deed::parse(&text) { + Ok(_) => panic!( + "{name} is INVALID upstream but this reader accepted it.\n\ + A reader more permissive than deed_lint.py mints launchers from \ + files the estate rejects." + ), + Err(e) => format!("{e:#}"), + }; + + let want = expected.get(stem.as_str()).unwrap_or_else(|| { + panic!( + "{name} is a new invalid fixture with no expected-reason entry. \ + Add one rather than letting it pass on any rejection at all." + ) + }); + assert!( + err.contains(want), + "{name} was rejected, but for the WRONG reason.\n expected to contain: {want}\n actual: {err}\nA fixture that fails incidentally does not test the rule it names." + ); + } +} + +/// **The mutant killer.** +/// +/// `scrambled-priority_praxis.deed` lists its rungs in file order 30, 10, 20, +/// 20. A reader that ignores `:priority` and iterates the file returns them in +/// that order and passes every test written against the real +/// `launcher-standard_praxis.deed`, whose ladder happens to be written in +/// ascending order already. +/// +/// To confirm this test has teeth: comment out the `sort_by_key` in +/// `Node::children_by_priority` and watch it go red. +#[test] +fn ladder_is_ordered_by_priority_not_by_file_position() { + let path = fixtures_dir().join("valid/scrambled-priority_praxis.deed"); + let doc = deed::parse(&read(&path)).expect("fixture parses"); + + let search = doc + .clause("resolution") + .expect("(resolution …)") + .clause("standard-search") + .expect("(standard-search …)"); + + // Sanity: the FILE really is scrambled. If this ever reads 10,20,20,30 the + // fixture has been tidied and the test below has silently lost its teeth. + let file_order: Vec = search + .clauses_named("path") + .map(|p| { + p.field("priority") + .and_then(Value::as_int) + .expect(":priority") + }) + .collect(); + assert_eq!( + file_order, + vec![30, 10, 20, 20], + "the fixture is no longer scrambled — this test can no longer detect \ + a reader that ignores :priority" + ); + + let sorted = search.children_by_priority("path"); + let priorities: Vec = sorted + .iter() + .map(|p| { + p.field("priority") + .and_then(Value::as_int) + .expect(":priority") + }) + .collect(); + assert_eq!( + priorities, + vec![10, 20, 20, 30], + "rungs must sort ascending" + ); + + let values: Vec<&str> = sorted + .iter() + .map(|p| p.str_field("value").expect(":value")) + .collect(); + assert_eq!( + values, + vec![ + "$FIRST/first.deed", + "$SECOND_BETA/beta.deed", + "$SECOND_GAMMA/gamma.deed", + "$THIRD/third.deed" + ], + "the tie at priority 20 must keep source order — the sort must be stable" + ); +} + +/// A clause with a head and no body is legal (`(deployment)` in the +/// rsr-template-repo fixture), and nesting is preserved. +#[test] +fn headless_clauses_and_nesting_survive() { + let doc = deed::parse(&read( + &fixtures_dir().join("valid/rsr-template-repo_chora.deed"), + )) + .expect("fixture parses"); + assert_eq!(doc.head, "repo-deed"); + + // `(deployment)` is not a top-level clause — it sits inside `(playbook)`, + // alongside four more head-only clauses on the same line. Reaching it at + // all is the nesting half of this test. + let playbook = doc.clause("playbook").expect("(playbook …)"); + for head in [ + "deployment", + "incident-response", + "release-process", + "docs-format", + "maintenance-operations", + ] { + let empty = playbook + .clause(head) + .unwrap_or_else(|| panic!("({head}) is a clause with a head and no body")); + assert!( + empty.fields.is_empty() && empty.clauses.is_empty(), + "({head}) should have parsed with an empty body" + ); + } + + // A sibling clause on the same nesting level that DOES have a body, so the + // test cannot pass by finding everything empty. + let skeleton = playbook.clause("skeleton").expect("(skeleton …)"); + assert_eq!(skeleton.str_field("version"), Some("1.0")); +} diff --git a/rs/deed-read/tests/fixtures/deed/MANIFEST.sha256 b/rs/deed-read/tests/fixtures/deed/MANIFEST.sha256 new file mode 100644 index 0000000..2977d77 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/MANIFEST.sha256 @@ -0,0 +1,41 @@ +# DEED corpus — vendored from hyperpolymath/standards +# +# Source: 1-formats/deed/tools/fixtures/{valid,invalid}/ +# Taken into launch-scaffolder at standards commit +# c60abfaae14ec8d543d2feeaebc951228590d645 (2026-09-22), then carried into +# this crate from launch-scaffolder 154b9b61 (#36) unchanged, 2026-10-01. +# updates-clause_chora.deed was added from standards PR #1110 (head 70e3e220, +# not yet merged at the time of copying); refresh once it lands. +# Every other file was re-checked byte-identical against standards on +# 2026-10-01. +# +# These files are the SAME corpus that 1-formats/deed/tools/deed_lint.py +# is tested against. If this crate's reader and deed_lint.py disagree about +# any file below, one of the two has become a second authority over the +# grammar, which the DEED spec does not permit. The ABNF at +# 1-formats/deed/spec/abnf/deed.abnf is the sole normative grammar (owner +# ruling 2026-09-19, standards#837). +# +# scrambled-priority_praxis.deed is NOT from the upstream corpus. It was +# written for launch-scaffolder; see its own header. +# +# Refresh procedure: re-copy from the path above, regenerate this file +# with sha256sum, and record the new standards commit. A changed digest +# with an unchanged commit means someone edited a vendored copy in place. +4f960dfe69498114672e70081c2d08b48147ab82f96ce8ea24f6801ad1d33113 valid/booleans-uuid_chora.deed +7f04ef2985f93b85e8d0d1b830b39de6180520f7f69df925b928b58b1b5ac687 valid/minimal_chora.deed +9cf6feb07a37e2eb7545c5f0a3e0638de1a22a521a8eaef30aec4b210264080e valid/nested_chora.deed +dfbb5207f1e7ce61e24c07ccaae10238f7b6dbe0c67f9f498a84d574739d0962 valid/quoted-list-symbols-007_chora.deed +12942b897ca8c51c26d8044b69092318c6cac0c66a1f76f655ef83ac11e4428b valid/rsr-template-repo_chora.deed +f47742749233ce005afcd82263833ac4f9d9330dc8a4ceca5847a604a082f3af valid/scrambled-priority_praxis.deed +d3ac53205f6cfb614b42cc251cdfcadcd792836b5491c834e5cdd24bb7a030b7 valid/updates-clause_chora.deed +ba03c83b292b8eff86e676454adbf16c91a309fafe373d8e56bd5c91da9bd2c7 invalid/inequals_chora.deed +0564a611ec2f6d116720010326d2640d7576f040a2ef9e5b93b8b0a755889340 invalid/inescape-u_chora.deed +a4cfd3d9f37de9e2df11e3f82e177ed32ffbcaabccd87a6a062cb023ea3a8afe invalid/inhead_chora.deed +c1dec8a3b95320c1bcc7232a2968f10ac249c1849aee9665bcf4552742fede44 invalid/inmissing-schema_chora.deed +64e043193b9cc2decbd12eb84ded1a2090b9f4ce9bc57003813ac82948de8945 invalid/inno-header_chora.deed +b491aaf831cd7e72dd89901e1d047ef7cbe58cbabf265c5750bab2a08a659e85 invalid/insection_chora.deed +eb3037013197dbb16d9a72cc26707634cc491fc3e1a9eac14037f7d6a4d01add invalid/intab_chora.deed +630550178930bbf0b79a7a9bf5acd91192c2e8145a1aec94228d21ae969eda6b invalid/intrailing_chora.deed +88cdca77410fbd1a80f04a699be19c9e8f72fbe022e694cc0c289dc329e9bf70 invalid/intrue-literal_chora.deed +f3856ebdb15114b03941af84398c921d08bb2a5b0b20e30d23f95b37ecd8c947 invalid/inunbalanced_chora.deed diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inequals_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inequals_chora.deed new file mode 100644 index 0000000..3183ae9 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inequals_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name = "x") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inescape-u_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inescape-u_chora.deed new file mode 100644 index 0000000..88d0bcf --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inescape-u_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" (m :s "bad \u0041")) diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inhead_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inhead_chora.deed new file mode 100644 index 0000000..24885fe --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inhead_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(chora-deed :schema-version "1.0.0") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inmissing-schema_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inmissing-schema_chora.deed new file mode 100644 index 0000000..2bcc354 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inmissing-schema_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :canonical-name "x") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inno-header_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inno-header_chora.deed new file mode 100644 index 0000000..42a8b8a --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inno-header_chora.deed @@ -0,0 +1 @@ +(repo-deed :schema-version "1.0.0") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/insection_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/insection_chora.deed new file mode 100644 index 0000000..e63678c --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/insection_chora.deed @@ -0,0 +1,4 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" +[status] +phase = "active") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/intab_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/intab_chora.deed new file mode 100644 index 0000000..96d7a70 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/intab_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0") diff --git a/rs/deed-read/tests/fixtures/deed/invalid/intrailing_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/intrailing_chora.deed new file mode 100644 index 0000000..424cc18 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/intrailing_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0") trailing diff --git a/rs/deed-read/tests/fixtures/deed/invalid/intrue-literal_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/intrue-literal_chora.deed new file mode 100644 index 0000000..6587f4f --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/intrue-literal_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" (status :present true)) diff --git a/rs/deed-read/tests/fixtures/deed/invalid/inunbalanced_chora.deed b/rs/deed-read/tests/fixtures/deed/invalid/inunbalanced_chora.deed new file mode 100644 index 0000000..8828b72 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/invalid/inunbalanced_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" (status :present #t) diff --git a/rs/deed-read/tests/fixtures/deed/valid/booleans-uuid_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/booleans-uuid_chora.deed new file mode 100644 index 0000000..02879d1 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/booleans-uuid_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" (status :present #t :ended #f :note "legal escapes: \n and \t and \\ and \"q\"")) diff --git a/rs/deed-read/tests/fixtures/deed/valid/minimal_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/minimal_chora.deed new file mode 100644 index 0000000..653eb24 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/minimal_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora") diff --git a/rs/deed-read/tests/fixtures/deed/valid/nested_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/nested_chora.deed new file mode 100644 index 0000000..36cb590 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/nested_chora.deed @@ -0,0 +1,5 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed + :schema-version "1.0.0" +:canonical-name "x" ; comment between + (lineage :type hub :parent "" :previous-names ()) ) diff --git a/rs/deed-read/tests/fixtures/deed/valid/quoted-list-symbols-007_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/quoted-list-symbols-007_chora.deed new file mode 100644 index 0000000..5787e80 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/quoted-list-symbols-007_chora.deed @@ -0,0 +1,2 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "not-string-issue" :other 007 :sym github-actions :q '(a b)) diff --git a/rs/deed-read/tests/fixtures/deed/valid/rsr-template-repo_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/rsr-template-repo_chora.deed new file mode 100644 index 0000000..92a54c4 --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/rsr-template-repo_chora.deed @@ -0,0 +1,119 @@ +;; SPDX-License-Identifier: MPL-2.0 +(repo-deed + :schema-version "1.0.0" + :canonical-name "rsr-template-repo" + :beholding-chora #u5"estate/chora" + + :repo-uuid #u5"github.com/hyperpolymath/rsr-template-repo" + + (identity :primary-forge github + :owner "hyperpolymath" + :prefixed-name "rm-rsr-template-repo") + + (clade :primary rm + :primary-name "Repo Management & Tooling" + :secondary () + :assigned "2026-03-16" + :rationale "Repo Management & Tooling (`rm`): the core value proposition is scaffolding repositories — CI/CD, AI manifests, ABI/FFI seams, governance — that other projects are instantiated from. `gv` was previously claimed as a secondary and is dropped: the template SHIPS governance files, but shipping them is not being a governance project. That authority lives in `standards` and `metadatastician-governance`. The inherited [\"gv\"] propagated into every repo created from this template, where it was even less meant.") + + (forges :github "hyperpolymath/rsr-template-repo" + :gitlab "hyperpolymath/rsr-template-repo" + :bitbucket "hyperpolymath/rsr-template-repo") + + (lineage :type standalone + :parent "" + :born "2026-03-16" + :previous-names () + :instantiated-from "") + + (status :phase active + :since "2026-03-16" + :present #t + :aliases () + :merged-into "" + :superseded-by "" + :successors () + :ended "" + (history + (entry :phase active + :since "2026-03-16" + :note "the estate's canonical repository template; in production use") + )) + + (meta + :version "0.1.0" + :last-updated "2026-04-11" + :type library + :languages () + :license MPL-2.0 + :author "Jonathan D.A. Jewell (hyperpolymath)" + :build-tool just + :container-runtime podman + :ci-platform github-actions + :package-manager guix + :scoping-first #t + :execution-order "axis-1 > axis-2 > axis-3" + :axis-1 "must > intend > like" + :axis-2 "corrective > adaptive > perfective" + :axis-3 "systems > compliance > effects" + (scoping + :sources "README, roadmap, status docs, maintenance checklist, CI/security docs" + :marker-scan "TODO/FIXME/XXX/HACK/STUB/PARTIAL" + :idris-unsound-scan "believe_me/assert_total") + :corrective-first #t + :adaptive-second #t + :adaptive-focus "scope-change reconciliation, stale-reference removal, obsolete-work culling" + :perfective-third #t + :perfective-source "axis-1 honest state after corrective/adaptive updates" + (axis-3 + :audit-focus "systems in place, documentation explains actual state, safety/security accounted for, observed effects reviewed" + :compliance-focus "seams/compromises/exception register, bounded exceptions, anti-drift checks" + :drift-risk-example "single exception broadening into policy violation (e.g. ->TypeScript spread)" + :effects-evidence "benchmark execution/results and maintainer status dialogue/review")) + + (ecosystem + :project "rsr-template-repo" + :ecosystem "hyperpolymath" + :position-type "repository-template" + :purpose "Canonical RSR-compliant repository template: scaffolding (CI/CD, AI manifests, ABI/FFI standards, container ecosystem, governance) that new hyperpolymath projects are instantiated from." + :not ("a project in its own right" "Scaffoldia (the full-featured repo designer)" "standards (the canon source this template operationalises)") + :pipeline-position "foundation" + :chain "standards → rsr-template-repo → (every estate repo)" + :pipeline-notes "rsr-template-repo turns the RSR standard into runnable scaffolding. New repos are created from it via `just repo-init`, which substitutes the {{PLACEHOLDER}} tokens." + :coordination standards + (related :name "standards" :relationship standard-source :notes "Defines the RSR standard, contractile canon, and policies that this template operationalises.") + (related :name "stapeln" :relationship build-tooling :notes "Layer-based container build system; the template ships stapeln.toml scaffolding.") + (related :name "selur-compose" :relationship build-tooling :notes "Service composition; the template ships selur-compose.toml scaffolding.") + (related :name "k9-svc" :relationship validation-tooling :notes "Runs the self-validating k9.ncl checks (.machine_readable/self-validating/).") + (related :name "cerro-torre" :relationship signing-tooling :notes "Container/image signing provider referenced by the container scaffolding.") + (related :name "svalinn" :relationship verification-tooling :notes "Supply-chain verification referenced by the container scaffolding.") + (related :name "vordr" :relationship verification-tooling :notes "Build/artifact verification referenced by the container scaffolding.")) + + (agentic + :version "0.1.0" :last-updated "2026-04-11" + (permissions :source #t + :tests #t + :docs #t + :config #t + :create-files #t) + (integrity :fail-closed #t + :require-evidence-per-step #t + :allow-silent-skip #f + :require-rerun-after-fix #t + :release-claim-requires-hard-pass #t) + (methodology :instructions-dir ".machine_readable/bot_directives/" :default-mode hybrid)) + + (neurosym + :version "0.1.0" :last-updated "2026-04-11" + (hypatia :scan-enabled #t + :scan-depth standard + :report-format "logtalk")) + + (playbook + :version "0.1.0" :last-updated "2026-04-11" + (skeleton :version "1.0" + :last-updated "2026-04-30" + :authority-allowlist ".machine_readable/root-allow.txt" + :enforcement-workflow ".github/workflows/estate-rules.yml") + (deployment) (incident-response) (release-process) (docs-format) (maintenance-operations)) +) diff --git a/rs/deed-read/tests/fixtures/deed/valid/scrambled-priority_praxis.deed b/rs/deed-read/tests/fixtures/deed/valid/scrambled-priority_praxis.deed new file mode 100644 index 0000000..6f43e5a --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/scrambled-priority_praxis.deed @@ -0,0 +1,43 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell +;; SPDX-License-Identifier: MPL-2.0 +;; +;; NOT part of the upstream standards corpus. Written for this crate. +;; +;; Purpose: kill the mutant that ignores `:priority`. +;; +;; The DEED grammar's closing note says order of fields and clauses is not +;; semantic, and that where precedence matters it is carried by an explicit +;; `:priority` INTEGER, never by file position. The real +;; launcher-standard_praxis.deed happens to list its ladder in ascending +;; priority order — so a reader that ignores `:priority` entirely and simply +;; iterates the file passes every test written against the real deed. +;; +;; This fixture is therefore deliberately scrambled: the three rungs appear in +;; FILE order 30, 10, 20, and any correct reader must return them as +;; 10, 20, 30. A reader that returns 30, 10, 20 has read the file instead of +;; reading the document. +;; +;; The two rungs at priority 20 are also deliberate: they prove the sort is +;; STABLE, so a tie keeps source order (beta before gamma) rather than +;; reordering unpredictably. +;; +;; The rung shape mirrors the real launcher-standard_praxis.deed: one +;; `:value` string carrying the whole path, `$VAR` prefixes included. +(praxis-deed + :schema-version "1.0.0" + :canonical-name "scrambled-priority" + + (resolution + (standard-search + (path + :priority 30 + :value "$THIRD/third.deed") + (path + :priority 10 + :value "$FIRST/first.deed") + (path + :priority 20 + :value "$SECOND_BETA/beta.deed") + (path + :priority 20 + :value "$SECOND_GAMMA/gamma.deed")))) diff --git a/rs/deed-read/tests/fixtures/deed/valid/updates-clause_chora.deed b/rs/deed-read/tests/fixtures/deed/valid/updates-clause_chora.deed new file mode 100644 index 0000000..309f13a --- /dev/null +++ b/rs/deed-read/tests/fixtures/deed/valid/updates-clause_chora.deed @@ -0,0 +1,13 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +; Exercises every term of the (updates ...) vocabulary: +; 1-formats/deed/vocabulary/updates.adoc +(repo-deed + :schema-version "1.0.0" + :canonical-name "updates-clause" + (updates + :enabled #t + :majors #f + :soak-days 7 + (hold :ecosystem cargo :package "tokio" :below "2.0.0" + :reason "needs the async-trait rework" :issue "#42" :until "2026-12-01") + (exclude :ecosystem npm))) diff --git a/rs/deed-read/tests/hub_conformance.rs b/rs/deed-read/tests/hub_conformance.rs new file mode 100644 index 0000000..d487061 --- /dev/null +++ b/rs/deed-read/tests/hub_conformance.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The reader against this hub's own `conformance/` deeds, and the +//! `(updates …)` fixture read end to end. +//! +//! `conformance/` also holds legacy `.a2ml` files; only `.deed` files are read. +//! Those fixtures are counted by `conformance/run-deed-tests.sh` too, which is +//! why the updates fixture lives under `tests/fixtures/` and not there. + +use std::path::{Path, PathBuf}; + +use deed_read::{parse, updates}; + +/// `conformance//*.deed` in the hub, sorted. +fn hub(sub: &str) -> Vec { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../conformance").join(sub); + let mut out: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("cannot list {}: {e}", dir.display())) + .map(|e| e.expect("dir entry").path()) + .filter(|p| p.extension().is_some_and(|x| x == "deed")) + .collect(); + out.sort(); + out +} + +/// Read a file or panic with its path. +fn read(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// Hub valid deeds parse. +#[test] +fn hub_valid_deeds_parse() { + let files = hub("valid"); + // run-deed-tests.sh asserts exactly 4; a smaller set means the path broke. + assert_eq!(files.len(), 4, "expected the 4 hub valid deeds, found {files:?}"); + for path in files { + parse(&read(&path)).unwrap_or_else(|e| panic!("{} should parse: {e:#}", path.display())); + } +} + +/// Hub invalid deeds are rejected. +#[test] +fn hub_invalid_deeds_are_rejected() { + let files = hub("invalid"); + assert_eq!(files.len(), 5, "expected the 5 hub invalid deeds, found {files:?}"); + for path in files { + assert!(parse(&read(&path)).is_err(), "{} should be rejected", path.display()); + } +} + +/// Updates fixture reads every term. +#[test] +fn updates_fixture_reads_every_term() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/deed/valid/updates-clause_chora.deed"); + let p = updates::from_path(&path).expect("fixture reads"); + assert!(p.enabled); + assert!(!p.majors, "fixture sets :majors #f; reading the default means the field was dropped"); + assert_eq!(p.soak_days, 7); + assert_eq!(p.holds.len(), 1); + let h = &p.holds[0]; + assert_eq!((h.ecosystem.as_str(), h.package.as_str(), h.below.as_str()), ("cargo", "tokio", "2.0.0")); + assert_eq!(h.issue.as_deref(), Some("#42")); + assert_eq!(h.until.map(|d| d.to_string()).as_deref(), Some("2026-12-01")); + assert!(p.excludes_ecosystem("npm")); +} + +/// Hub deeds without the clause get defaults. +#[test] +fn hub_deeds_without_the_clause_get_defaults() { + for path in hub("valid") { + assert_eq!( + updates::from_text(&read(&path)).expect("reads"), + updates::UpdatesPolicy::default(), + "{}", + path.display() + ); + } +}