commit a95445b181e108a35e2d524798201cd89919c0ce
parent 3b6767ef28eae443cb29a62a88ccd649b2977274
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 11:33:32 +0000
architecture: enforce package dependency direction
- encode the synchronized seven-tier public package policy
- resolve dependency identities independently of manifest aliases
- report exact features targets and dependency kinds for upward edges
- expose focused fixtures and a repository-owned boundary command
Diffstat:
8 files changed, 1010 insertions(+), 1 deletion(-)
diff --git a/contracts/releases/package_tiers.toml b/contracts/releases/package_tiers.toml
@@ -0,0 +1,78 @@
+schema_version = 1
+spec_id = "radroots.crates.release.v1"
+direction = "same_or_lower"
+enforced_dependency_kinds = ["normal", "dev", "build"]
+
+[[tier]]
+id = "foundation"
+rank = 0
+packages = [
+ "radroots-core",
+ "radroots-identity",
+ "radroots-blossom",
+ "radroots-protocol",
+]
+
+[[tier]]
+id = "domain"
+rank = 1
+packages = ["radroots-event", "radroots-event-codec", "radroots-trade"]
+
+[[tier]]
+id = "spi"
+rank = 2
+packages = [
+ "radroots-signing",
+ "radroots-transport",
+ "radroots-secrets",
+ "radroots-storage",
+]
+
+[[tier]]
+id = "adapter"
+rank = 3
+packages = [
+ "radroots-nostr",
+ "radroots-nostr-connect",
+ "radroots-storage-sqlite",
+ "radroots-transport-nostr",
+ "radroots-geonames",
+]
+
+[[tier]]
+id = "orchestration"
+rank = 4
+packages = ["radroots-sync"]
+
+[[tier]]
+id = "sdk"
+rank = 5
+packages = ["radroots-sdk"]
+
+[[tier]]
+id = "facade"
+rank = 6
+packages = ["radroots"]
+
+# These exact development-only edges predate the final trade boundary and are
+# removed by the manifest-alignment checkpoint. Normal, build, target, or
+# feature-broadened forms remain forbidden.
+[[temporary_exception]]
+owner = "radroots-trade"
+dependency = "radroots-nostr"
+kind = "dev"
+target = "all"
+features = ["events", "std"]
+uses_default_features = false
+removal_step = 88
+rationale = "Existing trade conformance tests consume Nostr vectors until the Step 088 package boundary alignment."
+
+[[temporary_exception]]
+owner = "radroots-trade"
+dependency = "radroots-transport"
+kind = "dev"
+target = "all"
+features = []
+uses_default_features = false
+removal_step = 88
+rationale = "Existing trade workflow tests use transport test types until the Step 088 package boundary alignment."
diff --git a/tools/xtask/src/architecture.rs b/tools/xtask/src/architecture.rs
@@ -6,6 +6,8 @@ use std::{
use serde::Deserialize;
+mod dependency_boundary;
+
const DEVIATIONS_RELATIVE: &str = "docs/implementation/deviations.toml";
const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml";
const ARCHITECTURE_ID: &str = "radroots.crates.release.v1";
@@ -151,6 +153,12 @@ pub fn validate(workspace_root: &Path) -> Result<(), String> {
let architecture = toml::from_str::<ArchitectureIdentity>(&architecture_raw)
.map_err(|error| format!("parse {}: {error}", architecture_path.display()))?;
+ let architecture_packages = architecture
+ .package
+ .iter()
+ .map(|package| package.name.clone())
+ .collect::<BTreeSet<_>>();
+ dependency_boundary::validate_policy_catalog(workspace_root, &architecture_packages)?;
validate_workspace_toolchain(workspace_root, &architecture)?;
validate_public_package_metadata(workspace_root, &architecture)?;
validate_no_production_sibling_paths(workspace_root)?;
@@ -162,6 +170,11 @@ pub fn validate(workspace_root: &Path) -> Result<(), String> {
validate_ledger(workspace_root, &architecture.spec_id, &ledger_raw)
}
+pub fn validate_dependency_boundaries(workspace_root: &Path) -> Result<(), String> {
+ validate(workspace_root)?;
+ dependency_boundary::validate_resolved_boundaries(workspace_root)
+}
+
fn validate_workspace_toolchain(
workspace_root: &Path,
architecture: &ArchitectureIdentity,
diff --git a/tools/xtask/src/architecture/dependency_boundary.rs b/tools/xtask/src/architecture/dependency_boundary.rs
@@ -0,0 +1,718 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::Path,
+ process::Command,
+};
+
+use serde::Deserialize;
+
+const PACKAGE_TIERS_RELATIVE: &str = "contracts/releases/package_tiers.toml";
+const SPEC_ID: &str = "radroots.crates.release.v1";
+const DIRECTION: &str = "same_or_lower";
+const POLICY_SCHEMA_VERSION: u16 = 1;
+const CURRENT_STEP: u16 = 24;
+const EXPECTED_TIERS: &[(&str, u8)] = &[
+ ("foundation", 0),
+ ("domain", 1),
+ ("spi", 2),
+ ("adapter", 3),
+ ("orchestration", 4),
+ ("sdk", 5),
+ ("facade", 6),
+];
+const EXPECTED_DEPENDENCY_KINDS: &[&str] = &["build", "dev", "normal"];
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct DependencyBoundaryPolicy {
+ schema_version: u16,
+ spec_id: String,
+ direction: String,
+ enforced_dependency_kinds: Vec<String>,
+ tier: Vec<PackageTier>,
+ #[serde(default)]
+ temporary_exception: Vec<TemporaryException>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct PackageTier {
+ id: String,
+ rank: u8,
+ packages: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct TemporaryException {
+ owner: String,
+ dependency: String,
+ kind: String,
+ target: String,
+ features: Vec<String>,
+ uses_default_features: bool,
+ removal_step: u16,
+ rationale: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoMetadata {
+ packages: Vec<CargoPackage>,
+ resolve: Option<CargoResolve>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoPackage {
+ id: String,
+ name: String,
+ #[serde(default)]
+ dependencies: Vec<CargoDependency>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoDependency {
+ name: String,
+ #[serde(default)]
+ kind: Option<String>,
+ #[serde(default)]
+ features: Vec<String>,
+ #[serde(default)]
+ target: Option<String>,
+ #[serde(default = "default_true")]
+ uses_default_features: bool,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoResolve {
+ nodes: Vec<CargoNode>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoNode {
+ id: String,
+ #[serde(default)]
+ deps: Vec<CargoNodeDependency>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoNodeDependency {
+ name: String,
+ pkg: String,
+ #[serde(default)]
+ dep_kinds: Vec<CargoDependencyKind>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CargoDependencyKind {
+ #[serde(default)]
+ kind: Option<String>,
+ #[serde(default)]
+ target: Option<String>,
+}
+
+#[derive(Clone, Copy)]
+struct PackagePlacement<'a> {
+ tier: &'a str,
+ rank: u8,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+pub(super) fn validate_policy_catalog(
+ workspace_root: &Path,
+ expected_packages: &BTreeSet<String>,
+) -> Result<(), String> {
+ let policy = load_policy(workspace_root)?;
+ validate_policy(&policy, expected_packages).map(|_| ())
+}
+
+pub(super) fn validate_resolved_boundaries(workspace_root: &Path) -> Result<(), String> {
+ let policy = load_policy(workspace_root)?;
+ let expected_packages = policy
+ .tier
+ .iter()
+ .flat_map(|tier| tier.packages.iter().cloned())
+ .collect::<BTreeSet<_>>();
+ let placements = validate_policy(&policy, &expected_packages)?;
+ let metadata = load_metadata(workspace_root)?;
+ validate_metadata(&policy, &placements, &metadata)
+}
+
+fn load_policy(workspace_root: &Path) -> Result<DependencyBoundaryPolicy, String> {
+ let path = workspace_root.join(PACKAGE_TIERS_RELATIVE);
+ let raw =
+ fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?;
+ toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display()))
+}
+
+fn validate_policy<'a>(
+ policy: &'a DependencyBoundaryPolicy,
+ expected_packages: &BTreeSet<String>,
+) -> Result<BTreeMap<&'a str, PackagePlacement<'a>>, String> {
+ if policy.schema_version != POLICY_SCHEMA_VERSION {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}"
+ ));
+ }
+ if policy.spec_id != SPEC_ID {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} spec_id must be {SPEC_ID}"
+ ));
+ }
+ if policy.direction != DIRECTION {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} direction must be {DIRECTION}"
+ ));
+ }
+
+ let actual_kinds = unique_strings(
+ "enforced_dependency_kinds",
+ &policy.enforced_dependency_kinds,
+ )?;
+ let expected_kinds = EXPECTED_DEPENDENCY_KINDS
+ .iter()
+ .copied()
+ .collect::<BTreeSet<_>>();
+ if actual_kinds != expected_kinds {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} enforced_dependency_kinds must be build, dev, normal"
+ ));
+ }
+
+ let actual_tiers = policy
+ .tier
+ .iter()
+ .map(|tier| (tier.id.as_str(), tier.rank))
+ .collect::<Vec<_>>();
+ if actual_tiers != EXPECTED_TIERS {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} tiers must match the approved ordered architecture"
+ ));
+ }
+
+ let mut placements = BTreeMap::new();
+ for tier in &policy.tier {
+ if tier.packages.is_empty() {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} tier {} must allocate at least one package",
+ tier.id
+ ));
+ }
+ for package in &tier.packages {
+ if placements
+ .insert(
+ package.as_str(),
+ PackagePlacement {
+ tier: &tier.id,
+ rank: tier.rank,
+ },
+ )
+ .is_some()
+ {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} package {package} is allocated more than once"
+ ));
+ }
+ }
+ }
+
+ let actual_packages = placements
+ .keys()
+ .map(|package| (*package).to_owned())
+ .collect::<BTreeSet<_>>();
+ if &actual_packages != expected_packages {
+ return Err(package_set_mismatch(expected_packages, &actual_packages));
+ }
+
+ let mut exception_keys = BTreeSet::new();
+ for exception in &policy.temporary_exception {
+ let owner = placements.get(exception.owner.as_str()).ok_or_else(|| {
+ format!(
+ "{PACKAGE_TIERS_RELATIVE} exception owner {} is not an approved package",
+ exception.owner
+ )
+ })?;
+ let dependency = placements
+ .get(exception.dependency.as_str())
+ .ok_or_else(|| {
+ format!(
+ "{PACKAGE_TIERS_RELATIVE} exception dependency {} is not an approved package",
+ exception.dependency
+ )
+ })?;
+ if dependency.rank <= owner.rank {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} does not describe an upward edge",
+ exception.owner, exception.dependency
+ ));
+ }
+ if !expected_kinds.contains(exception.kind.as_str()) {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} uses unknown dependency kind {}",
+ exception.owner, exception.dependency, exception.kind
+ ));
+ }
+ if exception.target.trim().is_empty() {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must name its exact target",
+ exception.owner, exception.dependency
+ ));
+ }
+ let normalized_features = normalized_features(&exception.features);
+ if normalized_features.len() != exception.features.len()
+ || normalized_features.iter().ne(exception.features.iter())
+ {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} features must be sorted and unique",
+ exception.owner, exception.dependency
+ ));
+ }
+ if exception.removal_step <= CURRENT_STEP {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} expired at Step {}",
+ exception.owner, exception.dependency, exception.removal_step
+ ));
+ }
+ if exception.rationale.trim().is_empty() {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must include a rationale",
+ exception.owner, exception.dependency
+ ));
+ }
+ let key = (
+ exception.owner.as_str(),
+ exception.dependency.as_str(),
+ exception.kind.as_str(),
+ exception.target.as_str(),
+ exception.features.clone(),
+ exception.uses_default_features,
+ );
+ if !exception_keys.insert(key) {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} contains a duplicate exception for {} -> {}",
+ exception.owner, exception.dependency
+ ));
+ }
+ }
+
+ Ok(placements)
+}
+
+fn unique_strings<'a>(label: &str, values: &'a [String]) -> Result<BTreeSet<&'a str>, String> {
+ let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>();
+ if unique.len() != values.len() {
+ return Err(format!(
+ "{PACKAGE_TIERS_RELATIVE} {label} must not contain duplicates"
+ ));
+ }
+ Ok(unique)
+}
+
+fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String {
+ let missing = expected
+ .difference(actual)
+ .cloned()
+ .collect::<Vec<_>>()
+ .join(", ");
+ let extra = actual
+ .difference(expected)
+ .cloned()
+ .collect::<Vec<_>>()
+ .join(", ");
+ format!("{PACKAGE_TIERS_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}")
+}
+
+fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> {
+ let output = Command::new("cargo")
+ .args([
+ "metadata",
+ "--format-version",
+ "1",
+ "--locked",
+ "--all-features",
+ ])
+ .current_dir(workspace_root)
+ .output()
+ .map_err(|error| format!("run cargo metadata: {error}"))?;
+ if !output.status.success() {
+ return Err(format!(
+ "cargo metadata failed while checking dependency boundaries: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ ));
+ }
+ serde_json::from_slice(&output.stdout)
+ .map_err(|error| format!("parse cargo metadata for dependency boundaries: {error}"))
+}
+
+fn validate_metadata(
+ policy: &DependencyBoundaryPolicy,
+ placements: &BTreeMap<&str, PackagePlacement<'_>>,
+ metadata: &CargoMetadata,
+) -> Result<(), String> {
+ let resolve = metadata
+ .resolve
+ .as_ref()
+ .ok_or_else(|| "cargo metadata did not include a resolved dependency graph".to_owned())?;
+ let mut packages_by_id = BTreeMap::new();
+ for package in &metadata.packages {
+ if packages_by_id
+ .insert(package.id.as_str(), package)
+ .is_some()
+ {
+ return Err(format!(
+ "cargo metadata contains duplicate package id {}",
+ package.id
+ ));
+ }
+ }
+
+ let mut nodes_by_id = BTreeMap::new();
+ for node in &resolve.nodes {
+ if nodes_by_id.insert(node.id.as_str(), node).is_some() {
+ return Err(format!(
+ "cargo metadata contains duplicate resolve node {}",
+ node.id
+ ));
+ }
+ }
+
+ let enforced_kinds = policy
+ .enforced_dependency_kinds
+ .iter()
+ .map(String::as_str)
+ .collect::<BTreeSet<_>>();
+ let mut violations = Vec::new();
+
+ for node in &resolve.nodes {
+ let Some(owner_package) = packages_by_id.get(node.id.as_str()).copied() else {
+ return Err(format!(
+ "cargo metadata resolve node {} has no package record",
+ node.id
+ ));
+ };
+ let Some(owner_placement) = placements.get(owner_package.name.as_str()).copied() else {
+ continue;
+ };
+
+ for resolved_dependency in &node.deps {
+ let dependency_package = packages_by_id
+ .get(resolved_dependency.pkg.as_str())
+ .copied()
+ .ok_or_else(|| {
+ format!(
+ "cargo metadata resolved dependency {} has no package record",
+ resolved_dependency.pkg
+ )
+ })?;
+ let Some(dependency_placement) =
+ placements.get(dependency_package.name.as_str()).copied()
+ else {
+ continue;
+ };
+ if resolved_dependency.dep_kinds.is_empty() {
+ return Err(format!(
+ "cargo metadata edge {} -> {} has no dependency kind",
+ owner_package.name, dependency_package.name
+ ));
+ }
+
+ for dependency_kind in &resolved_dependency.dep_kinds {
+ let kind = normalized_kind(dependency_kind.kind.as_deref());
+ if !enforced_kinds.contains(kind) {
+ continue;
+ }
+ let declaration =
+ resolve_declaration(owner_package, dependency_package, dependency_kind)?;
+ if dependency_placement.rank <= owner_placement.rank {
+ continue;
+ }
+ if exception_matches(
+ &policy.temporary_exception,
+ owner_package,
+ dependency_package,
+ dependency_kind,
+ declaration,
+ ) {
+ continue;
+ }
+ violations.push(format_violation(
+ owner_package,
+ owner_placement,
+ dependency_package,
+ dependency_placement,
+ resolved_dependency,
+ dependency_kind,
+ declaration,
+ ));
+ }
+ }
+ }
+
+ if violations.is_empty() {
+ Ok(())
+ } else {
+ violations.sort();
+ Err(format!(
+ "forbidden package dependency direction(s):\n{}",
+ violations.join("\n")
+ ))
+ }
+}
+
+fn resolve_declaration<'a>(
+ owner: &'a CargoPackage,
+ dependency: &CargoPackage,
+ dependency_kind: &CargoDependencyKind,
+) -> Result<&'a CargoDependency, String> {
+ let kind = normalized_kind(dependency_kind.kind.as_deref());
+ let candidates = owner
+ .dependencies
+ .iter()
+ .filter(|candidate| {
+ candidate.name == dependency.name
+ && normalized_kind(candidate.kind.as_deref()) == kind
+ && candidate.target == dependency_kind.target
+ })
+ .collect::<Vec<_>>();
+ match candidates.as_slice() {
+ [declaration] => Ok(*declaration),
+ [] => Err(format!(
+ "cargo metadata edge {} -> {} kind={kind} target={} has no matching package dependency declaration",
+ owner.name,
+ dependency.name,
+ target_label(dependency_kind.target.as_deref())
+ )),
+ _ => Err(format!(
+ "cargo metadata edge {} -> {} kind={kind} target={} has ambiguous package dependency declarations",
+ owner.name,
+ dependency.name,
+ target_label(dependency_kind.target.as_deref())
+ )),
+ }
+}
+
+fn exception_matches(
+ exceptions: &[TemporaryException],
+ owner: &CargoPackage,
+ dependency: &CargoPackage,
+ dependency_kind: &CargoDependencyKind,
+ declaration: &CargoDependency,
+) -> bool {
+ let kind = normalized_kind(dependency_kind.kind.as_deref());
+ let target = target_label(dependency_kind.target.as_deref());
+ let features = normalized_features(&declaration.features);
+ exceptions.iter().any(|exception| {
+ exception.owner == owner.name
+ && exception.dependency == dependency.name
+ && exception.kind == kind
+ && exception.target == target
+ && exception.features == features
+ && exception.uses_default_features == declaration.uses_default_features
+ })
+}
+
+fn format_violation(
+ owner: &CargoPackage,
+ owner_placement: PackagePlacement<'_>,
+ dependency: &CargoPackage,
+ dependency_placement: PackagePlacement<'_>,
+ resolved_dependency: &CargoNodeDependency,
+ dependency_kind: &CargoDependencyKind,
+ declaration: &CargoDependency,
+) -> String {
+ let features = normalized_features(&declaration.features);
+ format!(
+ "{} (tier={} rank={}) -> {} (tier={} rank={}); owner_id={}; dependency_id={}; alias={}; kind={}; target={}; features=[{}]; default_features={}",
+ owner.name,
+ owner_placement.tier,
+ owner_placement.rank,
+ dependency.name,
+ dependency_placement.tier,
+ dependency_placement.rank,
+ owner.id,
+ dependency.id,
+ resolved_dependency.name,
+ normalized_kind(dependency_kind.kind.as_deref()),
+ target_label(dependency_kind.target.as_deref()),
+ features.join(","),
+ declaration.uses_default_features,
+ )
+}
+
+fn normalized_kind(kind: Option<&str>) -> &str {
+ kind.unwrap_or("normal")
+}
+
+fn target_label(target: Option<&str>) -> &str {
+ target.unwrap_or("all")
+}
+
+fn normalized_features(features: &[String]) -> Vec<String> {
+ features
+ .iter()
+ .cloned()
+ .collect::<BTreeSet<_>>()
+ .into_iter()
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ CargoMetadata, DependencyBoundaryPolicy, exception_matches, validate_metadata,
+ validate_policy,
+ };
+ use serde::Deserialize;
+ use std::collections::BTreeSet;
+
+ const POLICY: &str = include_str!("../../../../contracts/releases/package_tiers.toml");
+ const ARCHITECTURE: &str =
+ include_str!("../../../../docs/specs/radroots_crates_release_v1.toml");
+ const DOMAIN_TO_STORAGE: &str =
+ include_str!("../../tests/fixtures/dependency-boundaries/domain-to-storage.json");
+ const SPI_TO_ADAPTER: &str =
+ include_str!("../../tests/fixtures/dependency-boundaries/spi-to-adapter.json");
+ const SERVICE_TO_SDK: &str =
+ include_str!("../../tests/fixtures/dependency-boundaries/service-to-sdk.json");
+ const VALID_DOWNWARD: &str =
+ include_str!("../../tests/fixtures/dependency-boundaries/valid-downward.json");
+
+ #[derive(Deserialize)]
+ struct ArchitectureCatalog {
+ package: Vec<ArchitecturePackage>,
+ }
+
+ #[derive(Deserialize)]
+ struct ArchitecturePackage {
+ name: String,
+ }
+
+ fn policy() -> DependencyBoundaryPolicy {
+ toml::from_str(POLICY).expect("package tier policy")
+ }
+
+ fn metadata(raw: &str) -> CargoMetadata {
+ serde_json::from_str(raw).expect("Cargo metadata fixture")
+ }
+
+ fn placements<'a>(
+ policy: &'a DependencyBoundaryPolicy,
+ ) -> std::collections::BTreeMap<&'a str, super::PackagePlacement<'a>> {
+ let architecture =
+ toml::from_str::<ArchitectureCatalog>(ARCHITECTURE).expect("architecture catalog");
+ let expected = architecture
+ .package
+ .into_iter()
+ .map(|package| package.name)
+ .collect::<BTreeSet<_>>();
+ validate_policy(policy, &expected).expect("valid package tier policy")
+ }
+
+ #[test]
+ fn policy_covers_the_exact_architecture_catalog() {
+ let policy = policy();
+ let placements = placements(&policy);
+ assert_eq!(placements.len(), 19);
+ }
+
+ #[test]
+ fn domain_to_storage_reports_resolved_alias_kind_target_and_features() {
+ let policy = policy();
+ let error = validate_metadata(&policy, &placements(&policy), &metadata(DOMAIN_TO_STORAGE))
+ .expect_err("domain must not depend on storage");
+ assert!(error.contains("radroots-event (tier=domain rank=1)"));
+ assert!(error.contains("radroots-storage (tier=spi rank=2)"));
+ assert!(error.contains("alias=storage_alias"));
+ assert!(error.contains("kind=normal"));
+ assert!(error.contains("target=cfg(unix)"));
+ assert!(error.contains("features=[memory]"));
+ }
+
+ #[test]
+ fn spi_to_adapter_reports_build_and_target_specific_edge() {
+ let policy = policy();
+ let error = validate_metadata(&policy, &placements(&policy), &metadata(SPI_TO_ADAPTER))
+ .expect_err("SPI must not depend on adapter");
+ assert!(error.contains("radroots-transport (tier=spi rank=2)"));
+ assert!(error.contains("radroots-nostr (tier=adapter rank=3)"));
+ assert!(error.contains("alias=protocol_adapter"));
+ assert!(error.contains("kind=build"));
+ assert!(error.contains("target=cfg(target_arch = \"wasm32\")"));
+ assert!(error.contains("features=[events]"));
+ }
+
+ #[test]
+ fn service_orchestration_to_sdk_is_forbidden() {
+ let policy = policy();
+ let error = validate_metadata(&policy, &placements(&policy), &metadata(SERVICE_TO_SDK))
+ .expect_err("service orchestration must not depend on SDK");
+ assert!(error.contains("radroots-sync (tier=orchestration rank=4)"));
+ assert!(error.contains("radroots-sdk (tier=sdk rank=5)"));
+ assert!(error.contains("alias=client_engine"));
+ }
+
+ #[test]
+ fn valid_downward_dev_target_edge_passes() {
+ let policy = policy();
+ validate_metadata(&policy, &placements(&policy), &metadata(VALID_DOWNWARD))
+ .expect("adapter dev dependency on SPI points downward");
+ }
+
+ #[test]
+ fn migration_exception_is_exact_about_features() {
+ let policy = policy();
+ let owner = super::CargoPackage {
+ id: "owner".to_owned(),
+ name: "radroots-trade".to_owned(),
+ dependencies: Vec::new(),
+ };
+ let dependency = super::CargoPackage {
+ id: "dependency".to_owned(),
+ name: "radroots-nostr".to_owned(),
+ dependencies: Vec::new(),
+ };
+ let kind = super::CargoDependencyKind {
+ kind: Some("dev".to_owned()),
+ target: None,
+ };
+ let exact = super::CargoDependency {
+ name: "radroots-nostr".to_owned(),
+ kind: Some("dev".to_owned()),
+ features: vec!["std".to_owned(), "events".to_owned()],
+ target: None,
+ uses_default_features: false,
+ };
+ assert!(exception_matches(
+ &policy.temporary_exception,
+ &owner,
+ &dependency,
+ &kind,
+ &exact
+ ));
+
+ let broadened = super::CargoDependency {
+ features: vec!["client".to_owned(), "events".to_owned(), "std".to_owned()],
+ ..exact
+ };
+ assert!(!exception_matches(
+ &policy.temporary_exception,
+ &owner,
+ &dependency,
+ &kind,
+ &broadened
+ ));
+ }
+
+ #[test]
+ fn metadata_without_resolve_graph_fails_closed() {
+ let policy = policy();
+ let mut fixture = metadata(VALID_DOWNWARD);
+ fixture.resolve = None;
+ let error = validate_metadata(&policy, &placements(&policy), &fixture)
+ .expect_err("missing resolved graph must fail");
+ assert!(error.contains("did not include a resolved dependency graph"));
+ }
+}
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -19,6 +19,7 @@ mod wasm_declarations;
enum CommandAction<'a> {
Architecture,
+ CheckDependencyBoundaries,
GenerateAll,
GenerateTs,
GenerateWasm(&'a [String]),
@@ -39,6 +40,9 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
let args = args.into_iter().collect::<Vec<_>>();
match command_action(&args)? {
CommandAction::Architecture => architecture::validate(&fs::workspace_root()?),
+ CommandAction::CheckDependencyBoundaries => {
+ architecture::validate_dependency_boundaries(&fs::workspace_root()?)
+ }
CommandAction::GenerateAll => generate::generate_all(),
CommandAction::GenerateTs => generate::generate_ts(),
CommandAction::GenerateWasm(rest) => wasm::generate(rest),
@@ -52,6 +56,9 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
match args {
[command] if command == "architecture" => Ok(CommandAction::Architecture),
+ [command] if command == "check-dependency-boundaries" => {
+ Ok(CommandAction::CheckDependencyBoundaries)
+ }
[command] if command == "generate" => Ok(CommandAction::GenerateAll),
[command, target] if command == "generate" && target == "ts" => {
Ok(CommandAction::GenerateTs)
@@ -71,7 +78,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> {
}
fn usage() -> String {
- "usage: cargo xtask architecture | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke knowledge-rust-local | cargo xtask coverage run"
+ "usage: cargo xtask architecture | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke knowledge-rust-local | cargo xtask coverage run"
.to_owned()
}
@@ -89,6 +96,15 @@ mod tests {
}
#[test]
+ fn accepts_dependency_boundary_check() {
+ let args = ["check-dependency-boundaries".to_owned()];
+ assert!(matches!(
+ command_action(&args).expect("action"),
+ CommandAction::CheckDependencyBoundaries
+ ));
+ }
+
+ #[test]
fn accepts_generate_ts() {
let args = ["generate".to_owned(), "ts".to_owned()];
assert!(matches!(
diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json b/tools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json
@@ -0,0 +1,46 @@
+{
+ "packages": [
+ {
+ "id": "fixture#radroots-event@0.1.0",
+ "name": "radroots-event",
+ "dependencies": [
+ {
+ "name": "radroots-storage",
+ "rename": "storage_alias",
+ "kind": null,
+ "features": ["memory"],
+ "target": "cfg(unix)",
+ "uses_default_features": false
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-storage@0.1.0",
+ "name": "radroots-storage",
+ "dependencies": []
+ }
+ ],
+ "resolve": {
+ "nodes": [
+ {
+ "id": "fixture#radroots-event@0.1.0",
+ "deps": [
+ {
+ "name": "storage_alias",
+ "pkg": "fixture#radroots-storage@0.1.0",
+ "dep_kinds": [
+ {
+ "kind": null,
+ "target": "cfg(unix)"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-storage@0.1.0",
+ "deps": []
+ }
+ ]
+ }
+}
diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json b/tools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json
@@ -0,0 +1,46 @@
+{
+ "packages": [
+ {
+ "id": "fixture#radroots-sync@0.1.0",
+ "name": "radroots-sync",
+ "dependencies": [
+ {
+ "name": "radroots-sdk",
+ "rename": "client_engine",
+ "kind": null,
+ "features": ["memory"],
+ "target": null,
+ "uses_default_features": false
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-sdk@0.1.0",
+ "name": "radroots-sdk",
+ "dependencies": []
+ }
+ ],
+ "resolve": {
+ "nodes": [
+ {
+ "id": "fixture#radroots-sync@0.1.0",
+ "deps": [
+ {
+ "name": "client_engine",
+ "pkg": "fixture#radroots-sdk@0.1.0",
+ "dep_kinds": [
+ {
+ "kind": null,
+ "target": null
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-sdk@0.1.0",
+ "deps": []
+ }
+ ]
+ }
+}
diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json b/tools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json
@@ -0,0 +1,46 @@
+{
+ "packages": [
+ {
+ "id": "fixture#radroots-transport@0.1.0",
+ "name": "radroots-transport",
+ "dependencies": [
+ {
+ "name": "radroots-nostr",
+ "rename": "protocol_adapter",
+ "kind": "build",
+ "features": ["events"],
+ "target": "cfg(target_arch = \"wasm32\")",
+ "uses_default_features": false
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-nostr@0.1.0",
+ "name": "radroots-nostr",
+ "dependencies": []
+ }
+ ],
+ "resolve": {
+ "nodes": [
+ {
+ "id": "fixture#radroots-transport@0.1.0",
+ "deps": [
+ {
+ "name": "protocol_adapter",
+ "pkg": "fixture#radroots-nostr@0.1.0",
+ "dep_kinds": [
+ {
+ "kind": "build",
+ "target": "cfg(target_arch = \"wasm32\")"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-nostr@0.1.0",
+ "deps": []
+ }
+ ]
+ }
+}
diff --git a/tools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json b/tools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json
@@ -0,0 +1,46 @@
+{
+ "packages": [
+ {
+ "id": "fixture#radroots-transport-nostr@0.1.0",
+ "name": "radroots-transport-nostr",
+ "dependencies": [
+ {
+ "name": "radroots-transport",
+ "rename": "transport_spi",
+ "kind": "dev",
+ "features": [],
+ "target": "cfg(unix)",
+ "uses_default_features": false
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-transport@0.1.0",
+ "name": "radroots-transport",
+ "dependencies": []
+ }
+ ],
+ "resolve": {
+ "nodes": [
+ {
+ "id": "fixture#radroots-transport-nostr@0.1.0",
+ "deps": [
+ {
+ "name": "transport_spi",
+ "pkg": "fixture#radroots-transport@0.1.0",
+ "dep_kinds": [
+ {
+ "kind": "dev",
+ "target": "cfg(unix)"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "fixture#radroots-transport@0.1.0",
+ "deps": []
+ }
+ ]
+ }
+}