From 38bc538110cfc5073be944960355cd00445b7ab1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:18:24 +0200 Subject: [PATCH] Close RS-L's four fixes: OrgRepo unequal-direction pins, schema description split, count fix, campaign.repos duplicate refusal Soul's third RS-L pass ruled the leaf's five promises hold but found four closing items (soul-e20-notes.md, 2026-09-22): 1. Every OrgRepo Eq/Hash/Ord/PartialOrd assertion compared values that should already be equal, so eq -> true, hash -> (), cmp -> Equal, partial_cmp -> None and partial_cmp -> self.0.partial_cmp(&other.0) all survived as mutants. Add the unequal direction (assert_ne! on two different repos, differing hashes, a non-Equal ordering) and std's partial_cmp(a,b) == Some(cmp(a,b)) contract for both an equal and an unequal pair. 2. The published OrgRepo schema description carried ~1,100 characters of Rust-macro justification into nine contracts. Split the doc comment: the grammar, the 140-byte bound and the case-insensitive identity stay in the published /// doc comment; the bounded_text! delegation reasoning moves to a plain // source comment, so it stops reaching schemars. 3. lib.rs said "eleven bidi control code points" above twelve fixtures (ALM, LRM, RLM, LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI). Say twelve. 4. campaign.repos admitted both spellings of one repository. Add PipelineRefusal::DuplicateRepo and a campaign-only validation hook (value_types!'s new optional `=> extra` clause) that refuses two entries sharing an OrgRepo identity; distinct repos still validate. This commit is the source half; the nine published schemas are regenerated in the next commit to match fix 2. Co-Authored-By: Claude Sonnet 5 --- epiphany-pipeline/src/lib.rs | 130 ++++++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/epiphany-pipeline/src/lib.rs b/epiphany-pipeline/src/lib.rs index 39e79e32..e8697753 100644 --- a/epiphany-pipeline/src/lib.rs +++ b/epiphany-pipeline/src/lib.rs @@ -60,6 +60,7 @@ pub enum PipelineRefusal { FieldBound { field: String, limit: u32, actual: u32 }, InvalidFormat { field: String, value: String }, InvalidIdentity { kind: PipelineKind, key: String, expected: String }, + DuplicateRepo { field: String, repo: String }, ForeignStore { r#type: String }, } @@ -275,19 +276,24 @@ impl Slug { /// 100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or /// trailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never /// `.` or `..`, and does not end in `.git`, ASCII-case-insensitively. -/// -/// The raw string is display-only. `OrgRepo` is not generated by -/// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd` -/// and `Ord` on the raw string, with no per-member override, so a type built -/// from it compares and keys on spelling. `OrgRepo` instead carries -/// hand-written impls of those five traits, every one of them over -/// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a -/// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany` -/// and `gamecult/epiphany` compare equal, hash equal, order together and -/// collapse to one map entry, and a caller cannot reach the case-sensitive -/// behaviour by accident. `Hash` and `Eq` agree because both are defined over -/// the same `identity()` string. Every other bounded type keeps the macro's -/// derives untouched. +/// Identity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany` +/// are the same repository. +// +// The raw string is display-only. `OrgRepo` is not generated by +// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd` +// and `Ord` on the raw string, with no per-member override, so a type built +// from it compares and keys on spelling. `OrgRepo` instead carries +// hand-written impls of those five traits, every one of them over +// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a +// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany` +// and `gamecult/epiphany` compare equal, hash equal, order together and +// collapse to one map entry, and a caller cannot reach the case-sensitive +// behaviour by accident. `Hash` and `Eq` agree because both are defined over +// the same `identity()` string. Every other bounded type keeps the macro's +// derives untouched. This reasoning is a source comment, not a doc comment, +// so it does not enter the published schema description (RS-L closing fix 2): +// a consumer needs the grammar, the bound and the case-insensitivity, not the +// Rust-macro justification for how that got built. #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] #[serde(transparent)] #[schemars(extend("maxLength" = 140))] @@ -392,7 +398,7 @@ macro_rules! bounded_field { /// The single field list: one invocation emits the value struct and its /// `Bounded` impl, so a field can never be left out of validation. macro_rules! value_types { - ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? })*) => {$( + ($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])?),* $(,)? } $(=> $extra:path)?)*) => {$( $(#[$attr])* #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct $name { @@ -405,6 +411,7 @@ macro_rules! value_types { impl Bounded for $name { fn validate(&self, at: &str) -> Result<(), PipelineRefusal> { $(bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);)* + $($extra(self, at)?;)? Ok(()) } } @@ -423,6 +430,26 @@ macro_rules! unit_enums { )*}; } +/// RS-L closing fix 4: `campaign.repos` refuses two entries naming the same +/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and +/// `gamecult/epiphany` in one list refuse exactly as one entry twice would. +/// This is a cross-element invariant on `PipelineCampaign` alone, not a +/// per-type format rule any other `value_types!` struct shares, so it is +/// wired in as that struct's one `=> extra` hook rather than folded into +/// `Bounded for OrgRepo` or the list bound in `bounded_field!`. +fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> { + let mut seen = std::collections::HashSet::new(); + for repo in &campaign.repos { + if !seen.insert(repo.identity()) { + return Err(PipelineRefusal::DuplicateRepo { + field: format!("{at}.repos"), + repo: repo.0.clone(), + }); + } + } + Ok(()) +} + unit_enums! { EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture } ClaimOutcome { Holds, Falsified, Unproven } @@ -480,7 +507,7 @@ value_types! { promise: Option