aboutsummaryrefslogtreecommitdiff
path: root/xtask/src
diff options
context:
space:
mode:
Diffstat (limited to 'xtask/src')
-rw-r--r--xtask/src/main.rs504
1 files changed, 338 insertions, 166 deletions
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 2bf6d07..8f67468 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -2,7 +2,9 @@
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! Repository automation for `FParkan`.
+use cargo_metadata::MetadataCommand;
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
+use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fmt::Write as _;
@@ -13,6 +15,9 @@ use std::process::Command;
const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
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";
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -29,10 +34,27 @@ fn main() {
fn run(args: &[String]) -> Result<(), String> {
match args {
[cmd] if cmd == "ci" => {
- run_rustfmt_check(Path::new("."))?;
+ run_cargo_fmt_check()?;
run_policy(Path::new("."))?;
- cargo(&["test", "--workspace", "--locked", "--offline"])?;
- clippy_rustup(&["--workspace", "--locked", "--offline"])?;
+ cargo(&["test", "--workspace", "--all-targets", "--all-features", "--locked"])?;
+ cargo(&[
+ "clippy",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--locked",
+ "--",
+ "-D",
+ "warnings",
+ ])?;
+ run_cargo_doc()?;
+ run_cargo_deny()?;
+ run_acceptance_audit(&AuditOptions {
+ roadmap: PathBuf::from(CI_ACCEPTANCE_ROADMAP),
+ coverage: PathBuf::from(CI_ACCEPTANCE_COVERAGE),
+ out: PathBuf::from(CI_ACCEPTANCE_REPORT),
+ strict: true,
+ })?;
Ok(())
}
[cmd] if cmd == "policy" => run_policy(Path::new(".")),
@@ -115,63 +137,53 @@ fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> {
}
}
-fn clippy_rustup(args: &[&str]) -> Result<(), String> {
- let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
- let status = Command::new(rustup)
- .args(["run", "stable", "cargo-clippy"])
- .args(args)
+fn run_cargo_fmt_check() -> Result<(), String> {
+ let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let status = Command::new(cargo)
+ .args(["fmt", "--all", "--", "--check"])
.status()
- .map_err(|err| format!("failed to run cargo-clippy through rustup: {err}"))?;
+ .map_err(|err| format!("failed to run rustfmt: {err}"))?;
if status.success() {
Ok(())
} else {
- Err(format!("cargo-clippy exited with {status}"))
+ Err(format!("cargo fmt exited with {status}"))
}
}
-fn run_rustfmt_check(root: &Path) -> Result<(), String> {
- let mut files = Vec::new();
- collect_rust_files(root, &mut files)?;
- if files.is_empty() {
- return Ok(());
- }
-
- let rustup = std::env::var_os("RUSTUP").unwrap_or_else(|| "rustup".into());
- let status = Command::new(rustup)
- .args(["run", "stable", "rustfmt", "--check"])
- .args(files)
+fn run_cargo_deny() -> Result<(), String> {
+ let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let status = Command::new(cargo)
+ .args([
+ "deny",
+ "check",
+ "--workspace",
+ "--all-features",
+ "advisories",
+ "bans",
+ "licenses",
+ "sources",
+ ])
.status()
- .map_err(|err| format!("failed to run rustfmt: {err}"))?;
+ .map_err(|err| format!("failed to run cargo-deny: {err}"))?;
if status.success() {
Ok(())
} else {
- Err(format!("rustfmt exited with {status}"))
+ Err(format!("cargo-deny exited with {status}"))
}
}
-fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
- let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
- for entry in entries {
- let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
- let path = entry.path();
- if should_skip_policy_path(&path) {
- continue;
- }
- let file_type = entry
- .file_type()
- .map_err(|err| format!("{}: {err}", path.display()))?;
- if file_type.is_dir() {
- collect_rust_files(&path, out)?;
- } else if file_type.is_file()
- && path
- .extension()
- .and_then(|ext| ext.to_str())
- .is_some_and(|ext| ext == "rs")
- {
- out.push(path);
- }
+fn run_cargo_doc() -> Result<(), String> {
+ let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
+ let status = Command::new(cargo)
+ .args(["doc", "--workspace", "--all-features", "--locked", "--no-deps"])
+ .env("RUSTDOCFLAGS", "-D warnings -D rustdoc::broken_intra_doc_links")
+ .status()
+ .map_err(|err| format!("failed to run cargo doc: {err}"))?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err(format!("cargo doc exited with {status}"))
}
- Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -197,67 +209,77 @@ fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, S
format!(
"licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
)
- })?;
+ })?;
parse_licensed_manifest(&manifest)
}
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct LicensedManifest {
+ schema: Option<u8>,
+ #[serde(rename = "corpus")]
+ corpora: Vec<CorpusEntry>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct CorpusEntry {
+ id: String,
+ kind: CorpusKind,
+ root: String,
+ expected_profile: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum CorpusKind {
+ Part1,
+ Part2,
+}
+
fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let manifest: LicensedManifest = toml::from_str(&text)
+ .map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
+ if manifest.schema.is_some_and(|version| version != 1) {
+ return Err(format!(
+ "unsupported corpora manifest schema {} (expected 1)",
+ manifest.schema.unwrap_or(1)
+ ));
+ }
+
let mut part1 = None;
let mut part2 = None;
- let mut current_kind: Option<String> = None;
- let mut current_root: Option<PathBuf> = None;
- for raw_line in text.lines() {
- let line = raw_line.split('#').next().unwrap_or_default().trim();
- if line.is_empty() {
- continue;
- }
- if line == "[[corpus]]" {
- flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
- continue;
+ for entry in manifest.corpora {
+ match entry.kind {
+ CorpusKind::Part1 => {
+ let root = PathBuf::from(entry.root);
+ assign_manifest_root(&mut part1, root, "part1")?;
+ }
+ CorpusKind::Part2 => {
+ let root = PathBuf::from(entry.root);
+ assign_manifest_root(&mut part2, root, "part2")?;
+ }
}
- let Some((key, value)) = line.split_once('=') else {
- continue;
- };
- let key = key.trim();
- match key {
- "kind" => current_kind = Some(parse_manifest_string(value.trim())?),
- "root" => current_root = Some(PathBuf::from(parse_manifest_string(value.trim())?)),
- _ => {}
+ if entry.expected_profile.is_none() {
+ return Err(format!(
+ "{}: corpus entry '{}' must define expected_profile",
+ path.display(),
+ entry.id
+ ));
}
}
- flush_manifest_entry(&mut part1, &mut part2, &mut current_kind, &mut current_root)?;
let roots = LicensedCorpusRoots {
- part1: part1.ok_or_else(|| "licensed manifest is missing kind = \"part1\"".to_string())?,
- part2: part2.ok_or_else(|| "licensed manifest is missing kind = \"part2\"".to_string())?,
+ part1: part1.ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?,
+ part2: part2.ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?,
};
validate_licensed_part("part1", &roots.part1)?;
validate_licensed_part("part2", &roots.part2)?;
Ok(roots)
}
-fn flush_manifest_entry(
- part1: &mut Option<PathBuf>,
- part2: &mut Option<PathBuf>,
- current_kind: &mut Option<String>,
- current_root: &mut Option<PathBuf>,
-) -> Result<(), String> {
- let Some(kind) = current_kind.take() else {
- *current_root = None;
- return Ok(());
- };
- let root = current_root
- .take()
- .ok_or_else(|| format!("licensed manifest entry {kind} is missing root"))?;
- match kind.as_str() {
- "part1" => assign_manifest_root(part1, root, "part1"),
- "part2" => assign_manifest_root(part2, root, "part2"),
- _ => Ok(()),
- }
-}
-
fn assign_manifest_root(
target: &mut Option<PathBuf>,
root: PathBuf,
@@ -269,18 +291,6 @@ fn assign_manifest_root(
Ok(())
}
-fn parse_manifest_string(value: &str) -> Result<String, String> {
- let trimmed = value.trim();
- if let Some(quoted) = trimmed
- .strip_prefix('"')
- .and_then(|value| value.strip_suffix('"'))
- {
- Ok(quoted.to_string())
- } else {
- Err(format!("manifest value must be a quoted string: {trimmed}"))
- }
-}
-
fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> {
if root.is_dir() {
Ok(())
@@ -400,27 +410,24 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
if !manifest.exists() {
return Ok(());
}
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let output = Command::new(cargo)
- .args([
- "metadata",
- "--format-version",
- "1",
- "--offline",
- "--locked",
- "--no-deps",
- "--manifest-path",
- ])
- .arg(&manifest)
- .output()
- .map_err(|err| format!("failed to run cargo metadata: {err}"))?;
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
+ let metadata = MetadataCommand::new()
+ .manifest_path(&manifest)
+ .no_deps(true)
+ .other_options(["--offline".to_string(), "--locked".to_string()])
+ .exec()
+ .map_err(|error| {
+ format!(
+ "{}: cargo metadata failed: {}",
+ manifest.display(),
+ error
+ )
+ })?;
+ if metadata.workspace_members.is_empty() {
failures.push(format!(
- "{}: cargo metadata failed: {}",
- manifest.display(),
- stderr.trim()
+ "{}: cargo metadata produced no workspace members",
+ manifest.display()
));
+ return Ok(());
}
Ok(())
}
@@ -490,34 +497,173 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
let Some(package) = parse_package_name(&text) else {
continue;
};
+ if is_removed_legacy_adapter_manifest(root, &manifest) {
+ failures.push(format!(
+ "{}: legacy SDL/OpenGL adapter crate must be removed: {package}",
+ manifest.display()
+ ));
+ continue;
+ }
let dependencies = parse_manifest_dependencies(&text);
- if is_domain_manifest(root, &manifest) {
+ if !is_adapter_like_package(&package) {
for dependency in &dependencies {
- if is_forbidden_domain_dependency(dependency) {
+ if is_forbidden_gui_dependency(dependency) {
failures.push(format!(
- "{}: domain package {package} depends on forbidden GUI/adapter package {dependency}",
+ "{}: package {package} depends on forbidden GUI/adapter package {dependency}",
manifest.display()
));
}
}
}
- if package == "fparkan-headless" {
+ if is_app_package(&package) {
+ if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
+ failures.push(format!(
+ "{}: app package {package} depends on parser crate {forbidden}",
+ manifest.display()
+ ));
+ }
for dependency in &dependencies {
- if matches!(
- dependency.as_str(),
- "fparkan-platform-sdl" | "fparkan-render-gl"
- ) {
+ if is_forbidden_runtime_bridge_dependency(dependency) {
failures.push(format!(
- "{}: fparkan-headless depends on forbidden platform/render adapter {dependency}",
+ "{}: app package {package} depends on forbidden bridge dependency {dependency}",
manifest.display()
));
}
}
}
+
+ if package == "fparkan-runtime" {
+ if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
+ failures.push(format!(
+ "{}: runtime package {package} depends on parser crate {forbidden}",
+ manifest.display()
+ ));
+ }
+ if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
+ failures.push(format!(
+ "{}: runtime package {package} depends on forbidden platform/driver crate {forbidden}",
+ manifest.display()
+ ));
+ }
+ }
+
+ if package == "fparkan-prototype" {
+ if let Some(forbidden) = first_forbidden_visual_dependency(&dependencies) {
+ failures.push(format!(
+ "{}: prototype package {package} depends on forbidden visual parser {forbidden}",
+ manifest.display()
+ ));
+ }
+ }
}
Ok(())
}
+fn is_app_package(package: &str) -> bool {
+ matches!(
+ package,
+ "fparkan-cli" | "fparkan-game" | "fparkan-headless" | "fparkan-viewer"
+ )
+}
+
+fn is_adapter_like_package(package: &str) -> bool {
+ matches!(
+ package,
+ "fparkan-platform-winit" | "fparkan-render-vulkan"
+ )
+}
+
+fn first_forbidden_parser_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
+ [
+ "fparkan-msh",
+ "fparkan-nres",
+ "fparkan-rsli",
+ "fparkan-terrain-format",
+ "fparkan-texm",
+ "fparkan-mission-format",
+ "fparkan-material",
+ "fparkan-fx",
+ ]
+ .iter()
+ .find_map(|forbidden| {
+ if dependencies.contains(*forbidden) {
+ Some(*forbidden)
+ } else {
+ None
+ }
+ })
+}
+
+fn first_forbidden_visual_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
+ [
+ "fparkan-msh",
+ "fparkan-material",
+ "fparkan-texm",
+ "fparkan-fx",
+ "fparkan-terrain-format",
+ ]
+ .iter()
+ .find_map(|forbidden| {
+ if dependencies.contains(*forbidden) {
+ Some(*forbidden)
+ } else {
+ None
+ }
+ })
+}
+
+fn first_forbidden_platform_bridge_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
+ [
+ "fparkan-platform-winit",
+ "fparkan-render-vulkan",
+ "winit",
+ "ash",
+ "ash-window",
+ ]
+ .iter()
+ .find_map(|forbidden| {
+ if dependencies.contains(*forbidden) {
+ Some(*forbidden)
+ } else {
+ None
+ }
+ })
+}
+
+fn is_forbidden_runtime_bridge_dependency(dependency: &str) -> bool {
+ matches!(
+ dependency,
+ "fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
+ )
+}
+
+fn is_forbidden_domain_dependency(dependency: &str) -> bool {
+ matches!(
+ dependency, "fparkan-cli"
+ | "fparkan-game"
+ | "fparkan-headless"
+ | "fparkan-viewer"
+ | "fparkan-platform-sdl"
+ | "fparkan-render-gl"
+ | "sdl2"
+ | "gl"
+ | "glow"
+ | "glium"
+ | "glutin"
+ )
+}
+
+fn is_forbidden_gui_dependency(dependency: &str) -> bool {
+ is_forbidden_domain_dependency(dependency) || is_forbidden_platform_dependency(dependency)
+}
+
+fn is_forbidden_platform_dependency(dependency: &str) -> bool {
+ matches!(
+ dependency,
+ "fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
+ )
+}
+
fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
for entry in entries {
@@ -610,30 +756,10 @@ fn parse_toml_string_value(line: &str) -> Option<String> {
Some(value.trim_matches('"').to_string())
}
-fn is_domain_manifest(root: &Path, manifest: &Path) -> bool {
- let relative = manifest.strip_prefix(root).unwrap_or(manifest);
- relative
- .components()
- .next()
- .is_some_and(|component| component.as_os_str() == "crates")
-}
-
-fn is_forbidden_domain_dependency(dependency: &str) -> bool {
- matches!(
- dependency,
- "fparkan-platform-sdl"
- | "fparkan-render-gl"
- | "fparkan-cli"
- | "fparkan-game"
- | "fparkan-headless"
- | "fparkan-viewer"
- | "sdl2"
- | "gl"
- | "glow"
- | "glium"
- | "glutin"
- | "winit"
- )
+fn is_removed_legacy_adapter_manifest(root: &Path, manifest: &Path) -> bool {
+ let normalized = manifest.strip_prefix(root).unwrap_or(manifest);
+ normalized.starts_with("adapters/fparkan-platform-sdl")
+ || normalized.starts_with("adapters/fparkan-render-gl")
}
fn scan_policy_dir(dir: &Path, failures: &mut Vec<String>) -> Result<(), String> {
@@ -752,18 +878,27 @@ fn scan_policy_file(path: &Path, failures: &mut Vec<String>) -> Result<(), Strin
path.display()
));
}
+ let mut previous_line_has_safety_comment = false;
for (index, line) in text.lines().enumerate() {
let trimmed = line.trim_start();
- if trimmed.starts_with("//") || trimmed.starts_with("//!") || trimmed.starts_with("///") {
+ if is_comment_line(trimmed) {
+ previous_line_has_safety_comment = has_safety_comment(trimmed);
+ continue;
+ }
+ if trimmed.is_empty() {
+ previous_line_has_safety_comment = false;
continue;
}
if contains_unsafe_construct(trimmed) {
- failures.push(format!(
- "{}:{}: unsafe construct in workspace source",
- path.display(),
- index + 1
- ));
+ if !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment) {
+ failures.push(format!(
+ "{}:{}: unsafe construct in workspace source",
+ path.display(),
+ index + 1
+ ));
+ }
}
+ previous_line_has_safety_comment = false;
}
Ok(())
}
@@ -775,6 +910,34 @@ fn contains_unsafe_construct(line: &str) -> bool {
|| line.contains(concat!("extern ", "\"C\""))
}
+fn is_comment_line(line: &str) -> bool {
+ line.starts_with("//")
+ || line.starts_with("//!")
+ || line.starts_with("///")
+}
+
+fn has_safety_comment(line: &str) -> bool {
+ line.contains("SAFETY:")
+}
+
+const AUDITED_UNSAFE_SOURCE_FILES: &[&str] = &["adapters/fparkan-render-vulkan/src/lib.rs"];
+
+fn is_audited_unsafe_source(path: &Path) -> bool {
+ let as_path = path.as_os_str().to_string_lossy();
+ AUDITED_UNSAFE_SOURCE_FILES.iter().any(|candidate| as_path.ends_with(candidate))
+}
+
+fn is_authorized_unsafe_construct(
+ path: &Path,
+ line: &str,
+ previous_line_has_safety_comment: bool,
+) -> bool {
+ if !is_audited_unsafe_source(path) {
+ return false;
+ }
+ previous_line_has_safety_comment || has_safety_comment(line)
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Stage {
All,
@@ -805,8 +968,8 @@ const ALL_WORKSPACE_PACKAGES: &[&str] = &[
"fparkan-texm",
"fparkan-vfs",
"fparkan-world",
- "fparkan-platform-sdl",
- "fparkan-render-gl",
+ "fparkan-platform-winit",
+ "fparkan-render-vulkan",
"fparkan-cli",
"fparkan-game",
"fparkan-headless",
@@ -1033,11 +1196,11 @@ fn run_acceptance_audit(options: &AuditOptions) -> Result<(), String> {
fs::write(&options.out, render_audit_json(&audit))
.map_err(|err| format!("{}: {err}", options.out.display()))?;
println!("{}", options.out.display());
- let unverified = audit.unverified();
- if options.strict && (!unverified.is_empty() || !audit.unknown_coverage.is_empty()) {
+ let strict_failures = audit.strict_failures();
+ if options.strict && (!strict_failures.is_empty() || !audit.unknown_coverage.is_empty()) {
Err(format!(
- "acceptance coverage incomplete: {} unverified, {} unknown",
- unverified.len(),
+ "acceptance coverage incomplete: {} strict failures, {} unknown",
+ strict_failures.len(),
audit.unknown_coverage.len()
))
} else {
@@ -1093,6 +1256,14 @@ impl AcceptanceAudit {
.cloned()
.collect()
}
+
+ fn strict_failures(&self) -> Vec<String> {
+ self.partial
+ .iter()
+ .chain(&self.missing)
+ .cloned()
+ .collect()
+ }
}
fn extract_acceptance_ids(text: &str) -> BTreeSet<String> {
@@ -1666,7 +1837,7 @@ fparkan-render = { path = "../fparkan-render" }
"quoted-dep" = "1"
[dev-dependencies]
-fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" }
+fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
"#;
assert_eq!(
@@ -1676,13 +1847,14 @@ fparkan-render-gl = { path = "../../adapters/fparkan-render-gl" }
let deps = parse_manifest_dependencies(manifest);
assert!(deps.contains("fparkan-render"));
assert!(deps.contains("quoted-dep"));
- assert!(deps.contains("fparkan-render-gl"));
+ assert!(deps.contains("fparkan-render-vulkan"));
}
#[test]
fn detects_forbidden_domain_dependencies() {
- assert!(is_forbidden_domain_dependency("fparkan-render-gl"));
+ assert!(!is_forbidden_domain_dependency("fparkan-render-vulkan"));
assert!(is_forbidden_domain_dependency("sdl2"));
+ assert!(is_forbidden_domain_dependency("fparkan-platform-sdl"));
assert!(!is_forbidden_domain_dependency("fparkan-render"));
assert!(!is_forbidden_domain_dependency("fparkan-platform"));
}