sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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:
Acontracts/releases/package_tiers.toml | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/architecture.rs | 13+++++++++++++
Atools/xtask/src/architecture/dependency_boundary.rs | 718+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 18+++++++++++++++++-
Atools/xtask/tests/fixtures/dependency-boundaries/domain-to-storage.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/service-to-sdk.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/spi-to-adapter.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/tests/fixtures/dependency-boundaries/valid-downward.json | 46++++++++++++++++++++++++++++++++++++++++++++++
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": [] + } + ] + } +}