diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-25 02:45:23 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-25 10:45:32 +0300 |
| commit | 5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018 (patch) | |
| tree | a4d02bb919998283e10ba018d2f757b227445591 | |
| parent | 27af3806b342cfe82fdd7050a1577f6e45aa8c26 (diff) | |
| download | fparkan-5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018.tar.xz fparkan-5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018.zip | |
ci: tighten stage 0 acceptance gates
| -rw-r--r-- | .github/workflows/ci.yml | 24 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 56 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 19 | ||||
| -rw-r--r-- | deny.toml | 35 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_packages.toml | 47 | ||||
| -rw-r--r-- | xtask/src/main.rs | 380 |
6 files changed, 424 insertions, 137 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0c90e0..76ceca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,19 +2,21 @@ name: fparkan-ci on: push: - branches: [main] + branches: [devel, main] pull_request: - branches: [main] + branches: [devel, main] + workflow_dispatch: jobs: msrv-backend-neutral: name: MSRV backend-neutral crates runs-on: ubuntu-latest + timeout-minutes: 20 env: CARGO_TERM_COLOR: always steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 with: toolchain: 1.87.0 - name: Test backend-neutral crates @@ -48,6 +50,7 @@ jobs: stage0-matrix: name: Stage 0-2 CI (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -61,12 +64,13 @@ jobs: env: CARGO_TERM_COLOR: always steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 with: - toolchain-file: rust-toolchain.toml + toolchain: 1.87.0 + components: clippy,rustfmt - name: Install cargo-deny - run: cargo install cargo-deny --locked + run: cargo install cargo-deny --version 0.19.9 --locked - name: Run canonical CI gate run: cargo xtask ci - name: Record native Vulkan smoke status @@ -81,10 +85,10 @@ jobs: --reason "native Vulkan smoke runner is not enabled on this CI lane yet" - name: Upload acceptance evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: stage-0-2-acceptance-${{ matrix.os }} path: | target/fparkan/acceptance/stage-0-2-audit.json target/fparkan/native-smoke/*.json - if-no-files-found: ignore + if-no-files-found: error diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index ee89e46..4490d81 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -345,14 +345,20 @@ impl VulkanLogicalDeviceProbe { #[must_use] pub fn graphics_queue(&self) -> vk::Queue { // SAFETY: The queue-family index belongs to this live logical device. - unsafe { self.device.get_device_queue(self.report.graphics_queue_family, 0) } + unsafe { + self.device + .get_device_queue(self.report.graphics_queue_family, 0) + } } /// Returns the presentation queue selected by the Stage 0 policy. #[must_use] pub fn present_queue(&self) -> vk::Queue { // SAFETY: The queue-family index belongs to this live logical device. - unsafe { self.device.get_device_queue(self.report.present_queue_family, 0) } + unsafe { + self.device + .get_device_queue(self.report.present_queue_family, 0) + } } /// Returns a shared reference to the live logical device. @@ -440,8 +446,12 @@ pub enum VulkanSmokeRunError { impl std::fmt::Display for VulkanSmokeRunError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::AcquireImage { result } => write!(f, "failed to acquire swapchain image: {result}"), - Self::PresentImage { result } => write!(f, "failed to present swapchain image: {result}"), + Self::AcquireImage { result } => { + write!(f, "failed to acquire swapchain image: {result}") + } + Self::PresentImage { result } => { + write!(f, "failed to present swapchain image: {result}") + } Self::RecreateSwapchain { result } => { write!(f, "failed to recreate swapchain: {result}") } @@ -464,9 +474,11 @@ pub fn run_vulkan_smoke_pass( let timeout_ns = u64::MAX; let image_available = vk::SemaphoreCreateInfo::default(); - let image_ready = unsafe { device.device().create_semaphore(&image_available, None) } - .map_err(|error| VulkanSmokeRunError::RecreateSwapchain { - result: format!("{error:?}"), + let image_ready = + unsafe { device.device().create_semaphore(&image_available, None) }.map_err(|error| { + VulkanSmokeRunError::RecreateSwapchain { + result: format!("{error:?}"), + } })?; let recreate_interval = if recreate_count == 0 { @@ -479,18 +491,27 @@ pub fn run_vulkan_smoke_pass( let mut created = 0_u32; for frame in 0..frames { - if recreate_interval > 0 && frame > 0 && frame % recreate_interval == 0 && created < recreate_count { - swapchain = create_vulkan_swapchain_probe(instance, surface, device) - .map_err(|error| VulkanSmokeRunError::RecreateSwapchain { - result: error.to_string(), + if recreate_interval > 0 + && frame > 0 + && frame % recreate_interval == 0 + && created < recreate_count + { + swapchain = + create_vulkan_swapchain_probe(instance, surface, device).map_err(|error| { + VulkanSmokeRunError::RecreateSwapchain { + result: error.to_string(), + } })?; created = created.saturating_add(1); } let image_index = unsafe { - swapchain - .loader() - .acquire_next_image(swapchain.swapchain(), timeout_ns, image_ready, vk::Fence::null()) + swapchain.loader().acquire_next_image( + swapchain.swapchain(), + timeout_ns, + image_ready, + vk::Fence::null(), + ) } .map(|(index, _)| index) .map_err(|error| VulkanSmokeRunError::AcquireImage { @@ -513,10 +534,11 @@ pub fn run_vulkan_smoke_pass( result: format!("{error:?}"), })?; - unsafe { device.device().queue_wait_idle(render_queue) } - .map_err(|error| VulkanSmokeRunError::PresentImage { + unsafe { device.device().queue_wait_idle(render_queue) }.map_err(|error| { + VulkanSmokeRunError::PresentImage { result: format!("{error:?}"), - })?; + } + })?; swaps = swaps.saturating_add(1); } diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 59499bd..4e60ec8 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -16,8 +16,8 @@ use fparkan_platform_winit::{probe_smoke_window, WinitWindowPlan}; use fparkan_render_vulkan::{ create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe, create_vulkan_swapchain_probe, probe_vulkan_loader, run_vulkan_smoke_pass, - triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, - VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSwapchainProbe, + triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, VulkanInstanceProbe, + VulkanLogicalDeviceProbe, VulkanSwapchainProbe, }; use std::path::PathBuf; use std::process::Command; @@ -67,9 +67,13 @@ fn run(args: &[String]) -> Result<String, String> { return Err("passed native smoke report requires frames to be advanced".to_string()); } if smoke_run.validation_error_count - != options.validation_error_count.unwrap_or(smoke_run.validation_error_count) + != options + .validation_error_count + .unwrap_or(smoke_run.validation_error_count) { - return Err("passed native smoke report requires validation errors to be zero".to_string()); + return Err( + "passed native smoke report requires validation errors to be zero".to_string(), + ); } } let report = render_smoke_report_json(&options, &bootstrap)?; @@ -443,12 +447,13 @@ impl VulkanBootstrapProbe { options: &SmokeOptions, instance: &VulkanInstanceProbe, window_handles: Option<NativeWindowHandles>, - ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> - { + ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> { if options.probes.vulkan.includes_surface() && self.instance_status == VulkanInstanceStatus::Created { - match create_vulkan_surface_probe(instance, window_handles).map_err(|err| err.to_string()) { + match create_vulkan_surface_probe(instance, window_handles) + .map_err(|err| err.to_string()) + { Ok(surface) => { self.surface_status = VulkanSurfaceStatus::Created; return Some(surface); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..7717806 --- /dev/null +++ b/deny.toml @@ -0,0 +1,35 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" + +[bans] +multiple-versions = "allow" +wildcards = "deny" +deny = [ + { name = "native-tls" }, + { name = "openssl" }, + { name = "openssl-sys" }, +] + +[licenses] +unlicensed = "deny" +copyleft = "allow" +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "GPL-2.0-only", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/fixtures/acceptance/stage_packages.toml b/fixtures/acceptance/stage_packages.toml new file mode 100644 index 0000000..9a0faaa --- /dev/null +++ b/fixtures/acceptance/stage_packages.toml @@ -0,0 +1,47 @@ +schema = 1 + +[stages] +"0" = [ + "fparkan-binary", + "fparkan-corpus", + "fparkan-diagnostics", + "fparkan-platform", + "fparkan-platform-winit", + "fparkan-render", + "fparkan-render-vulkan", + "fparkan-test-support", + "fparkan-vulkan-smoke", + "xtask", +] +"1" = [ + "fparkan-cli", + "fparkan-inspection", + "fparkan-nres", + "fparkan-path", + "fparkan-resource", + "fparkan-rsli", + "fparkan-vfs", +] +"2" = [ + "fparkan-prototype", +] +"3" = [ + "fparkan-assets", + "fparkan-material", + "fparkan-msh", + "fparkan-texm", + "fparkan-viewer", +] +"4" = [ + "fparkan-animation", + "fparkan-fx", +] +"5" = [ + "fparkan-game", + "fparkan-headless", + "fparkan-mission-format", + "fparkan-runtime", + "fparkan-terrain", + "fparkan-terrain-format", + "fparkan-world", +] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ed40de8..6a9bbb7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -37,11 +37,24 @@ const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT"; const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md"; const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv"; const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json"; +const STAGE_PACKAGE_MANIFEST: &str = "fixtures/acceptance/stage_packages.toml"; const REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["linux", "macos", "windows"]; const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; const SUPPLY_CHAIN_BANNED_PACKAGES: &[&str] = &["native-tls", "openssl", "openssl-sys"]; const PINNED_RUST_TOOLCHAIN: &str = "1.87.0"; const WORKSPACE_MSRV: &str = "1.87"; +const ALLOW_SUPPLY_CHAIN_FALLBACK_ENV: &str = "FPARKAN_ALLOW_SUPPLY_CHAIN_FALLBACK"; + +fn workspace_root_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) + .to_path_buf() +} + +fn workspace_relative_path(path: &str) -> PathBuf { + workspace_root_path().join(path) +} fn main() { let args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -180,13 +193,27 @@ fn run_cargo_fmt_check() -> Result<(), String> { fn run_cargo_deny() -> Result<(), String> { let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into()); - let available = Command::new(&cargo_deny).arg("--version").status(); - match available { - Ok(status) if status.success() => {} - Ok(_) | Err(_) => { - eprintln!("cargo-deny is unavailable; running built-in supply-chain policy fallback"); - return run_builtin_supply_chain_policy(Path::new(".")); - } + let version_output = Command::new(&cargo_deny) + .arg("--version") + .output() + .map_err(|err| { + format!( + "cargo-deny is required; install cargo-deny {PINNED_CARGO_DENY_VERSION} or set {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1 for the built-in fallback: {err}" + ) + })?; + if !version_output.status.success() { + return handle_cargo_deny_fallback(format!( + "cargo-deny --version exited with {}", + version_output.status + )); + } + let version_text = String::from_utf8(version_output.stdout) + .map_err(|err| format!("cargo-deny --version produced invalid UTF-8: {err}"))?; + if !version_text.contains(PINNED_CARGO_DENY_VERSION) { + return handle_cargo_deny_fallback(format!( + "cargo-deny version mismatch: expected {PINNED_CARGO_DENY_VERSION}, found {}", + version_text.trim() + )); } let status = Command::new(cargo_deny) @@ -208,6 +235,21 @@ fn run_cargo_deny() -> Result<(), String> { } } +const PINNED_CARGO_DENY_VERSION: &str = "0.19.9"; + +fn handle_cargo_deny_fallback(reason: String) -> Result<(), String> { + if std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() { + eprintln!( + "{reason}; running built-in supply-chain policy fallback because {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is set" + ); + run_builtin_supply_chain_policy(Path::new(".")) + } else { + Err(format!( + "{reason}; install cargo-deny {PINNED_CARGO_DENY_VERSION} or explicitly opt into the fallback with {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1" + )) + } +} + fn run_builtin_supply_chain_policy(root: &Path) -> Result<(), String> { let mut failures = Vec::new(); validate_workspace_license(root, &mut failures)?; @@ -473,12 +515,7 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<() if !manifest.exists() { return Ok(()); } - let metadata = MetadataCommand::new() - .manifest_path(&manifest) - .no_deps() - .other_options(["--offline".to_string(), "--locked".to_string()]) - .exec() - .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))?; + let metadata = workspace_metadata(root)?; if metadata.workspace_members.is_empty() { failures.push(format!( "{}: cargo metadata produced no workspace members", @@ -486,6 +523,18 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<() )); return Ok(()); } + let stage_manifest_path = root.join(STAGE_PACKAGE_MANIFEST); + let stage_manifest = load_stage_package_manifest(&stage_manifest_path)?; + let workspace_packages = metadata + .workspace_packages() + .iter() + .map(|package| package.name.to_string()) + .collect::<BTreeSet<_>>(); + if let Err(err) = + validate_stage_package_entries(&stage_manifest, &workspace_packages, &stage_manifest_path) + { + failures.push(err); + } Ok(()) } @@ -1185,40 +1234,6 @@ enum Stage { Number(u8), } -const ALL_WORKSPACE_PACKAGES: &[&str] = &[ - "fparkan-animation", - "fparkan-assets", - "fparkan-binary", - "fparkan-corpus", - "fparkan-diagnostics", - "fparkan-fx", - "fparkan-material", - "fparkan-mission-format", - "fparkan-msh", - "fparkan-nres", - "fparkan-path", - "fparkan-platform", - "fparkan-prototype", - "fparkan-render", - "fparkan-resource", - "fparkan-rsli", - "fparkan-runtime", - "fparkan-terrain", - "fparkan-terrain-format", - "fparkan-test-support", - "fparkan-texm", - "fparkan-vfs", - "fparkan-world", - "fparkan-platform-winit", - "fparkan-render-vulkan", - "fparkan-cli", - "fparkan-game", - "fparkan-headless", - "fparkan-vulkan-smoke", - "fparkan-viewer", - "xtask", -]; - impl Stage { fn parse(value: &str) -> Result<Self, String> { if value == "all" { @@ -1390,9 +1405,10 @@ fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String } fn parse_audit_options(args: &[String]) -> Result<AuditOptions, String> { - let mut roadmap = PathBuf::from("FPARKAN_ARCHITECTURE_ROADMAP_STAGES_0_5.md"); - let mut coverage = PathBuf::from("fixtures/acceptance/coverage.tsv"); - let mut out = PathBuf::from("target") + let mut roadmap = workspace_relative_path(CI_ACCEPTANCE_ROADMAP); + let mut coverage = workspace_relative_path(CI_ACCEPTANCE_COVERAGE); + let mut out = workspace_root_path() + .join("target") .join("fparkan") .join("reports") .join("acceptance") @@ -1478,25 +1494,55 @@ fn read_native_smoke_reports(dir: &Path) -> Result<BTreeMap<String, serde_json:: .map_err(|err| format!("{}: {err}", path.display()))?; let platform = json_string_field(&json, "platform") .map_err(|err| format!("{}: {err}", path.display()))?; - reports.insert(platform.to_string(), json); + let platform = platform.to_string(); + if reports.insert(platform.clone(), json).is_some() { + return Err(format!( + "{}: duplicate native smoke report for platform {platform}", + path.display() + )); + } } Ok(reports) } fn audit_native_smoke_reports(reports: &BTreeMap<String, serde_json::Value>) -> Vec<String> { let mut failures = Vec::new(); + let mut commit_shas = BTreeSet::new(); + let mut rust_toolchains = BTreeSet::new(); for platform in REQUIRED_NATIVE_SMOKE_PLATFORMS { let Some(report) = reports.get(*platform) else { failures.push(format!("{platform}: missing native smoke report")); continue; }; validate_native_smoke_report(platform, report, &mut failures); + if let Ok(commit_sha) = json_string_field(report, "commit_sha") { + if commit_sha == "unknown" { + failures.push(format!("{platform}: commit_sha must not be \"unknown\"")); + } else { + commit_shas.insert(commit_sha.to_string()); + } + } + if let Ok(toolchain) = json_string_field(report, "rust_toolchain") { + rust_toolchains.insert(toolchain.to_string()); + } } for platform in reports.keys() { if !REQUIRED_NATIVE_SMOKE_PLATFORMS.contains(&platform.as_str()) { failures.push(format!("{platform}: unexpected native smoke platform")); } } + if commit_shas.len() > 1 { + failures.push(format!( + "native smoke reports disagree on commit_sha: {}", + commit_shas.into_iter().collect::<Vec<_>>().join(", ") + )); + } + if rust_toolchains.len() > 1 { + failures.push(format!( + "native smoke reports disagree on rust_toolchain: {}", + rust_toolchains.into_iter().collect::<Vec<_>>().join(", ") + )); + } failures } @@ -1561,7 +1607,14 @@ fn validate_native_smoke_report( expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures); expect_u64_field(platform, report, "validation_error_count", 0, failures); expect_nonempty_string(platform, report, "commit_sha", failures); - expect_nonempty_string(platform, report, "rust_toolchain", failures); + expect_string_field( + platform, + report, + "rust_toolchain", + &measured_rust_toolchain_version(), + failures, + ); + expect_string_field(platform, report, "platform", platform, failures); expect_nonempty_string(platform, report, "target_triple", failures); expect_nonempty_string(platform, report, "shader_manifest_hash", failures); expect_nonempty_string(platform, report, "vulkan_device_name", failures); @@ -1741,13 +1794,20 @@ impl AcceptanceAudit { self.partial .iter() .chain(&self.blocked) + .chain(&self.omitted) .chain(&self.missing) .cloned() .collect() } fn strict_failures(&self) -> Vec<String> { - self.partial.iter().chain(&self.missing).cloned().collect() + self.partial + .iter() + .chain(&self.blocked) + .chain(&self.omitted) + .chain(&self.missing) + .cloned() + .collect() } } @@ -1855,7 +1915,7 @@ fn build_acceptance_audit( AcceptanceAudit { commit_sha: current_git_commit_sha(), - rust_toolchain: PINNED_RUST_TOOLCHAIN.to_string(), + rust_toolchain: measured_rust_toolchain_version(), msrv: WORKSPACE_MSRV.to_string(), required_total: required.len(), covered, @@ -1930,6 +1990,23 @@ fn current_git_commit_sha() -> String { .unwrap_or_else(|| "unknown".to_string()) } +fn measured_rust_toolchain_version() -> String { + Command::new("rustc") + .args(["-Vv"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|stdout| { + stdout.lines().find_map(|line| { + line.strip_prefix("release:") + .map(str::trim) + .map(ToString::to_string) + }) + }) + .unwrap_or_else(|| PINNED_RUST_TOOLCHAIN.to_string()) +} + fn render_string_usize_map(values: &BTreeMap<String, usize>) -> String { let pairs = values .iter() @@ -1994,6 +2071,7 @@ fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> { fn render_acceptance_report(options: &AcceptanceOptions) -> String { let packages = stage_report_packages(options.stage) + .unwrap_or_default() .into_iter() .map(|package| format!(" \"{package}\"")) .collect::<Vec<_>>() @@ -2023,10 +2101,12 @@ fn render_acceptance_report(options: &AcceptanceOptions) -> String { ) } -fn stage_report_packages(stage: Stage) -> Vec<&'static str> { +fn stage_report_packages(stage: Stage) -> Result<Vec<String>, String> { + let workspace_root = workspace_root_path(); match stage { - Stage::All => ALL_WORKSPACE_PACKAGES.to_vec(), - Stage::Number(number) => stage_packages(number).unwrap_or(&[]).to_vec(), + Stage::All => workspace_package_names(&workspace_root) + .map(|packages| packages.into_iter().collect::<Vec<_>>()), + Stage::Number(number) => stage_packages(number), } } @@ -2052,12 +2132,19 @@ fn run_stage_tests( } Stage::Number(number) => { for package in stage_packages(number)? { - let mut args = vec!["test", "-p", package, "--locked", "--offline"]; - args.extend(suffix.iter().copied()); + let mut args = vec![ + "test".to_string(), + "-p".to_string(), + package, + "--locked".to_string(), + "--offline".to_string(), + ]; + args.extend(suffix.iter().map(|value| (*value).to_string())); if let Some(envs) = envs { - cargo_with_env(&args, &envs)?; + let borrowed = args.iter().map(String::as_str).collect::<Vec<_>>(); + cargo_with_env(&borrowed, &envs)?; } else { - cargo(&args)?; + cargo_owned(&args)?; } } Ok(()) @@ -2065,43 +2152,108 @@ fn run_stage_tests( } } -fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> { - match stage { - 0 => Ok(&[ - "fparkan-corpus", - "fparkan-diagnostics", - "fparkan-test-support", - ]), - 1 => Ok(&[ - "fparkan-binary", - "fparkan-path", - "fparkan-nres", - "fparkan-rsli", - "fparkan-resource", - "fparkan-vfs", - ]), - 2 => Ok(&["fparkan-prototype"]), - 3 => Ok(&[ - "fparkan-msh", - "fparkan-material", - "fparkan-texm", - "fparkan-assets", - "fparkan-render", - "fparkan-viewer", - ]), - 4 => Ok(&["fparkan-animation", "fparkan-fx"]), - 5 => Ok(&[ - "fparkan-terrain-format", - "fparkan-terrain", - "fparkan-mission-format", - "fparkan-world", - "fparkan-runtime", - "fparkan-headless", - "fparkan-game", - "fparkan-vulkan-smoke", - ]), - _ => Err(format!("stage out of range: {stage}")), +fn stage_packages(stage: u8) -> Result<Vec<String>, String> { + let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST); + let manifest = load_stage_package_manifest(&manifest_path)?; + let packages = manifest + .stages + .get(&stage.to_string()) + .cloned() + .ok_or_else(|| format!("stage out of range: {stage}"))?; + validate_stage_package_entries( + &manifest, + &workspace_package_names(&workspace_root_path())?, + &manifest_path, + )?; + Ok(packages) +} + +fn workspace_package_names(root: &Path) -> Result<BTreeSet<String>, String> { + let metadata = workspace_metadata(root)?; + Ok(metadata + .workspace_packages() + .iter() + .map(|package| package.name.to_string()) + .collect()) +} + +fn workspace_metadata(root: &Path) -> Result<cargo_metadata::Metadata, String> { + let manifest = root.join("Cargo.toml"); + MetadataCommand::new() + .manifest_path(&manifest) + .no_deps() + .other_options(["--offline".to_string(), "--locked".to_string()]) + .exec() + .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error)) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StagePackageManifest { + schema: Option<u8>, + stages: BTreeMap<String, Vec<String>>, +} + +fn load_stage_package_manifest(path: &Path) -> Result<StagePackageManifest, String> { + let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; + let manifest = toml::from_str::<StagePackageManifest>(&text) + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + if manifest.schema != Some(1) { + return Err(format!( + "{}: unsupported stage package manifest schema {:?} (expected 1)", + path.display(), + manifest.schema + )); } + Ok(manifest) +} + +fn validate_stage_package_entries( + manifest: &StagePackageManifest, + workspace_packages: &BTreeSet<String>, + path: &Path, +) -> Result<(), String> { + let required_stages = (0_u8..=5_u8) + .map(|stage| stage.to_string()) + .collect::<BTreeSet<_>>(); + let declared_stages = manifest.stages.keys().cloned().collect::<BTreeSet<_>>(); + if declared_stages != required_stages { + return Err(format!( + "{}: stage package manifest must declare stages 0 through 5 exactly once", + path.display() + )); + } + + let mut assigned = BTreeSet::new(); + for (stage, packages) in &manifest.stages { + for package in packages { + if !workspace_packages.contains(package) { + return Err(format!( + "{}: stage {stage} references unknown package {package}", + path.display() + )); + } + if !assigned.insert(package.clone()) { + return Err(format!( + "{}: package {package} is assigned to multiple stages", + path.display() + )); + } + } + } + + let missing = workspace_packages + .difference(&assigned) + .cloned() + .collect::<Vec<_>>(); + if !missing.is_empty() { + return Err(format!( + "{}: stage package manifest is missing workspace packages: {}", + path.display(), + missing.join(", ") + )); + } + Ok(()) } #[cfg(test)] @@ -2233,6 +2385,10 @@ mod tests { assert_eq!(audit.missing, ["S0-ARCH-002"]); assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]); assert_eq!(audit.by_stage.get("S0"), Some(&2)); + assert_eq!( + audit.strict_failures(), + strings(&["L5-RG40-001", "L3-DEVICE-001", "S0-ARCH-002"]) + ); } #[test] @@ -2273,7 +2429,7 @@ mod tests { serde_json::json!({ "schema_version": "fparkan-native-smoke-v1", "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "rust_toolchain": "1.87.0", + "rust_toolchain": measured_rust_toolchain_version(), "target_triple": format!("{platform}-test-target"), "platform": platform, "status": "passed", @@ -2311,7 +2467,7 @@ mod tests { serde_json::json!({ "schema_version": "fparkan-native-smoke-v1", "commit_sha": "0123456789abcdef0123456789abcdef01234567", - "rust_toolchain": "1.87.0", + "rust_toolchain": measured_rust_toolchain_version(), "target_triple": "aarch64-apple-darwin", "platform": "macos", "status": "blocked", @@ -2412,14 +2568,32 @@ mod tests { #[test] fn maps_stage_packages() { - assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-assets"))); - assert!(stage_packages(3).is_ok_and(|packages| packages.contains(&"fparkan-viewer"))); - assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-runtime"))); - assert!(stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game"))); + assert!(stage_packages(0) + .is_ok_and(|packages| packages.contains(&"fparkan-platform".to_string()))); + assert!(stage_packages(0) + .is_ok_and(|packages| packages.contains(&"fparkan-vulkan-smoke".to_string()))); + assert!(stage_packages(1) + .is_ok_and(|packages| packages.contains(&"fparkan-inspection".to_string()))); + assert!(stage_packages(5) + .is_ok_and(|packages| packages.contains(&"fparkan-runtime".to_string()))); + assert!( + stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game".to_string())) + ); assert_eq!(stage_packages(9), Err("stage out of range: 9".to_string())); } #[test] + fn stage_package_manifest_covers_workspace_once() -> Result<(), String> { + let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST); + let manifest = load_stage_package_manifest(&manifest_path)?; + let workspace_packages = workspace_package_names(&workspace_root_path())?; + + validate_stage_package_entries(&manifest, &workspace_packages, &manifest_path)?; + + Ok(()) + } + + #[test] fn parses_manifest_dependencies_for_arch_policy() { let manifest = r#" [package] |
