aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 22:05:01 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 22:05:01 +0300
commit4c1edef21b55929355c42341d294d3d3c6a3be96 (patch)
treea892b00fdaf58ca5eeb57b09a7262611c2832921
parente6778d43afd746f4017cc328db1fa6f23452c598 (diff)
downloadfparkan-4c1edef21b55929355c42341d294d3d3c6a3be96.tar.xz
fparkan-4c1edef21b55929355c42341d294d3d3c6a3be96.zip
test: verify headless dependency closure
-rw-r--r--fixtures/acceptance/coverage.tsv2
-rw-r--r--xtask/src/main.rs89
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"));