commit 431901f8c5524948c02946651bede377494f7aef
parent 8c3c1496ff6db8b4fa10b261fa04539341469df8
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Feb 2026 03:10:12 +0000
coverage: add workspace rollout parity guard
- expand coverage rollout contract to include every workspace crate entry
- validate rollout names against workspace package names in xtask contract checks
- enforce required list parity with rollout required statuses and contiguous order fields
- add xtask tests for rollout workspace parity and required status alignment
Diffstat:
2 files changed, 330 insertions(+), 1 deletion(-)
diff --git a/contract/coverage/rollout.toml b/contract/coverage/rollout.toml
@@ -52,3 +52,98 @@ order = 8
name = "xtask"
status = "planned"
order = 9
+
+[[rollout.crates]]
+name = "radroots-app-core"
+status = "planned"
+order = 10
+
+[[rollout.crates]]
+name = "radroots-app-ffi-swift"
+status = "planned"
+order = 11
+
+[[rollout.crates]]
+name = "radroots-app-wasm"
+status = "planned"
+order = 12
+
+[[rollout.crates]]
+name = "radroots-events-indexed"
+status = "planned"
+order = 13
+
+[[rollout.crates]]
+name = "radroots-log"
+status = "planned"
+order = 14
+
+[[rollout.crates]]
+name = "radroots-net-core"
+status = "planned"
+order = 15
+
+[[rollout.crates]]
+name = "radroots-net"
+status = "planned"
+order = 16
+
+[[rollout.crates]]
+name = "radroots-nostr"
+status = "planned"
+order = 17
+
+[[rollout.crates]]
+name = "radroots-nostr-accounts"
+status = "planned"
+order = 18
+
+[[rollout.crates]]
+name = "radroots-nostr-ndb"
+status = "planned"
+order = 19
+
+[[rollout.crates]]
+name = "radroots-nostr-runtime"
+status = "planned"
+order = 20
+
+[[rollout.crates]]
+name = "radroots-runtime"
+status = "planned"
+order = 21
+
+[[rollout.crates]]
+name = "radroots-sql-core"
+status = "planned"
+order = 22
+
+[[rollout.crates]]
+name = "radroots-sql-wasm-core"
+status = "planned"
+order = 23
+
+[[rollout.crates]]
+name = "radroots-sql-wasm-bridge"
+status = "planned"
+order = 24
+
+[[rollout.crates]]
+name = "radroots-tangle-events"
+status = "planned"
+order = 25
+
+[[rollout.crates]]
+name = "radroots-tangle-db"
+status = "planned"
+order = 26
+
+[[rollout.crates]]
+name = "radroots-tangle-events-wasm"
+status = "planned"
+order = 27
+
+[[rollout.crates]]
+name = "radroots-tangle-db-wasm"
+status = "planned"
+order = 28
diff --git a/crates/xtask/src/contract.rs b/crates/xtask/src/contract.rs
@@ -1,7 +1,7 @@
#![forbid(unsafe_code)]
use serde::Deserialize;
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
@@ -89,6 +89,53 @@ pub struct ContractBundle {
pub exports: Vec<ExportMapping>,
}
+#[derive(Debug, Deserialize)]
+struct WorkspaceCargoManifest {
+ workspace: WorkspaceSection,
+}
+
+#[derive(Debug, Deserialize)]
+struct WorkspaceSection {
+ members: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct PackageCargoManifest {
+ package: PackageSection,
+}
+
+#[derive(Debug, Deserialize)]
+struct PackageSection {
+ name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct CoverageRolloutFile {
+ rollout: CoverageRolloutSection,
+}
+
+#[derive(Debug, Deserialize)]
+struct CoverageRolloutSection {
+ crates: Vec<CoverageRolloutCrate>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CoverageRolloutCrate {
+ name: String,
+ status: String,
+ order: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct CoverageRequiredFile {
+ required: CoverageRequiredSection,
+}
+
+#[derive(Debug, Deserialize)]
+struct CoverageRequiredSection {
+ crates: Vec<String>,
+}
+
fn parse_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> {
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
toml::from_str::<T>(&raw).map_err(|e| format!("parse {}: {e}", path.display()))
@@ -98,6 +145,30 @@ fn contract_root(workspace_root: &Path) -> PathBuf {
workspace_root.join("contract")
}
+fn workspace_package_names(workspace_root: &Path) -> Result<Vec<String>, String> {
+ let workspace_manifest =
+ parse_toml::<WorkspaceCargoManifest>(&workspace_root.join("Cargo.toml"))?;
+ let mut names = Vec::with_capacity(workspace_manifest.workspace.members.len());
+ for member in workspace_manifest.workspace.members {
+ let member_manifest = workspace_root.join(member).join("Cargo.toml");
+ let package_manifest = parse_toml::<PackageCargoManifest>(&member_manifest)?;
+ names.push(package_manifest.package.name);
+ }
+ Ok(names)
+}
+
+fn load_coverage_rollout(contract_root: &Path) -> Result<CoverageRolloutFile, String> {
+ parse_toml::<CoverageRolloutFile>(&contract_root.join("coverage").join("rollout.toml"))
+}
+
+fn load_coverage_required(contract_root: &Path) -> Result<CoverageRequiredFile, String> {
+ parse_toml::<CoverageRequiredFile>(&contract_root.join("coverage").join("required-crates.toml"))
+}
+
+fn join_set(items: &BTreeSet<String>) -> String {
+ items.iter().cloned().collect::<Vec<_>>().join(", ")
+}
+
const CORE_UNIT_DIMENSION_ENUM: &str = "RadrootsCoreUnitDimension";
const CORE_UNIT_DIMENSION_ORDER: [&str; 3] = ["Count", "Mass", "Volume"];
@@ -187,6 +258,130 @@ fn validate_core_unit_dimension_variant_order(workspace_root: &Path) -> Result<(
Ok(())
}
+fn validate_coverage_rollout_parity(
+ workspace_root: &Path,
+ contract_root: &Path,
+) -> Result<(), String> {
+ let workspace_packages = workspace_package_names(workspace_root)?
+ .into_iter()
+ .collect::<BTreeSet<_>>();
+ let rollout = load_coverage_rollout(contract_root)?;
+ if rollout.rollout.crates.is_empty() {
+ return Err("coverage rollout crates list must not be empty".to_string());
+ }
+ let mut rollout_packages = BTreeSet::new();
+ let mut rollout_status = BTreeMap::new();
+ let mut orders = Vec::with_capacity(rollout.rollout.crates.len());
+ for entry in &rollout.rollout.crates {
+ if !matches!(entry.status.as_str(), "required" | "planned") {
+ return Err(format!(
+ "coverage rollout status must be required or planned for {}",
+ entry.name
+ ));
+ }
+ if !rollout_packages.insert(entry.name.clone()) {
+ return Err(format!("duplicate coverage rollout crate {}", entry.name));
+ }
+ rollout_status.insert(entry.name.clone(), entry.status.clone());
+ orders.push(entry.order);
+ }
+ let mut sorted_orders = orders;
+ sorted_orders.sort_unstable();
+ for (index, order) in sorted_orders.iter().enumerate() {
+ let expected = (index + 1) as u32;
+ if *order != expected {
+ return Err(format!(
+ "coverage rollout order must be contiguous from 1; expected {expected} but found {order}"
+ ));
+ }
+ }
+
+ if workspace_packages != rollout_packages {
+ let missing = workspace_packages
+ .difference(&rollout_packages)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let extra = rollout_packages
+ .difference(&workspace_packages)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ if !missing.is_empty() {
+ return Err(format!(
+ "coverage rollout missing workspace crates: {}",
+ join_set(&missing)
+ ));
+ }
+ if !extra.is_empty() {
+ return Err(format!(
+ "coverage rollout includes unknown crates: {}",
+ join_set(&extra)
+ ));
+ }
+ }
+
+ let required = load_coverage_required(contract_root)?;
+ if required.required.crates.is_empty() {
+ return Err("coverage required crates list must not be empty".to_string());
+ }
+ let mut required_set = BTreeSet::new();
+ for crate_name in &required.required.crates {
+ if !required_set.insert(crate_name.clone()) {
+ return Err(format!("duplicate coverage required crate {}", crate_name));
+ }
+ if !workspace_packages.contains(crate_name) {
+ return Err(format!(
+ "coverage required crate is not a workspace crate: {}",
+ crate_name
+ ));
+ }
+ match rollout_status.get(crate_name) {
+ Some(status) if status == "required" => {}
+ Some(status) => {
+ return Err(format!(
+ "coverage required crate {} must have rollout status required, found {}",
+ crate_name, status
+ ));
+ }
+ None => {
+ return Err(format!(
+ "coverage required crate {} missing from rollout",
+ crate_name
+ ));
+ }
+ }
+ }
+
+ let rollout_required = rollout_status
+ .iter()
+ .filter(|(_, status)| *status == "required")
+ .map(|(name, _)| name.clone())
+ .collect::<BTreeSet<_>>();
+ if rollout_required != required_set {
+ let missing = rollout_required
+ .difference(&required_set)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let extra = required_set
+ .difference(&rollout_required)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ if !missing.is_empty() {
+ return Err(format!(
+ "coverage required list missing rollout required crates: {}",
+ join_set(&missing)
+ ));
+ }
+ if !extra.is_empty() {
+ return Err(format!(
+ "coverage required list has crates without rollout required status: {}",
+ join_set(&extra)
+ ));
+ }
+ }
+
+ Ok(())
+}
+
pub fn load_contract_bundle(workspace_root: &Path) -> Result<ContractBundle, String> {
let root = contract_root(workspace_root);
let manifest = parse_toml::<ContractManifest>(&root.join("manifest.toml"))?;
@@ -309,6 +504,7 @@ pub fn validate_contract_bundle(bundle: &ContractBundle) -> Result<(), String> {
.parent()
.ok_or_else(|| "failed to resolve workspace root from contract root".to_string())?;
validate_core_unit_dimension_variant_order(workspace_root)?;
+ validate_coverage_rollout_parity(workspace_root, &bundle.root)?;
Ok(())
}
@@ -422,4 +618,42 @@ pub enum RadrootsCoreUnitDimension {
.collect::<Vec<_>>();
assert_ne!(variants, expected);
}
+
+ #[test]
+ fn coverage_rollout_includes_workspace_crates() {
+ let root = workspace_root();
+ let workspace_names = workspace_package_names(&root)
+ .expect("workspace crates")
+ .into_iter()
+ .collect::<BTreeSet<_>>();
+ let rollout = load_coverage_rollout(&root.join("contract")).expect("coverage rollout");
+ let rollout_names = rollout
+ .rollout
+ .crates
+ .iter()
+ .map(|entry| entry.name.clone())
+ .collect::<BTreeSet<_>>();
+ assert_eq!(workspace_names, rollout_names);
+ }
+
+ #[test]
+ fn coverage_required_crates_match_rollout_required_status() {
+ let root = workspace_root();
+ let contract_root = root.join("contract");
+ let required = load_coverage_required(&contract_root).expect("coverage required");
+ let required_names = required
+ .required
+ .crates
+ .into_iter()
+ .collect::<BTreeSet<_>>();
+ let rollout = load_coverage_rollout(&contract_root).expect("coverage rollout");
+ let rollout_required = rollout
+ .rollout
+ .crates
+ .iter()
+ .filter(|entry| entry.status == "required")
+ .map(|entry| entry.name.clone())
+ .collect::<BTreeSet<_>>();
+ assert_eq!(required_names, rollout_required);
+ }
}