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 <noreply@anthropic.com>
This commit is contained in:
parent
45dfab4ea8
commit
38bc538110
1 changed files with 113 additions and 17 deletions
|
|
@ -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<Label>, mutations: Vec<Label>[8],
|
||||
}
|
||||
|
||||
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef }
|
||||
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct
|
||||
pub struct PipelineTarget {
|
||||
campaign: Slug, revision: u32, invariants: Vec<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
|
||||
canonical_implementations: Vec<Line>[16], doc: DocRef,
|
||||
|
|
@ -1282,6 +1309,35 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// RS-L closing fix 4: `campaign.repos` refuses two entries naming one
|
||||
/// repository by identity, not only by raw spelling, so `GameCult/Epiphany`
|
||||
/// and `gamecult/epiphany` in one list refuse exactly as the same spelling
|
||||
/// twice would. Two genuinely distinct repos still validate.
|
||||
#[test]
|
||||
fn campaign_repos_refuse_duplicates_by_identity() {
|
||||
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
|
||||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("gamecult/epiphany".into())];
|
||||
assert_eq!(
|
||||
PipelineDocument::Campaign(campaign.clone()).validate(),
|
||||
Err(PipelineRefusal::DuplicateRepo {
|
||||
field: "campaign.repos".into(),
|
||||
repo: "gamecult/epiphany".into(),
|
||||
})
|
||||
);
|
||||
|
||||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 2];
|
||||
assert_eq!(
|
||||
PipelineDocument::Campaign(campaign.clone()).validate(),
|
||||
Err(PipelineRefusal::DuplicateRepo {
|
||||
field: "campaign.repos".into(),
|
||||
repo: "GameCult/Epiphany".into(),
|
||||
})
|
||||
);
|
||||
|
||||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Aetheria".into())];
|
||||
assert_eq!(PipelineDocument::Campaign(campaign).validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_fields_must_be_org_slash_repo() {
|
||||
let mut report = report_sample();
|
||||
|
|
@ -2448,6 +2504,7 @@ mod tests {
|
|||
key: format!("{CAMPAIGN}:campaign:forged"),
|
||||
expected: format!("{CAMPAIGN}:campaign:self"),
|
||||
},
|
||||
PipelineRefusal::DuplicateRepo { field: "campaign.repos".into(), repo: "gamecult/epiphany".into() },
|
||||
PipelineRefusal::ForeignStore { r#type: <ForeignDocument as DatabaseEntry>::TYPE.into() },
|
||||
];
|
||||
for refusal in &refusals {
|
||||
|
|
@ -2489,7 +2546,7 @@ mod tests {
|
|||
properties.keys().next().expect("one key").to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(named, vec!["FieldBound", "InvalidFormat", "InvalidIdentity", "ForeignStore"]);
|
||||
assert_eq!(named, vec!["FieldBound", "InvalidFormat", "InvalidIdentity", "DuplicateRepo", "ForeignStore"]);
|
||||
assert_eq!(named.len(), refusals.len(), "every refusal variant is in the schema and in the round trip");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2763,6 +2820,45 @@ mod tests {
|
|||
// repository wherever `OrgRepo` is compared, hashed or ordered.
|
||||
assert_eq!(OrgRepo::from("GameCult/Epiphany"), OrgRepo::from("gamecult/epiphany"));
|
||||
assert_eq!(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany")), std::cmp::Ordering::Equal);
|
||||
assert_eq!(
|
||||
OrgRepo::from("GameCult/Epiphany").partial_cmp(&OrgRepo::from("gamecult/epiphany")),
|
||||
Some(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany"))),
|
||||
"partial_cmp must agree with cmp for an equal pair"
|
||||
);
|
||||
|
||||
// RS-L closing fix 1: every assertion above compares values that
|
||||
// should be equal, so `eq -> true`, `hash -> ()` and `cmp -> Equal`
|
||||
// all survive as mutants. Pin the unequal direction as well: two
|
||||
// different repos must be unequal, hash differently (with
|
||||
// overwhelming probability, not by construction, so this is belt and
|
||||
// suspenders on top of the `!=` and `cmp` checks), and not compare
|
||||
// `Equal`, and `partial_cmp` must agree with `cmp` for an unequal
|
||||
// pair too, per std's own contract that `partial_cmp(a, b) ==
|
||||
// Some(cmp(a, b))`.
|
||||
let a = OrgRepo::from("GameCult/Epiphany");
|
||||
let b = OrgRepo::from("GameCult/Aetheria");
|
||||
assert_ne!(a, b, "two different repos must not compare equal");
|
||||
assert_ne!(
|
||||
a.cmp(&b),
|
||||
std::cmp::Ordering::Equal,
|
||||
"two different repos must not order as Equal"
|
||||
);
|
||||
assert_eq!(
|
||||
a.partial_cmp(&b),
|
||||
Some(a.cmp(&b)),
|
||||
"partial_cmp must agree with cmp for an unequal pair"
|
||||
);
|
||||
let hash_of = |repo: &OrgRepo| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
repo.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
};
|
||||
assert_ne!(
|
||||
hash_of(&a),
|
||||
hash_of(&b),
|
||||
"two different repos must hash differently"
|
||||
);
|
||||
|
||||
let mut hash_map = std::collections::HashMap::new();
|
||||
hash_map.insert(OrgRepo::from("GameCult/Epiphany"), 1);
|
||||
|
|
@ -2865,7 +2961,7 @@ mod tests {
|
|||
refused("\u{a0}"); // NBSP alone
|
||||
refused("\u{1f680}"); // emoji only
|
||||
|
||||
// S-3: every one of the eleven bidi control code points is pinned
|
||||
// S-3: every one of the twelve bidi control code points is pinned
|
||||
// with an embedded fixture (real alphanumeric text on both sides), so
|
||||
// the alphanumeric rule cannot mask a hole in the bidi match arm the
|
||||
// way a lone-character fixture would.
|
||||
|
|
|
|||
Reference in a new issue