diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 22:05:01 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 22:05:01 +0300 |
| commit | 4c1edef21b55929355c42341d294d3d3c6a3be96 (patch) | |
| tree | a892b00fdaf58ca5eeb57b09a7262611c2832921 | |
| parent | e6778d43afd746f4017cc328db1fa6f23452c598 (diff) | |
| download | fparkan-4c1edef21b55929355c42341d294d3d3c6a3be96.tar.xz fparkan-4c1edef21b55929355c42341d294d3d3c6a3be96.zip | |
test: verify headless dependency closure
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 2 | ||||
| -rw-r--r-- | xtask/src/main.rs | 89 |
2 files changed, 90 insertions, 1 deletions
diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index d6456c0..17a3145 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -7,7 +7,7 @@ L0-P2-001 covered cargo test -p fparkan-corpus --offline licensed_part2_manifest L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates -S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from fparkan-headless +S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from the transitive fparkan-headless workspace manifest closure S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/ diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2e1f7fb..1a90b30 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -425,6 +425,7 @@ fn run_policy(root: &Path) -> Result<(), String> { let mut failures = Vec::new(); scan_policy_dir(root, &mut failures)?; validate_cargo_metadata(root, &mut failures)?; + validate_cargo_metadata_dependency_closures(root, &mut failures)?; validate_lockfile(root, &mut failures); validate_workspace_license(root, &mut failures)?; validate_dependency_boundaries(root, &mut failures)?; @@ -456,6 +457,69 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<() Ok(()) } +fn validate_cargo_metadata_dependency_closures( + root: &Path, + failures: &mut Vec<String>, +) -> Result<(), String> { + let mut manifests = Vec::new(); + collect_cargo_manifests(root, &mut manifests)?; + let mut deps_by_package = BTreeMap::new(); + for manifest in manifests { + let text = fs::read_to_string(&manifest) + .map_err(|err| format!("{}: {err}", manifest.display()))?; + let Some(package) = parse_package_name(&text) else { + continue; + }; + deps_by_package.insert(package, parse_manifest_dependencies(&text)); + } + + validate_package_closure_excludes("fparkan-headless", &deps_by_package, failures); + Ok(()) +} + +fn validate_package_closure_excludes( + package: &str, + deps_by_package: &BTreeMap<String, BTreeSet<String>>, + failures: &mut Vec<String>, +) { + if !deps_by_package.contains_key(package) { + failures.push(format!( + "workspace manifest graph missing package {package}" + )); + return; + } + let closure = dependency_closure_names(package, deps_by_package); + if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&closure) { + failures.push(format!( + "workspace manifest closure: package {package} depends on forbidden platform/render dependency {forbidden}" + )); + } +} + +fn dependency_closure_names( + root: &str, + deps_by_package: &BTreeMap<String, BTreeSet<String>>, +) -> BTreeSet<String> { + let mut seen = BTreeSet::new(); + let mut names = BTreeSet::new(); + let mut stack = deps_by_package + .get(root) + .cloned() + .unwrap_or_default() + .into_iter() + .collect::<Vec<_>>(); + while let Some(name) = stack.pop() { + if !seen.insert(name.clone()) { + continue; + } + names.insert(name.clone()); + if let Some(deps) = deps_by_package.get(&name) { + stack.extend(deps.iter().cloned()); + } + } + names +} + fn validate_lockfile(root: &Path, failures: &mut Vec<String>) { let lockfile = root.join("Cargo.lock"); if !lockfile.is_file() { @@ -1862,6 +1926,31 @@ fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } } #[test] + fn workspace_manifest_closure_detects_transitive_platform_bridge() { + let deps_by_package = [ + ( + "fparkan-headless".to_string(), + ["fparkan-runtime".to_string()].into_iter().collect(), + ), + ( + "fparkan-runtime".to_string(), + ["fparkan-render-vulkan".to_string()].into_iter().collect(), + ), + ("fparkan-render-vulkan".to_string(), BTreeSet::new()), + ] + .into_iter() + .collect::<BTreeMap<_, _>>(); + + let closure = dependency_closure_names("fparkan-headless", &deps_by_package); + + assert!(closure.contains("fparkan-runtime")); + assert_eq!( + first_forbidden_platform_bridge_dependency(&closure), + Some("fparkan-render-vulkan") + ); + } + + #[test] fn detects_forbidden_domain_dependencies() { assert!(!is_forbidden_domain_dependency("fparkan-render-vulkan")); assert!(is_forbidden_domain_dependency("sdl2")); |
