dependency_boundary.rs (24116B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 fs, 4 path::Path, 5 process::Command, 6 }; 7 8 use serde::Deserialize; 9 10 const PACKAGE_TIERS_RELATIVE: &str = "contracts/releases/package_tiers.toml"; 11 const SPEC_ID: &str = "radroots.crates.release.v1"; 12 const DIRECTION: &str = "same_or_lower"; 13 const POLICY_SCHEMA_VERSION: u16 = 1; 14 const CURRENT_STEP: u16 = 24; 15 const EXPECTED_TIERS: &[(&str, u8)] = &[ 16 ("foundation", 0), 17 ("domain", 1), 18 ("spi", 2), 19 ("adapter", 3), 20 ("orchestration", 4), 21 ("sdk", 5), 22 ("facade", 6), 23 ]; 24 const EXPECTED_DEPENDENCY_KINDS: &[&str] = &["build", "dev", "normal"]; 25 26 #[derive(Debug, Deserialize)] 27 #[serde(deny_unknown_fields)] 28 struct DependencyBoundaryPolicy { 29 schema_version: u16, 30 spec_id: String, 31 direction: String, 32 enforced_dependency_kinds: Vec<String>, 33 tier: Vec<PackageTier>, 34 #[serde(default)] 35 temporary_exception: Vec<TemporaryException>, 36 } 37 38 #[derive(Debug, Deserialize)] 39 #[serde(deny_unknown_fields)] 40 struct PackageTier { 41 id: String, 42 rank: u8, 43 packages: Vec<String>, 44 } 45 46 #[derive(Debug, Deserialize)] 47 #[serde(deny_unknown_fields)] 48 struct TemporaryException { 49 owner: String, 50 dependency: String, 51 kind: String, 52 target: String, 53 features: Vec<String>, 54 uses_default_features: bool, 55 removal_step: u16, 56 rationale: String, 57 } 58 59 #[derive(Debug, Deserialize)] 60 struct CargoMetadata { 61 packages: Vec<CargoPackage>, 62 resolve: Option<CargoResolve>, 63 } 64 65 #[derive(Debug, Deserialize)] 66 struct CargoPackage { 67 id: String, 68 name: String, 69 #[serde(default)] 70 dependencies: Vec<CargoDependency>, 71 } 72 73 #[derive(Debug, Deserialize)] 74 struct CargoDependency { 75 name: String, 76 #[serde(default)] 77 kind: Option<String>, 78 #[serde(default)] 79 features: Vec<String>, 80 #[serde(default)] 81 target: Option<String>, 82 #[serde(default = "default_true")] 83 uses_default_features: bool, 84 } 85 86 #[derive(Debug, Deserialize)] 87 struct CargoResolve { 88 nodes: Vec<CargoNode>, 89 } 90 91 #[derive(Debug, Deserialize)] 92 struct CargoNode { 93 id: String, 94 #[serde(default)] 95 deps: Vec<CargoNodeDependency>, 96 } 97 98 #[derive(Debug, Deserialize)] 99 struct CargoNodeDependency { 100 name: String, 101 pkg: String, 102 #[serde(default)] 103 dep_kinds: Vec<CargoDependencyKind>, 104 } 105 106 #[derive(Debug, Deserialize)] 107 struct CargoDependencyKind { 108 #[serde(default)] 109 kind: Option<String>, 110 #[serde(default)] 111 target: Option<String>, 112 } 113 114 #[derive(Clone, Copy)] 115 struct PackagePlacement<'a> { 116 tier: &'a str, 117 rank: u8, 118 } 119 120 fn default_true() -> bool { 121 true 122 } 123 124 pub(super) fn validate_policy_catalog( 125 workspace_root: &Path, 126 expected_packages: &BTreeSet<String>, 127 ) -> Result<(), String> { 128 let policy = load_policy(workspace_root)?; 129 validate_policy(&policy, expected_packages).map(|_| ()) 130 } 131 132 pub(super) fn validate_resolved_boundaries(workspace_root: &Path) -> Result<(), String> { 133 let policy = load_policy(workspace_root)?; 134 let expected_packages = policy 135 .tier 136 .iter() 137 .flat_map(|tier| tier.packages.iter().cloned()) 138 .collect::<BTreeSet<_>>(); 139 let placements = validate_policy(&policy, &expected_packages)?; 140 let metadata = load_metadata(workspace_root)?; 141 validate_metadata(&policy, &placements, &metadata) 142 } 143 144 fn load_policy(workspace_root: &Path) -> Result<DependencyBoundaryPolicy, String> { 145 let path = workspace_root.join(PACKAGE_TIERS_RELATIVE); 146 let raw = 147 fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; 148 toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) 149 } 150 151 fn validate_policy<'a>( 152 policy: &'a DependencyBoundaryPolicy, 153 expected_packages: &BTreeSet<String>, 154 ) -> Result<BTreeMap<&'a str, PackagePlacement<'a>>, String> { 155 if policy.schema_version != POLICY_SCHEMA_VERSION { 156 return Err(format!( 157 "{PACKAGE_TIERS_RELATIVE} schema_version must be {POLICY_SCHEMA_VERSION}" 158 )); 159 } 160 if policy.spec_id != SPEC_ID { 161 return Err(format!( 162 "{PACKAGE_TIERS_RELATIVE} spec_id must be {SPEC_ID}" 163 )); 164 } 165 if policy.direction != DIRECTION { 166 return Err(format!( 167 "{PACKAGE_TIERS_RELATIVE} direction must be {DIRECTION}" 168 )); 169 } 170 171 let actual_kinds = unique_strings( 172 "enforced_dependency_kinds", 173 &policy.enforced_dependency_kinds, 174 )?; 175 let expected_kinds = EXPECTED_DEPENDENCY_KINDS 176 .iter() 177 .copied() 178 .collect::<BTreeSet<_>>(); 179 if actual_kinds != expected_kinds { 180 return Err(format!( 181 "{PACKAGE_TIERS_RELATIVE} enforced_dependency_kinds must be build, dev, normal" 182 )); 183 } 184 185 let actual_tiers = policy 186 .tier 187 .iter() 188 .map(|tier| (tier.id.as_str(), tier.rank)) 189 .collect::<Vec<_>>(); 190 if actual_tiers != EXPECTED_TIERS { 191 return Err(format!( 192 "{PACKAGE_TIERS_RELATIVE} tiers must match the approved ordered architecture" 193 )); 194 } 195 196 let mut placements = BTreeMap::new(); 197 for tier in &policy.tier { 198 if tier.packages.is_empty() { 199 return Err(format!( 200 "{PACKAGE_TIERS_RELATIVE} tier {} must allocate at least one package", 201 tier.id 202 )); 203 } 204 for package in &tier.packages { 205 if placements 206 .insert( 207 package.as_str(), 208 PackagePlacement { 209 tier: &tier.id, 210 rank: tier.rank, 211 }, 212 ) 213 .is_some() 214 { 215 return Err(format!( 216 "{PACKAGE_TIERS_RELATIVE} package {package} is allocated more than once" 217 )); 218 } 219 } 220 } 221 222 let actual_packages = placements 223 .keys() 224 .map(|package| (*package).to_owned()) 225 .collect::<BTreeSet<_>>(); 226 if &actual_packages != expected_packages { 227 return Err(package_set_mismatch(expected_packages, &actual_packages)); 228 } 229 230 let mut exception_keys = BTreeSet::new(); 231 for exception in &policy.temporary_exception { 232 let owner = placements.get(exception.owner.as_str()).ok_or_else(|| { 233 format!( 234 "{PACKAGE_TIERS_RELATIVE} exception owner {} is not an approved package", 235 exception.owner 236 ) 237 })?; 238 let dependency = placements 239 .get(exception.dependency.as_str()) 240 .ok_or_else(|| { 241 format!( 242 "{PACKAGE_TIERS_RELATIVE} exception dependency {} is not an approved package", 243 exception.dependency 244 ) 245 })?; 246 if dependency.rank <= owner.rank { 247 return Err(format!( 248 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} does not describe an upward edge", 249 exception.owner, exception.dependency 250 )); 251 } 252 if !expected_kinds.contains(exception.kind.as_str()) { 253 return Err(format!( 254 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} uses unknown dependency kind {}", 255 exception.owner, exception.dependency, exception.kind 256 )); 257 } 258 if exception.target.trim().is_empty() { 259 return Err(format!( 260 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must name its exact target", 261 exception.owner, exception.dependency 262 )); 263 } 264 let normalized_features = normalized_features(&exception.features); 265 if normalized_features.len() != exception.features.len() 266 || normalized_features.iter().ne(exception.features.iter()) 267 { 268 return Err(format!( 269 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} features must be sorted and unique", 270 exception.owner, exception.dependency 271 )); 272 } 273 if exception.removal_step <= CURRENT_STEP { 274 return Err(format!( 275 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} expired at Step {}", 276 exception.owner, exception.dependency, exception.removal_step 277 )); 278 } 279 if exception.rationale.trim().is_empty() { 280 return Err(format!( 281 "{PACKAGE_TIERS_RELATIVE} exception {} -> {} must include a rationale", 282 exception.owner, exception.dependency 283 )); 284 } 285 let key = ( 286 exception.owner.as_str(), 287 exception.dependency.as_str(), 288 exception.kind.as_str(), 289 exception.target.as_str(), 290 exception.features.clone(), 291 exception.uses_default_features, 292 ); 293 if !exception_keys.insert(key) { 294 return Err(format!( 295 "{PACKAGE_TIERS_RELATIVE} contains a duplicate exception for {} -> {}", 296 exception.owner, exception.dependency 297 )); 298 } 299 } 300 301 Ok(placements) 302 } 303 304 fn unique_strings<'a>(label: &str, values: &'a [String]) -> Result<BTreeSet<&'a str>, String> { 305 let unique = values.iter().map(String::as_str).collect::<BTreeSet<_>>(); 306 if unique.len() != values.len() { 307 return Err(format!( 308 "{PACKAGE_TIERS_RELATIVE} {label} must not contain duplicates" 309 )); 310 } 311 Ok(unique) 312 } 313 314 fn package_set_mismatch(expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String { 315 let missing = expected 316 .difference(actual) 317 .cloned() 318 .collect::<Vec<_>>() 319 .join(", "); 320 let extra = actual 321 .difference(expected) 322 .cloned() 323 .collect::<Vec<_>>() 324 .join(", "); 325 format!("{PACKAGE_TIERS_RELATIVE} package catalog mismatch; missing: {missing}; extra: {extra}") 326 } 327 328 fn load_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> { 329 let output = Command::new("cargo") 330 .args([ 331 "metadata", 332 "--format-version", 333 "1", 334 "--locked", 335 "--all-features", 336 ]) 337 .current_dir(workspace_root) 338 .output() 339 .map_err(|error| format!("run cargo metadata: {error}"))?; 340 if !output.status.success() { 341 return Err(format!( 342 "cargo metadata failed while checking dependency boundaries: {}", 343 String::from_utf8_lossy(&output.stderr).trim() 344 )); 345 } 346 serde_json::from_slice(&output.stdout) 347 .map_err(|error| format!("parse cargo metadata for dependency boundaries: {error}")) 348 } 349 350 fn validate_metadata( 351 policy: &DependencyBoundaryPolicy, 352 placements: &BTreeMap<&str, PackagePlacement<'_>>, 353 metadata: &CargoMetadata, 354 ) -> Result<(), String> { 355 let resolve = metadata 356 .resolve 357 .as_ref() 358 .ok_or_else(|| "cargo metadata did not include a resolved dependency graph".to_owned())?; 359 let mut packages_by_id = BTreeMap::new(); 360 for package in &metadata.packages { 361 if packages_by_id 362 .insert(package.id.as_str(), package) 363 .is_some() 364 { 365 return Err(format!( 366 "cargo metadata contains duplicate package id {}", 367 package.id 368 )); 369 } 370 } 371 372 let mut nodes_by_id = BTreeMap::new(); 373 for node in &resolve.nodes { 374 if nodes_by_id.insert(node.id.as_str(), node).is_some() { 375 return Err(format!( 376 "cargo metadata contains duplicate resolve node {}", 377 node.id 378 )); 379 } 380 } 381 382 let enforced_kinds = policy 383 .enforced_dependency_kinds 384 .iter() 385 .map(String::as_str) 386 .collect::<BTreeSet<_>>(); 387 let mut violations = Vec::new(); 388 389 for node in &resolve.nodes { 390 let Some(owner_package) = packages_by_id.get(node.id.as_str()).copied() else { 391 return Err(format!( 392 "cargo metadata resolve node {} has no package record", 393 node.id 394 )); 395 }; 396 let Some(owner_placement) = placements.get(owner_package.name.as_str()).copied() else { 397 continue; 398 }; 399 400 for resolved_dependency in &node.deps { 401 let dependency_package = packages_by_id 402 .get(resolved_dependency.pkg.as_str()) 403 .copied() 404 .ok_or_else(|| { 405 format!( 406 "cargo metadata resolved dependency {} has no package record", 407 resolved_dependency.pkg 408 ) 409 })?; 410 let Some(dependency_placement) = 411 placements.get(dependency_package.name.as_str()).copied() 412 else { 413 continue; 414 }; 415 if resolved_dependency.dep_kinds.is_empty() { 416 return Err(format!( 417 "cargo metadata edge {} -> {} has no dependency kind", 418 owner_package.name, dependency_package.name 419 )); 420 } 421 422 for dependency_kind in &resolved_dependency.dep_kinds { 423 let kind = normalized_kind(dependency_kind.kind.as_deref()); 424 if !enforced_kinds.contains(kind) { 425 continue; 426 } 427 let declaration = 428 resolve_declaration(owner_package, dependency_package, dependency_kind)?; 429 if dependency_placement.rank <= owner_placement.rank { 430 continue; 431 } 432 if exception_matches( 433 &policy.temporary_exception, 434 owner_package, 435 dependency_package, 436 dependency_kind, 437 declaration, 438 ) { 439 continue; 440 } 441 violations.push(format_violation( 442 owner_package, 443 owner_placement, 444 dependency_package, 445 dependency_placement, 446 resolved_dependency, 447 dependency_kind, 448 declaration, 449 )); 450 } 451 } 452 } 453 454 if violations.is_empty() { 455 Ok(()) 456 } else { 457 violations.sort(); 458 Err(format!( 459 "forbidden package dependency direction(s):\n{}", 460 violations.join("\n") 461 )) 462 } 463 } 464 465 fn resolve_declaration<'a>( 466 owner: &'a CargoPackage, 467 dependency: &CargoPackage, 468 dependency_kind: &CargoDependencyKind, 469 ) -> Result<&'a CargoDependency, String> { 470 let kind = normalized_kind(dependency_kind.kind.as_deref()); 471 let candidates = owner 472 .dependencies 473 .iter() 474 .filter(|candidate| { 475 candidate.name == dependency.name 476 && normalized_kind(candidate.kind.as_deref()) == kind 477 && candidate.target == dependency_kind.target 478 }) 479 .collect::<Vec<_>>(); 480 match candidates.as_slice() { 481 [declaration] => Ok(*declaration), 482 [] => Err(format!( 483 "cargo metadata edge {} -> {} kind={kind} target={} has no matching package dependency declaration", 484 owner.name, 485 dependency.name, 486 target_label(dependency_kind.target.as_deref()) 487 )), 488 _ => Err(format!( 489 "cargo metadata edge {} -> {} kind={kind} target={} has ambiguous package dependency declarations", 490 owner.name, 491 dependency.name, 492 target_label(dependency_kind.target.as_deref()) 493 )), 494 } 495 } 496 497 fn exception_matches( 498 exceptions: &[TemporaryException], 499 owner: &CargoPackage, 500 dependency: &CargoPackage, 501 dependency_kind: &CargoDependencyKind, 502 declaration: &CargoDependency, 503 ) -> bool { 504 let kind = normalized_kind(dependency_kind.kind.as_deref()); 505 let target = target_label(dependency_kind.target.as_deref()); 506 let features = normalized_features(&declaration.features); 507 exceptions.iter().any(|exception| { 508 exception.owner == owner.name 509 && exception.dependency == dependency.name 510 && exception.kind == kind 511 && exception.target == target 512 && exception.features == features 513 && exception.uses_default_features == declaration.uses_default_features 514 }) 515 } 516 517 fn format_violation( 518 owner: &CargoPackage, 519 owner_placement: PackagePlacement<'_>, 520 dependency: &CargoPackage, 521 dependency_placement: PackagePlacement<'_>, 522 resolved_dependency: &CargoNodeDependency, 523 dependency_kind: &CargoDependencyKind, 524 declaration: &CargoDependency, 525 ) -> String { 526 let features = normalized_features(&declaration.features); 527 format!( 528 "{} (tier={} rank={}) -> {} (tier={} rank={}); owner_id={}; dependency_id={}; alias={}; kind={}; target={}; features=[{}]; default_features={}", 529 owner.name, 530 owner_placement.tier, 531 owner_placement.rank, 532 dependency.name, 533 dependency_placement.tier, 534 dependency_placement.rank, 535 owner.id, 536 dependency.id, 537 resolved_dependency.name, 538 normalized_kind(dependency_kind.kind.as_deref()), 539 target_label(dependency_kind.target.as_deref()), 540 features.join(","), 541 declaration.uses_default_features, 542 ) 543 } 544 545 fn normalized_kind(kind: Option<&str>) -> &str { 546 kind.unwrap_or("normal") 547 } 548 549 fn target_label(target: Option<&str>) -> &str { 550 target.unwrap_or("all") 551 } 552 553 fn normalized_features(features: &[String]) -> Vec<String> { 554 features 555 .iter() 556 .cloned() 557 .collect::<BTreeSet<_>>() 558 .into_iter() 559 .collect() 560 } 561 562 #[cfg(test)] 563 mod tests { 564 use super::{ 565 CargoMetadata, DependencyBoundaryPolicy, exception_matches, validate_metadata, 566 validate_policy, 567 }; 568 use serde::Deserialize; 569 use std::collections::BTreeSet; 570 571 const POLICY: &str = include_str!("../../../../contracts/releases/package_tiers.toml"); 572 const ARCHITECTURE: &str = 573 include_str!("../../../../contracts/crates/release_v1/radroots_crates_release_v1.toml"); 574 const DOMAIN_TO_STORAGE: &str = 575 include_str!("../../tests/fixtures/dependency-boundaries/domain-to-storage.json"); 576 const SPI_TO_ADAPTER: &str = 577 include_str!("../../tests/fixtures/dependency-boundaries/spi-to-adapter.json"); 578 const SERVICE_TO_SDK: &str = 579 include_str!("../../tests/fixtures/dependency-boundaries/service-to-sdk.json"); 580 const VALID_DOWNWARD: &str = 581 include_str!("../../tests/fixtures/dependency-boundaries/valid-downward.json"); 582 583 #[derive(Deserialize)] 584 struct ArchitectureCatalog { 585 package: Vec<ArchitecturePackage>, 586 } 587 588 #[derive(Deserialize)] 589 struct ArchitecturePackage { 590 name: String, 591 } 592 593 fn policy() -> DependencyBoundaryPolicy { 594 toml::from_str(POLICY).expect("package tier policy") 595 } 596 597 fn metadata(raw: &str) -> CargoMetadata { 598 serde_json::from_str(raw).expect("Cargo metadata fixture") 599 } 600 601 fn placements<'a>( 602 policy: &'a DependencyBoundaryPolicy, 603 ) -> std::collections::BTreeMap<&'a str, super::PackagePlacement<'a>> { 604 let architecture = 605 toml::from_str::<ArchitectureCatalog>(ARCHITECTURE).expect("architecture catalog"); 606 let expected = architecture 607 .package 608 .into_iter() 609 .map(|package| package.name) 610 .collect::<BTreeSet<_>>(); 611 validate_policy(policy, &expected).expect("valid package tier policy") 612 } 613 614 #[test] 615 fn policy_covers_the_exact_architecture_catalog() { 616 let policy = policy(); 617 let placements = placements(&policy); 618 assert_eq!(placements.len(), 19); 619 } 620 621 #[test] 622 fn domain_to_storage_reports_resolved_alias_kind_target_and_features() { 623 let policy = policy(); 624 let error = validate_metadata(&policy, &placements(&policy), &metadata(DOMAIN_TO_STORAGE)) 625 .expect_err("domain must not depend on storage"); 626 assert!(error.contains("radroots_event (tier=domain rank=1)")); 627 assert!(error.contains("radroots_storage (tier=spi rank=2)")); 628 assert!(error.contains("alias=storage_alias")); 629 assert!(error.contains("kind=normal")); 630 assert!(error.contains("target=cfg(unix)")); 631 assert!(error.contains("features=[memory]")); 632 } 633 634 #[test] 635 fn spi_to_adapter_reports_build_and_target_specific_edge() { 636 let policy = policy(); 637 let error = validate_metadata(&policy, &placements(&policy), &metadata(SPI_TO_ADAPTER)) 638 .expect_err("SPI must not depend on adapter"); 639 assert!(error.contains("radroots_transport (tier=spi rank=2)")); 640 assert!(error.contains("radroots_nostr (tier=adapter rank=3)")); 641 assert!(error.contains("alias=protocol_adapter")); 642 assert!(error.contains("kind=build")); 643 assert!(error.contains("target=cfg(target_arch = \"wasm32\")")); 644 assert!(error.contains("features=[events]")); 645 } 646 647 #[test] 648 fn service_orchestration_to_sdk_is_forbidden() { 649 let policy = policy(); 650 let error = validate_metadata(&policy, &placements(&policy), &metadata(SERVICE_TO_SDK)) 651 .expect_err("service orchestration must not depend on SDK"); 652 assert!(error.contains("radroots_sync (tier=orchestration rank=4)")); 653 assert!(error.contains("radroots_sdk (tier=sdk rank=5)")); 654 assert!(error.contains("alias=client_engine")); 655 } 656 657 #[test] 658 fn valid_downward_dev_target_edge_passes() { 659 let policy = policy(); 660 validate_metadata(&policy, &placements(&policy), &metadata(VALID_DOWNWARD)) 661 .expect("adapter dev dependency on SPI points downward"); 662 } 663 664 #[test] 665 fn temporary_exception_is_exact_about_features() { 666 let policy = policy(); 667 assert!( 668 policy.temporary_exception.is_empty(), 669 "expired dependency exceptions must not remain in production policy" 670 ); 671 let exceptions = [super::TemporaryException { 672 owner: "radroots_trade".to_owned(), 673 dependency: "radroots_nostr".to_owned(), 674 kind: "dev".to_owned(), 675 target: "all".to_owned(), 676 features: vec!["events".to_owned(), "std".to_owned()], 677 uses_default_features: false, 678 removal_step: 88, 679 rationale: "synthetic exact-match fixture".to_owned(), 680 }]; 681 let owner = super::CargoPackage { 682 id: "owner".to_owned(), 683 name: "radroots_trade".to_owned(), 684 dependencies: Vec::new(), 685 }; 686 let dependency = super::CargoPackage { 687 id: "dependency".to_owned(), 688 name: "radroots_nostr".to_owned(), 689 dependencies: Vec::new(), 690 }; 691 let kind = super::CargoDependencyKind { 692 kind: Some("dev".to_owned()), 693 target: None, 694 }; 695 let exact = super::CargoDependency { 696 name: "radroots_nostr".to_owned(), 697 kind: Some("dev".to_owned()), 698 features: vec!["std".to_owned(), "events".to_owned()], 699 target: None, 700 uses_default_features: false, 701 }; 702 assert!(exception_matches( 703 &exceptions, 704 &owner, 705 &dependency, 706 &kind, 707 &exact 708 )); 709 710 let broadened = super::CargoDependency { 711 features: vec!["client".to_owned(), "events".to_owned(), "std".to_owned()], 712 ..exact 713 }; 714 assert!(!exception_matches( 715 &exceptions, 716 &owner, 717 &dependency, 718 &kind, 719 &broadened 720 )); 721 } 722 723 #[test] 724 fn metadata_without_resolve_graph_fails_closed() { 725 let policy = policy(); 726 let mut fixture = metadata(VALID_DOWNWARD); 727 fixture.resolve = None; 728 let error = validate_metadata(&policy, &placements(&policy), &fixture) 729 .expect_err("missing resolved graph must fail"); 730 assert!(error.contains("did not include a resolved dependency graph")); 731 } 732 }