//! Dependency-aware validation orchestration. No third-party Rust dependencies. use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; type Result = std::result::Result; type Set = BTreeSet; fn python_program(value: Option<&str>) -> Result { let value = value.ok_or("PYTHON is unset; run this command inside nix develop")?; let path = Path::new(value); if !value.starts_with("/nix/store/") || !value.ends_with("/bin/python3") || path.components().any(|part| { matches!( part, std::path::Component::ParentDir | std::path::Component::CurDir ) }) { return Err( "PYTHON must be the flake's absolute Nix-store Python executable; run nix develop" .into(), ); } Ok(value.into()) } fn python_runtime() -> Result { python_program(std::env::var("PYTHON").ok().as_deref()) } #[cfg(test)] mod python_tests { use super::*; #[test] fn missing_system_and_relative_python_are_rejected_without_launching() { assert!(python_program(None).unwrap_err().contains("nix develop")); for value in [ "", "python3", "/usr/bin/python3", "/Library/Developer/CommandLineTools/usr/bin/python3", "/nix/store/../../usr/bin/python3", ] { assert!(python_program(Some(value)).is_err(), "{value}"); } } #[test] fn explicit_nix_dependency_is_accepted() { let value = "/nix/store/example-python3/bin/python3"; assert_eq!(python_program(Some(value)).unwrap(), value); } } #[derive(Default, Debug)] pub struct Graph { nodes: Set, edges: BTreeMap, owners: BTreeMap, outputs: BTreeMap, groups: BTreeMap>, dynamic: Set, unsafe_nodes: Set, warnings: Vec, } fn clean_path(s: &str) -> Result { if s.is_empty() || s.contains(['\t', '\n', '\r', '\0', '\\']) || Path::new(s).is_absolute() || Path::new(s) .components() .any(|c| !matches!(c, std::path::Component::Normal(_))) { return Err(format!("expected a repository-relative path, got {s:?}")); } Ok(s.to_owned()) } impl Graph { fn edge(&mut self, input: &str, consumer: &str) { self.nodes.insert(input.into()); self.nodes.insert(consumer.into()); self.edges .entry(input.into()) .or_default() .insert(consumer.into()); } fn output(&mut self, producer: &str, output: &str) -> Result<()> { clean_path(producer)?; clean_path(output)?; if !producer.starts_with("scripts/analyze_") || !producer.ends_with(".py") { return Err(format!("invalid report producer: {producer}")); } if let Some(old) = self.owners.insert(output.into(), producer.into()) { if old != producer { return Err(format!( "multiple producers for {output}: {old}, {producer}" )); } } self.outputs .entry(producer.into()) .or_default() .insert(output.into()); self.edge(producer, output); Ok(()) } fn registry(text: &str) -> Result { let mut g = Self::default(); for (n, line) in text.lines().enumerate() { if line.trim().is_empty() || line.starts_with('#') { continue; } let parts: Vec<_> = line.split('\t').collect(); match parts.as_slice() { ["version", "1"] => (), ["report", producer, output] => g.output(producer, output)?, ["edge", input, consumer] => { clean_path(input)?; clean_path(consumer)?; g.edge(input, consumer); } ["dynamic", producer] => { g.dynamic.insert(clean_path(producer)?); } ["group", name, prefix] => { clean_path(prefix)?; g.groups .entry((*name).into()) .or_default() .push((*prefix).into()); } _ => return Err(format!("invalid registry line {}: {line}", n + 1)), } } if text.lines().filter(|l| *l == "version\t1").count() != 1 { return Err("registry requires exactly one version 1 header".into()); } Ok(g) } fn scanner(&mut self, text: &str) -> Result<()> { for line in text.lines() { let parts: Vec<_> = line.split('\t').collect(); match parts.as_slice() { ["node", path] => { self.nodes.insert(clean_path(path)?); } ["edge", input, consumer] => { clean_path(input)?; clean_path(consumer)?; self.edge(input, consumer); } ["output", producer, output] => { if self.owners.get(*output).map(String::as_str) != Some(*producer) { self.warnings.push(format!( "unregistered output {output} from {producer}; add a report row" )); self.output(producer, output)?; } } ["dynamic", producer] => { if !self.dynamic.contains(*producer) { self.warnings.push(format!( "unregistered dynamic writer {producer}; review checks.tsv" )); self.unsafe_nodes.insert((*producer).into()); } } ["unsafe", path] => { self.unsafe_nodes.insert((*path).into()); } _ => return Err(format!("invalid AST reader record: {line}")), } } // A writer's own output literals are not input dependencies. for (output, producer) in &self.owners { if let Some(consumers) = self.edges.get_mut(output) { consumers.remove(producer); } } Ok(()) } fn closure(&self, roots: &Set) -> BTreeMap { let mut why: BTreeMap<_, _> = roots .iter() .map(|p| (p.clone(), "selected input".into())) .collect(); let mut queue: VecDeque<_> = roots.iter().cloned().collect(); while let Some(p) = queue.pop_front() { if let Some(children) = self.edges.get(&p) { for child in children { if !why.contains_key(child) { why.insert(child.clone(), format!("depends on {p}")); queue.push_back(child.clone()); } } } } why } fn producers_in_order(&self, selected: &Set) -> Result> { // Report dependencies can pass through imported helpers. Imports alone // never require executing an imported module's main(). let mut required: BTreeMap = selected.iter().map(|p| (p.clone(), Set::new())).collect(); for producer in selected { let mut seen = Set::new(); let mut todo = VecDeque::new(); for output in &self.outputs[producer] { todo.push_back(output.clone()); } while let Some(node) = todo.pop_front() { if !seen.insert(node.clone()) { continue; } if selected.contains(&node) && &node != producer { required.get_mut(&node).unwrap().insert(producer.clone()); continue; } if let Some(children) = self.edges.get(&node) { for child in children { // Do not cross other output boundaries when finding direct prerequisites. if !self.owners.contains_key(child) { todo.push_back(child.clone()); } } } } } let mut done = Set::new(); let mut order = Vec::new(); while done.len() < selected.len() { let next = required .iter() .find(|(p, deps)| !done.contains(*p) && deps.is_subset(&done)) .map(|(p, _)| p.clone()); let Some(next) = next else { return Err(format!( "report dependency cycle among {:?}", selected.difference(&done).collect::>() )); }; done.insert(next.clone()); order.push(next); } Ok(order) } } #[derive(Debug)] struct Plan { tests: Set, producers: Vec, reasons: BTreeMap, fallback: Vec, rust: bool, } fn is_test(s: &str) -> bool { s.starts_with("scripts/test_") && s.ends_with(".py") } fn doc_only(s: &str) -> bool { s.starts_with("docs/") && s.ends_with(".md") } fn plan(g: &Graph, changed: &Set, force_full: bool, root: &Path) -> Result { let mut roots = changed.clone(); for p in changed { if let Some(owner) = g.owners.get(p) { roots.insert(owner.clone()); } if p.starts_with("crates/urt-assets/") { roots.insert("scripts/analyze_rust_md3.py".into()); } } let reasons = g.closure(&roots); let mut fallback = g.warnings.clone(); for p in changed { if p.starts_with("xtask/") || p.starts_with(".cargo/") || matches!(p.as_str(), "Cargo.toml" | "Cargo.lock") { fallback.push(format!("runner/toolchain/workspace change: {p}")); } else if !g.nodes.contains(p) && !doc_only(p) && !p.starts_with("crates/urt-assets/") { fallback.push(format!("unmapped change: {p}")); } } for p in reasons.keys() { if g.unsafe_nodes.contains(p) { fallback.push(format!( "dynamic dependency/writer requires broad checks: {p}" )); } } let mut tests: Set = reasons .keys() .filter(|p| is_test(p) && root.join(p).is_file()) .cloned() .collect(); let producers: Set = g .outputs .keys() .filter(|p| reasons.contains_key(*p)) .cloned() .collect(); let code_change = changed.iter().any(|p| !doc_only(p)); if code_change && tests.is_empty() && !changed.is_empty() { fallback.push("no mapped test consumers; use full suite".into()); } if force_full || !fallback.is_empty() { tests = g .nodes .iter() .filter(|p| is_test(p) && root.join(p).is_file()) .cloned() .collect(); } // Refuse unreviewed dynamic writers, rather than passing against stale reports. for p in &producers { if g.unsafe_nodes.contains(p) { return Err(format!( "cannot safely regenerate {p}; declare/review its dynamic outputs in xtask/checks.tsv (full test fallback alone cannot validate stale reports)" )); } } let rust = force_full || !fallback.is_empty() || changed.iter().any(|p| { p.starts_with("crates/") || p.starts_with("xtask/") || p.starts_with(".cargo/") || matches!(p.as_str(), "Cargo.toml" | "Cargo.lock") }); Ok(Plan { tests, producers: g.producers_in_order(&producers)?, reasons, fallback, rust, }) } #[derive(Clone, Debug, PartialEq, Eq)] struct Cmd { program: String, args: Vec, } impl Cmd { fn new(program: &str, args: impl IntoIterator>) -> Self { Self { program: program.into(), args: args.into_iter().map(Into::into).collect(), } } fn display(&self) -> String { format!( "{} {}", self.program, self.args .iter() .map(|s| format!("{s:?}")) .collect::>() .join(" ") ) } } trait Runner { fn run(&mut self, cmd: &Cmd) -> Result<()>; } struct Process { root: PathBuf, } impl Runner for Process { fn run(&mut self, c: &Cmd) -> Result<()> { println!("+ {}", c.display()); let status = Command::new(&c.program) .args(&c.args) .current_dir(&self.root) .env("PYTHONPATH", self.root.join("scripts")) .status() .map_err(|e| format!("{}: {e}", c.program))?; if status.success() { Ok(()) } else { Err(format!("command failed ({status}): {}", c.display())) } } } fn capture(root: &Path, c: &Cmd) -> Result { let out = Command::new(&c.program) .args(&c.args) .current_dir(root) .output() .map_err(|e| format!("{}: {e}", c.program))?; if !out.status.success() { return Err(format!( "{} failed: {}", c.display(), String::from_utf8_lossy(&out.stderr) )); } String::from_utf8(out.stdout).map_err(|e| format!("non-UTF-8 command output: {e}")) } fn nul_paths(text: &str) -> Result { text.split('\0') .filter(|s| !s.is_empty()) .map(clean_path) .collect() } fn changes(root: &Path, since: Option<&str>) -> Result { let mut paths = Set::new(); for args in [ vec!["diff", "--name-only", "--no-renames", "-z"], vec!["diff", "--cached", "--name-only", "--no-renames", "-z"], vec!["ls-files", "--others", "--exclude-standard", "-z"], ] { paths.extend(nul_paths(&capture(root, &Cmd::new("git", args))?)?); } if let Some(rev) = since { let oid = capture( root, &Cmd::new( "git", [ "rev-parse", "--verify", "--end-of-options", &format!("{rev}^{{commit}}"), ], ), )?; paths.extend(nul_paths(&capture( root, &Cmd::new( "git", [ "diff", "--name-only", "--no-renames", "-z", oid.trim(), "HEAD", "--", ], ), )?)?); } Ok(paths) } fn snapshots(root: &Path, outputs: &Set) -> Result>> { outputs .iter() .map(|p| { fs::read(root.join(p)) .map(|b| (p.clone(), b)) .map_err(|e| format!("expected generated output {p}: {e}")) }) .collect() } fn regenerate( root: &Path, g: &Graph, producer: &str, python: &str, runner: &mut impl Runner, ) -> Result<()> { let c = Cmd::new(python, [producer]); runner.run(&c)?; let first = snapshots(root, &g.outputs[producer])?; runner.run(&c)?; let second = snapshots(root, &g.outputs[producer])?; if first != second { return Err(format!("nondeterministic report outputs from {producer}")); } println!("Verified deterministic outputs: {producer}"); Ok(()) } fn docs_check(root: &Path) -> Result { fn walk(root: &Path, dir: &Path, count: &mut usize) -> Result<()> { for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { let path = entry.map_err(|e| e.to_string())?.path(); if path.is_dir() { walk(root, &path, count)?; } else if path.extension().and_then(|s| s.to_str()) == Some("md") { *count += 1; let text = fs::read_to_string(&path).map_err(|e| e.to_string())?; for part in text.split("](").skip(1) { let Some(end) = part.find(')') else { continue; }; let target = part[..end] .trim() .trim_start_matches('<') .trim_end_matches('>') .split('#') .next() .unwrap(); if target.is_empty() || target.starts_with('/') || target.contains("://") || target.starts_with("mailto:") { continue; } if !path.parent().unwrap_or(root).join(target).exists() { return Err(format!( "broken documentation link in {}: {target}", path.display() )); } } } } Ok(()) } let mut count = 0; walk(root, &root.join("docs"), &mut count)?; Ok(count) } fn execute(root: &Path, g: &Graph, p: &Plan, python: &str, runner: &mut impl Runner) -> Result<()> { for producer in &p.producers { regenerate(root, g, producer, python, runner)?; } if p.rust { runner.run(&Cmd::new("cargo", ["test", "--offline", "--workspace"]))?; } // Chunking avoids platform argv limits without rediscovering unrelated tests. let modules: Vec<_> = p .tests .iter() .map(|p| { p.trim_start_matches("scripts/") .trim_end_matches(".py") .to_string() }) .collect(); for chunk in modules.chunks(80) { let mut args = vec!["-m".to_string(), "unittest".into()]; args.extend_from_slice(chunk); runner.run(&Cmd::new(python, args))?; } runner.run(&Cmd::new( python, ["scripts/storage_policy.py", "check", "--require-setup"], ))?; println!("Documentation links pass ({} files)", docs_check(root)?); runner.run(&Cmd::new("git", ["diff", "--check"]))?; runner.run(&Cmd::new("git", ["diff", "--cached", "--check"]))?; Ok(()) } pub fn run(args: Vec) -> Result<()> { if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { println!( "cargo xtask test-changed [--since REV] [--path PATH ...] [--dry-run]\ncargo xtask test-group NAME [--dry-run]\ncargo xtask test-full [--since REV] [--dry-run]\ncargo xtask list-groups\nPYTHON must come from nix develop; no system-Python fallback. Run from anywhere inside the checkout. Reports regenerate twice before tests." ); return Ok(()); } let root = PathBuf::from( capture( &std::env::current_dir().map_err(|e| e.to_string())?, &Cmd::new("git", ["rev-parse", "--show-toplevel"]), )? .trim(), ); let python = python_runtime()?; let mode = &args[0]; let mut index = 1; let mut group = None; let mut since = None; let mut dry = false; let mut extra = Set::new(); if mode == "test-group" { group = Some( args.get(index) .ok_or("test-group requires a group name")? .clone(), ); index += 1; } while index < args.len() { match args[index].as_str() { "--dry-run" => dry = true, "--since" => { index += 1; since = Some( args.get(index) .ok_or("--since requires a revision")? .clone(), ); } "--path" => { index += 1; extra.insert(clean_path( args.get(index).ok_or("--path requires a path")?, )?); } other => return Err(format!("unknown option: {other}")), } index += 1; } if !matches!( mode.as_str(), "test-changed" | "test-group" | "test-full" | "list-groups" ) { return Err(format!("unknown command: {mode}")); } let mut g = Graph::registry( &fs::read_to_string(root.join("xtask/checks.tsv")).map_err(|e| e.to_string())?, )?; let mut changed = changes(&root, since.as_deref())?; changed.extend(extra); let mut scan_args = vec!["-I".into(), "xtask/python_graph.py".into()]; scan_args.extend(changed.iter().cloned()); g.scanner(&capture(&root, &Cmd::new(&python, scan_args))?)?; if mode == "list-groups" { for name in g.groups.keys() { println!("{name}"); } return Ok(()); } if let Some(group) = group { if since.is_some() { return Err("--since applies to test-changed/test-full, not test-group".into()); } let prefixes = g .groups .get(&group) .ok_or_else(|| format!("unknown group {group}; use list-groups"))?; changed = g .nodes .iter() .filter(|n| prefixes.iter().any(|p| n.starts_with(p))) .cloned() .collect(); if changed.is_empty() { return Err(format!("empty group {group}")); } } let p = plan(&g, &changed, mode == "test-full", &root)?; println!( "Inputs: {}. Python test modules: {}. Report producers: {}. Rust workspace tests: {}.", changed.len(), p.tests.len(), p.producers.len(), p.rust ); for input in &changed { println!("INPUT {input}"); } for why in &p.fallback { println!("FULL-SUITE FALLBACK: {why}"); } for producer in &p.producers { println!( "REGENERATE {producer} (twice): {}", p.reasons .get(producer) .map(String::as_str) .unwrap_or("selected") ); } for test in &p.tests { println!( "TEST {test}: {}", p.reasons .get(test) .map(String::as_str) .unwrap_or("full suite") ); } if dry { println!("Dry run: no analyzers, tests or checks executed."); return Ok(()); } execute(&root, &g, &p, &python, &mut Process { root: root.clone() })?; println!("Validation passed. Review regenerated outputs before committing."); Ok(()) } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static SERIAL: AtomicU64 = AtomicU64::new(0); struct Temp(PathBuf); impl Temp { fn new() -> Self { let p = std::env::temp_dir().join(format!( "urt-xtask-{}-{}", std::process::id(), SERIAL.fetch_add(1, Ordering::SeqCst) )); fs::create_dir_all(&p).unwrap(); Self(p) } fn put(&self, p: &str, text: &str) { let target = self.0.join(p); fs::create_dir_all(target.parent().unwrap()).unwrap(); fs::write(target, text).unwrap(); } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn set(paths: &[&str]) -> Set { paths.iter().map(|s| (*s).into()).collect() } fn fixture() -> (Temp, Graph) { let t = Temp::new(); for file in [ "scripts/test_first.py", "scripts/test_last.py", "scripts/test_other.py", ] { t.put(file, ""); } let mut g=Graph::registry("version\t1\nreport\tscripts/analyze_first.py\tdocs/first.json\nreport\tscripts/analyze_last.py\tdocs/last.json\ngroup\tfirst\tscripts/analyze_first.py\n").unwrap(); g.scanner("node\tscripts/helper.py\nnode\tscripts/test_other.py\nedge\tscripts/helper.py\tscripts/analyze_first.py\nedge\tdocs/first.json\tscripts/bridge.py\nedge\tscripts/bridge.py\tscripts/analyze_last.py\nedge\tdocs/first.json\tscripts/test_first.py\nedge\tdocs/last.json\tscripts/test_last.py\n").unwrap(); (t, g) } #[test] fn transitive_import_report_and_test_selection() { let (t, g) = fixture(); let p = plan(&g, &set(&["scripts/helper.py"]), false, &t.0).unwrap(); assert_eq!( p.tests, set(&["scripts/test_first.py", "scripts/test_last.py"]) ); assert_eq!( p.producers, vec!["scripts/analyze_first.py", "scripts/analyze_last.py"] ); assert!(p.fallback.is_empty()); assert!(!p.rust); } #[test] fn report_change_selects_its_producer() { let (t, g) = fixture(); let p = plan(&g, &set(&["docs/first.json"]), false, &t.0).unwrap(); assert_eq!(p.producers.len(), 2); } #[test] fn test_change_does_not_regenerate_unaffected_reports() { let (t, g) = fixture(); let p = plan(&g, &set(&["scripts/test_first.py"]), false, &t.0).unwrap(); assert!(p.producers.is_empty()); assert_eq!(p.tests, set(&["scripts/test_first.py"])); } #[test] fn shared_test_oracle_selects_consumers() { let (t, mut g) = fixture(); g.edge("scripts/test_first.py", "scripts/test_last.py"); let p = plan(&g, &set(&["scripts/test_first.py"]), false, &t.0).unwrap(); assert_eq!(p.tests.len(), 2); } #[test] fn unknown_change_falls_back_to_all_tests_and_rust() { let (t, g) = fixture(); let p = plan(&g, &set(&["surprise.rs"]), false, &t.0).unwrap(); assert_eq!(p.tests.len(), 3); assert!(p.rust); assert!(!p.fallback.is_empty()); } #[test] fn documentation_only_skips_code_tests() { let (t, g) = fixture(); let p = plan(&g, &set(&["docs/README.md"]), false, &t.0).unwrap(); assert!(p.tests.is_empty()); assert!(p.fallback.is_empty()); assert!(!p.rust); } #[test] fn runner_and_workspace_changes_force_full() { let (t, g) = fixture(); for file in [ "xtask/src/lib.rs", "Cargo.lock", "Cargo.toml", ".cargo/config.toml", ] { let p = plan(&g, &set(&[file]), false, &t.0).unwrap(); assert_eq!(p.tests.len(), 3); assert!(p.rust); } } #[test] fn full_suite_keeps_affected_regeneration() { let (t, g) = fixture(); let p = plan(&g, &set(&["scripts/helper.py"]), true, &t.0).unwrap(); assert_eq!(p.tests.len(), 3); assert_eq!(p.producers.len(), 2); assert!(p.rust); } #[test] fn empty_diff_is_cheap() { let (t, g) = fixture(); let p = plan(&g, &Set::new(), false, &t.0).unwrap(); assert!(p.tests.is_empty()); assert!(p.producers.is_empty()); } #[test] fn code_without_consumers_is_conservative() { let (t, mut g) = fixture(); g.nodes.insert("scripts/lonely.py".into()); let p = plan(&g, &set(&["scripts/lonely.py"]), false, &t.0).unwrap(); assert_eq!(p.tests.len(), 3); } #[test] fn duplicate_report_ownership_is_rejected() { assert!(Graph::registry("version\t1\nreport\tscripts/analyze_a.py\tdocs/a.json\nreport\tscripts/analyze_b.py\tdocs/a.json").is_err()); } #[test] fn invalid_registry_versions_and_paths_are_rejected() { for text in [ "version\t2", "version\t1\nunknown\tx", "version\t1\nedge\t../escape\tscripts/a.py", ] { assert!(Graph::registry(text).is_err()); } } #[test] fn malformed_ast_records_are_rejected() { assert!(Graph::default().scanner("bad\tinput").is_err()); } #[test] fn new_literal_output_requires_full_checks_but_can_regenerate() { let (t, mut g) = fixture(); g.scanner("output\tscripts/analyze_new.py\tdocs/new.json") .unwrap(); let p = plan(&g, &set(&["scripts/analyze_new.py"]), false, &t.0).unwrap(); assert_eq!(p.tests.len(), 3); assert_eq!(p.producers, vec!["scripts/analyze_new.py"]); } #[test] fn unknown_dynamic_writer_fails_closed() { let (t, mut g) = fixture(); g.scanner("dynamic\tscripts/analyze_first.py").unwrap(); assert!( plan(&g, &set(&["scripts/helper.py"]), false, &t.0) .unwrap_err() .contains("cannot safely regenerate") ); } #[test] fn cycles_are_rejected_instead_of_using_stale_reports() { let (t, mut g) = fixture(); g.edge("docs/last.json", "scripts/analyze_first.py"); assert!( plan(&g, &set(&["scripts/helper.py"]), false, &t.0) .unwrap_err() .contains("cycle") ); } #[test] fn import_cycles_alone_do_not_require_main_execution() { let (_, mut g) = fixture(); g.edge("scripts/analyze_last.py", "scripts/analyze_first.py"); assert!( g.producers_in_order(&set(&[ "scripts/analyze_first.py", "scripts/analyze_last.py" ])) .is_ok() ); } #[test] fn removed_test_is_not_executed() { let (t, g) = fixture(); fs::remove_file(t.0.join("scripts/test_first.py")).unwrap(); let p = plan(&g, &set(&["scripts/helper.py"]), false, &t.0).unwrap(); assert_eq!(p.tests, set(&["scripts/test_last.py"])); } #[test] fn nul_git_records_preserve_spaces() { assert_eq!( nul_paths("a b.py\0old.py\0").unwrap(), set(&["a b.py", "old.py"]) ); assert!(nul_paths("../bad\0").is_err()); } struct Fake { calls: usize, root: PathBuf, change: bool, fail: bool, } impl Runner for Fake { fn run(&mut self, _: &Cmd) -> Result<()> { self.calls += 1; if self.fail { return Err("child failed".into()); } fs::write( self.root.join("docs/first.json"), if self.change && self.calls == 2 { "changed" } else { "stable" }, ) .unwrap(); Ok(()) } } #[test] fn regeneration_runs_twice_and_compares_actual_bytes() { let (t, g) = fixture(); t.put("docs/first.json", "old"); let mut r = Fake { calls: 0, root: t.0.clone(), change: false, fail: false, }; regenerate(&t.0, &g, "scripts/analyze_first.py", "python3", &mut r).unwrap(); assert_eq!(r.calls, 2); assert_eq!( fs::read_to_string(t.0.join("docs/first.json")).unwrap(), "stable" ); } #[test] fn nondeterministic_reports_stop_validation() { let (t, g) = fixture(); t.put("docs/first.json", "old"); let mut r = Fake { calls: 0, root: t.0.clone(), change: true, fail: false, }; assert!( regenerate(&t.0, &g, "scripts/analyze_first.py", "python3", &mut r) .unwrap_err() .contains("nondeterministic") ); } #[test] fn failed_analyzer_does_not_continue() { let (t, g) = fixture(); let mut r = Fake { calls: 0, root: t.0.clone(), change: false, fail: true, }; assert!(regenerate(&t.0, &g, "scripts/analyze_first.py", "python3", &mut r).is_err()); assert_eq!(r.calls, 1); } #[test] fn missing_output_is_an_error() { let (t, g) = fixture(); assert!(snapshots(&t.0, &g.outputs["scripts/analyze_first.py"]).is_err()); } #[test] fn docs_checks_local_links_and_ignores_external_anchors() { let t = Temp::new(); t.put( "docs/README.md", "[ok](a.md#heading) [external](https://example.org) [anchor](#here)", ); t.put("docs/a.md", ""); assert_eq!(docs_check(&t.0).unwrap(), 2); t.put("docs/a.md", "[broken](missing.md)"); assert!(docs_check(&t.0).is_err()); } #[test] fn git_changes_include_batch_index_worktree_untracked_and_renames() { let t = Temp::new(); let git = |args: Vec<&str>| capture(&t.0, &Cmd::new("git", args)).unwrap(); git(vec!["init", "-q"]); for p in ["a.py", "b.py", "old.py"] { t.put(p, "base"); } git(vec!["add", "."]); git(vec![ "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "base", ]); let base = git(vec!["rev-parse", "HEAD"]); fs::rename(t.0.join("old.py"), t.0.join("new.py")).unwrap(); git(vec!["add", "-A"]); git(vec![ "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "rename", ]); t.put("a.py", "staged"); git(vec!["add", "a.py"]); t.put("b.py", "unstaged"); t.put("with spaces.py", "new"); assert_eq!( changes(&t.0, Some(base.trim())).unwrap(), set(&["a.py", "b.py", "old.py", "new.py", "with spaces.py"]) ); assert!(changes(&t.0, Some("missing-revision")).is_err()); } #[test] fn ast_reader_handles_multiline_alias_and_formatted_output_without_importing() { let t = Temp::new(); t.put( "scripts/helper.py", "raise RuntimeError('must not execute')", ); t.put( "scripts/test_case.py", "from helper import (\n x as alias,\n)\nimport helper as h\np='docs/a.json'\n", ); t.put( "scripts/analyze_x.py", "write_json(Path(f'docs/{label}.json'), {})", ); let scanner = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("python_graph.py"); let out = capture( &t.0, &Cmd::new( &python_runtime().expect("run cargo test inside nix develop"), ["-I".into(), scanner.to_string_lossy().into_owned()], ), ) .unwrap(); assert!(out.contains("edge\tscripts/helper.py\tscripts/test_case.py")); assert!(out.contains("edge\tdocs/a.json\tscripts/test_case.py")); assert!(out.contains("dynamic\tscripts/analyze_x.py")); } } #[cfg(test)] mod integration_tests { use super::*; #[test] fn current_registry_matches_literal_writers_and_health_selection() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); let mut g = Graph::registry(&fs::read_to_string(root.join("xtask/checks.tsv")).unwrap()).unwrap(); let output = capture( root, &Cmd::new( &python_runtime().expect("run cargo test inside nix develop"), ["-I", "xtask/python_graph.py"], ), ) .unwrap(); g.scanner(&output).unwrap(); assert!(g.warnings.is_empty(), "{:?}", g.warnings); // Keep independent health and session helper namespaces importable. // A filename collision must fail this scoped registry regression. capture(root,&Cmd::new(&python_runtime().expect("run cargo test inside nix develop"),["-I","-c","import sys; sys.path.insert(0, 'scripts'); from qvm_lifecycle_support import seed, probe; from qvm_session_lifecycle_support import initialize, post_full, connect_branch"])).unwrap(); let health = plan( &g, &["scripts/qvm_health_support.py".into()] .into_iter() .collect(), false, root, ) .unwrap(); assert!(health.tests.contains("scripts/test_qvm_health_gates.py")); assert!( health .tests .contains("scripts/test_qvm_health_knockback.py") ); assert!( health .producers .contains(&"scripts/analyze_qvm_health_gates.py".into()) ); assert!( health .producers .contains(&"scripts/analyze_qvm_health_knockback.py".into()) ); assert!(health.fallback.is_empty()); // Gameplay module edits must refresh both their parity and the scoped // package test-count metadata owned by the impulse report. for (module, producer) in [ ("native_policy", "bot_best"), ("native_policy", "bot_breed"), ("native_policy", "post_map_clients"), ("native_policy", "spawn_overlap"), ("native_policy", "bot_rotation"), ("camera_world", "queried_spawn"), ("camera_world", "queried_camera"), ("native_policy", "queried_spawn"), ("native_policy", "queried_camera"), ("native_policy", "session_write"), ("native_policy", "post_map_session"), ("native_policy", "normal_map_session"), ("map_commands", "normal_map_session"), ("transition", "normal_map_session"), ("qvm_format", "normal_map_session"), ("native_policy", "info_lookup"), ("native_policy", "numeric_scan"), ("native_policy", "position_save"), ("native_policy", "position_load"), ("native_policy", "session_restore"), ("intermission", "session_restore"), ("native_policy", "session_init"), ("native_policy", "post_map_files"), ("native_policy", "normal_map_files"), ("native_policy", "persistence_roundtrip"), ("native_policy", "connect_session"), ("map_commands", "normal_map_files"), ("transition", "normal_map_files"), ("intermission", "connect_session"), ("native_policy", "byte_compare"), ("native_policy", "address_filter"), ("native_policy", "connect_admission"), ("native_policy", "connect_tail"), ("native_policy", "connect_complete"), ("intermission", "connect_complete"), ("native_policy", "bot_settings"), ("native_policy", "team_announcement"), ("native_policy", "resolved_connect_tail"), ("native_policy", "resolved_connect_complete"), ("native_policy", "filter_insert"), ("filter_lifecycle", "filter_export"), ("filter_loading", "filter_time"), ("filter_commands", "filter_add_command"), ("client_timer", "client_timer_terminal"), ("client_move_context", "client_move_suppression"), ("client_move_context", "client_move_mask"), ("client_move_context", "client_move_context"), ("client_move_context", "client_move_assembly"), ("move_callbacks", "move_meter"), ("move_callbacks", "move_self_recovery"), ("move_callbacks", "move_assist_cleanup"), ("move_callbacks", "move_mode8_eligibility"), ("move_callbacks", "move_resolved_self"), ("client_move_context", "move_resolved_self"), ("assistance", "assist_acquire"), ("move_callbacks", "assist_acquire"), ("assistance", "assist_cancel"), ("move_callbacks", "assist_cancel"), ("assistance", "assist_progress"), ("move_callbacks", "assist_progress"), ("assistance", "assist_complete"), ("move_callbacks", "assist_complete"), ("assistance", "assist_sequence"), ("move_callbacks", "assist_sequence"), ("defuse", "defuse_begin"), ("move_callbacks", "defuse_begin"), ("defuse", "defuse_cancel"), ("move_callbacks", "defuse_cancel"), ("defuse", "defuse_finish"), ("move_callbacks", "defuse_finish"), ("defuse", "defuse_complete"), ("move_callbacks", "defuse_complete"), ("defuse", "defuse_sequence"), ("move_callbacks", "defuse_sequence"), ("effect_feedback", "effect_counter"), ("score", "effect_counter"), ("transition", "effect_counter"), ("effect_feedback", "effect_radio"), ("score", "effect_radio"), ("transition", "effect_radio"), ("effect_feedback", "effect_defuse_finish"), ("score", "effect_defuse_finish"), ("transition", "effect_defuse_finish"), ("effect_feedback", "effect_defuse_begin"), ("score", "effect_defuse_begin"), ("transition", "effect_defuse_begin"), ("effect_feedback", "effect_defuse_complete"), ("defuse", "effect_defuse_complete"), ("score", "effect_defuse_complete"), ("transition", "effect_defuse_complete"), ("location_feedback", "location_nearest"), ("qvm_format", "location_nearest"), ("location_feedback", "location_text"), ("qvm_format", "location_text"), ("location_feedback", "location_radio"), ("qvm_format", "location_radio"), ("location_feedback", "location_allocation"), ("qvm_format", "location_allocation"), ("location_feedback", "location_allocated_defuse"), ("defuse", "location_allocated_defuse"), ("transition", "location_allocated_defuse"), ("qvm_format", "location_allocated_defuse"), ("client_move_context", "client_move_scratch"), ("client_processing", "client_routing"), ("client_timer", "client_routing"), ("filter_commands", "client_routing"), ("filter_loading", "client_routing"), ("filter_lifecycle", "client_routing"), ("native_policy", "client_routing"), ("qvm_format", "client_routing"), ("client_processing", "client_command_rewrite"), ("client_timer", "client_command_rewrite"), ("filter_commands", "client_command_rewrite"), ("filter_loading", "client_command_rewrite"), ("filter_lifecycle", "client_command_rewrite"), ("native_policy", "client_command_rewrite"), ("qvm_format", "client_command_rewrite"), ("client_processing", "client_inactivity"), ("client_timer", "client_inactivity"), ("filter_commands", "client_inactivity"), ("filter_loading", "client_inactivity"), ("filter_lifecycle", "client_inactivity"), ("native_policy", "client_inactivity"), ("qvm_format", "client_inactivity"), ("client_processing", "client_movement_setup"), ("client_timer", "client_movement_setup"), ("filter_commands", "client_movement_setup"), ("filter_loading", "client_movement_setup"), ("filter_lifecycle", "client_movement_setup"), ("native_policy", "client_movement_setup"), ("qvm_format", "client_movement_setup"), ("client_processing", "client_pre_movement"), ("client_timer", "client_pre_movement"), ("filter_commands", "client_pre_movement"), ("filter_loading", "client_pre_movement"), ("filter_lifecycle", "client_pre_movement"), ("native_policy", "client_pre_movement"), ("qvm_format", "client_pre_movement"), ("filter_commands", "client_timer_terminal"), ("filter_loading", "client_timer_terminal"), ("filter_lifecycle", "client_timer_terminal"), ("native_policy", "client_timer_terminal"), ("qvm_format", "client_timer_terminal"), ("client_timer", "client_timer_ticks"), ("filter_commands", "client_timer_ticks"), ("filter_loading", "client_timer_ticks"), ("filter_lifecycle", "client_timer_ticks"), ("native_policy", "client_timer_ticks"), ("qvm_format", "client_timer_ticks"), ("client_timer", "client_timer_recovery"), ("filter_commands", "client_timer_recovery"), ("filter_loading", "client_timer_recovery"), ("filter_lifecycle", "client_timer_recovery"), ("native_policy", "client_timer_recovery"), ("qvm_format", "client_timer_recovery"), ("client_timer", "client_timer_complete"), ("filter_commands", "client_timer_complete"), ("filter_loading", "client_timer_complete"), ("filter_lifecycle", "client_timer_complete"), ("native_policy", "client_timer_complete"), ("qvm_format", "client_timer_complete"), ("client_timer", "client_timer_prepare"), ("filter_commands", "client_timer_prepare"), ("filter_loading", "client_timer_prepare"), ("filter_lifecycle", "client_timer_prepare"), ("native_policy", "client_timer_prepare"), ("qvm_format", "client_timer_prepare"), ("filter_loading", "filter_add_command"), ("filter_lifecycle", "filter_add_command"), ("native_policy", "filter_add_command"), ("qvm_format", "filter_add_command"), ("filter_commands", "filter_remove_command"), ("filter_loading", "filter_remove_command"), ("filter_lifecycle", "filter_remove_command"), ("native_policy", "filter_remove_command"), ("qvm_format", "filter_remove_command"), ("filter_commands", "filter_duration_export"), ("filter_loading", "filter_duration_export"), ("filter_lifecycle", "filter_duration_export"), ("native_policy", "filter_duration_export"), ("qvm_format", "filter_duration_export"), ("filter_commands", "filter_expiry_admission"), ("filter_loading", "filter_expiry_admission"), ("filter_lifecycle", "filter_expiry_admission"), ("native_policy", "filter_expiry_admission"), ("qvm_format", "filter_expiry_admission"), ("filter_commands", "filter_frame_prefix"), ("filter_loading", "filter_frame_prefix"), ("filter_lifecycle", "filter_frame_prefix"), ("native_policy", "filter_frame_prefix"), ("qvm_format", "filter_frame_prefix"), ("filter_lifecycle", "filter_time"), ("native_policy", "filter_time"), ("qvm_format", "filter_time"), ("filter_loading", "filter_expiry"), ("filter_lifecycle", "filter_expiry"), ("native_policy", "filter_expiry"), ("qvm_format", "filter_expiry"), ("filter_loading", "filter_load"), ("filter_lifecycle", "filter_load"), ("native_policy", "filter_load"), ("qvm_format", "filter_load"), ("filter_loading", "filter_duration"), ("filter_lifecycle", "filter_duration"), ("native_policy", "filter_duration"), ("qvm_format", "filter_duration"), ("filter_loading", "filter_roundtrip"), ("filter_lifecycle", "filter_roundtrip"), ("native_policy", "filter_roundtrip"), ("qvm_format", "filter_roundtrip"), ("native_policy", "filter_export"), ("qvm_format", "filter_export"), ("filter_lifecycle", "filter_remove"), ("native_policy", "filter_remove"), ("filter_lifecycle", "filter_metadata"), ("native_policy", "filter_metadata"), ("filter_lifecycle", "filter_insert_export"), ("native_policy", "filter_insert_export"), ("qvm_format", "filter_insert_export"), ("filter_lifecycle", "filter_remove_export"), ("native_policy", "filter_remove_export"), ("qvm_format", "filter_remove_export"), ("qvm_format", "resolved_connect_complete"), ("protected", "protected_update"), ("mode10", "mode10_entry"), ("intercept_animation", "mode10_animation"), ("score", "score_update"), ("method_stats", "method_stats"), ("record_clear", "cross_client_clear"), ("stat_totals", "stat_totals"), ("recent_awards", "recent_awards"), ("recipient_payload", "recipient_payload"), ("ranking", "ranking"), ("rank_sort", "rank_sort"), ("ranking_population", "ranking_population"), ("scoreboard", "scoreboard"), ("scoreboard_wire", "scoreboard_rows"), ("qvm_format", "qvm_format"), ("exit_prefix", "exit_prefix"), ("exit_rules", "exit_class"), ("intermission", "intermission_flow"), ("intermission", "nextmap_series"), ("transition", "event_construct"), ("transition", "camera_world"), ("camera_world", "camera_world"), ("camera_world", "camera_spawn"), ("map_commands", "normal_map_flow"), ("transition", "camera_spawn"), ("transition", "normal_map_flow"), ("intermission", "normal_map_flow"), ("leaderboard", "leaderboard"), ("recipient_wire", "recipient_wire"), ] { let selected = plan( &g, &[format!("crates/urt-gameplay/src/{module}.rs")] .into_iter() .collect(), false, root, ) .unwrap(); assert!(selected.fallback.is_empty()); assert!( selected .producers .contains(&format!("scripts/analyze_rust_{producer}.py")) ); assert!( selected .producers .contains(&"scripts/analyze_rust_health_impulse.py".into()) ); assert!( selected .tests .contains(&format!("scripts/test_rust_{producer}.py")) ); } // Formatted source-report paths are explicit registry inputs. Each // must select its Rust parity report without full fallback. for (source, target) in [ ("qvm-bot-best", "bot_best"), ("qvm-bot-breed", "bot_breed"), ("qvm-post-map-clients", "post_map_clients"), ("qvm-spawn-overlap", "spawn_overlap"), ("qvm-bot-rotation", "bot_rotation"), ("qvm-queried-spawn", "queried_spawn"), ("qvm-queried-camera", "queried_camera"), ("qvm-session-write", "session_write"), ("qvm-post-map-session", "post_map_session"), ("qvm-normal-map-session", "normal_map_session"), ("qvm-info-lookup", "info_lookup"), ("qvm-numeric-scan", "numeric_scan"), ("qvm-position-save", "position_save"), ("qvm-position-load", "position_load"), ("qvm-session-restore", "session_restore"), ("qvm-session-init", "session_init"), ("qvm-post-map-files", "post_map_files"), ("qvm-normal-map-files", "normal_map_files"), ("qvm-persistence-roundtrip", "persistence_roundtrip"), ("qvm-connect-session", "connect_session"), ("qvm-byte-compare", "byte_compare"), ("qvm-address-filter", "address_filter"), ("qvm-connect-admission", "connect_admission"), ("qvm-connect-tail", "connect_tail"), ("qvm-connect-complete", "connect_complete"), ("qvm-bot-settings", "bot_settings"), ("qvm-team-announcement", "team_announcement"), ("qvm-resolved-connect-tail", "resolved_connect_tail"), ("qvm-resolved-connect-complete", "resolved_connect_complete"), ("qvm-filter-insert", "filter_insert"), ("qvm-filter-export", "filter_export"), ("qvm-filter-time", "filter_time"), ("qvm-filter-add-command", "filter_add_command"), ("qvm-client-timer-terminal", "client_timer_terminal"), ("qvm-client-move-suppression", "client_move_suppression"), ("qvm-client-move-mask", "client_move_mask"), ("qvm-client-move-context", "client_move_context"), ("qvm-client-move-assembly", "client_move_assembly"), ("qvm-move-meter", "move_meter"), ("qvm-move-self-recovery", "move_self_recovery"), ("qvm-move-assist-cleanup", "move_assist_cleanup"), ("qvm-move-mode8-eligibility", "move_mode8_eligibility"), ("qvm-move-resolved-self", "move_resolved_self"), ("qvm-assist-acquire", "assist_acquire"), ("qvm-assist-cancel", "assist_cancel"), ("qvm-assist-progress", "assist_progress"), ("qvm-assist-complete", "assist_complete"), ("qvm-assist-sequence", "assist_sequence"), ("qvm-defuse-begin", "defuse_begin"), ("qvm-defuse-cancel", "defuse_cancel"), ("qvm-defuse-finish", "defuse_finish"), ("qvm-defuse-complete", "defuse_complete"), ("qvm-defuse-sequence", "defuse_sequence"), ("qvm-effect-counter", "effect_counter"), ("qvm-effect-radio", "effect_radio"), ("qvm-effect-defuse-finish", "effect_defuse_finish"), ("qvm-effect-defuse-begin", "effect_defuse_begin"), ("qvm-effect-defuse-complete", "effect_defuse_complete"), ("qvm-location-nearest", "location_nearest"), ("qvm-location-text", "location_text"), ("qvm-location-radio", "location_radio"), ("qvm-location-allocation", "location_allocation"), ("qvm-location-allocated-defuse", "location_allocated_defuse"), ("qvm-client-move-scratch", "client_move_scratch"), ("qvm-client-routing", "client_routing"), ("qvm-client-command-rewrite", "client_command_rewrite"), ("qvm-client-inactivity", "client_inactivity"), ("qvm-client-movement-setup", "client_movement_setup"), ("qvm-client-pre-movement", "client_pre_movement"), ("qvm-client-timer-ticks", "client_timer_ticks"), ("qvm-client-timer-recovery", "client_timer_recovery"), ("qvm-client-timer-complete", "client_timer_complete"), ("qvm-client-timer-prepare", "client_timer_prepare"), ("qvm-filter-remove-command", "filter_remove_command"), ("qvm-filter-duration-export", "filter_duration_export"), ("qvm-filter-expiry-admission", "filter_expiry_admission"), ("qvm-filter-frame-prefix", "filter_frame_prefix"), ("qvm-filter-expiry", "filter_expiry"), ("qvm-filter-load", "filter_load"), ("qvm-filter-duration", "filter_duration"), ("qvm-filter-roundtrip", "filter_roundtrip"), ("qvm-filter-remove", "filter_remove"), ("qvm-filter-metadata", "filter_metadata"), ("qvm-filter-insert-export", "filter_insert_export"), ("qvm-filter-remove-export", "filter_remove_export"), ("qvm-rank-small-sort", "rank_sort"), ("qvm-rank-large-sort", "rank_sort"), ("qvm-ranking-complete", "ranking_complete"), ("qvm-ranking-large-caller", "ranking_complete"), ("qvm-ranking-setup", "ranking_population"), ("qvm-ranking-large-caller", "ranking_population"), ("qvm-ranking-population", "ranking_population"), ("qvm-ranking-large-caller", "ranking_mixed"), ("qvm-ranking-population", "ranking_mixed"), ("qvm-scoreboard-broadcast", "scoreboard"), ("qvm-scoreboard-rows", "scoreboard_rows"), ("qvm-scoreboard-wire", "scoreboard_packet"), ("qvm-scoreboard-stream", "scoreboard_stream"), ("qvm-formatter", "qvm_format"), ("qvm-scoreboard-formatter", "scoreboard_original"), ("qvm-scoreboard-formatter", "scoreboard_packet"), ("qvm-format-wrappers", "format_wrappers"), ("qvm-exit-prefix", "exit_prefix"), ("qvm-format-float", "format_float"), ("qvm-exit-class", "exit_class"), ("qvm-exit-limits", "exit_limits"), ("qvm-exit-timers", "exit_timers"), ("qvm-exit-complete", "exit_complete"), ("qvm-exit-predicate", "exit_predicate"), ("qvm-intermission-ready", "intermission_ready"), ("qvm-intermission-begin", "intermission_begin"), ("qvm-exit-log", "exit_log"), ("qvm-intermission-flow", "intermission_flow"), ("qvm-camera-place", "camera_place"), ("qvm-camera-select", "camera_select"), ("qvm-nextmap-series", "nextmap_series"), ("qvm-nextmap-reset", "nextmap_reset"), ("qvm-event-construct", "event_construct"), ("qvm-entity-hash", "entity_hash"), ("qvm-entity-field", "entity_field"), ("qvm-target-pick", "target_pick"), ("qvm-camera-angles", "camera_angles"), ("qvm-camera-world", "camera_world"), ("qvm-spawn-fallback", "spawn_fallback"), ("qvm-camera-spawn", "camera_spawn"), ("qvm-rotation-advance", "rotation_advance"), ("qvm-map-select", "map_select"), ("qvm-normal-map-flow", "normal_map_flow"), ] { let selected = plan( &g, &[format!("docs/research/{source}.json")] .into_iter() .collect(), false, root, ) .unwrap(); assert!(selected.fallback.is_empty()); assert!( selected .producers .contains(&format!("scripts/analyze_rust_{target}.py")) ); assert!( selected .tests .contains(&format!("scripts/test_rust_{target}.py")) ); } // Validate the entire registry DAG, including multi-output native/QVM writers. g.producers_in_order(&g.outputs.keys().cloned().collect()) .unwrap(); for producer in g.outputs.keys() { assert!(root.join(producer).is_file(), "missing producer {producer}"); } let rust = plan( &g, &["crates/urt-assets/src/lib.rs".into()] .into_iter() .collect(), false, root, ) .unwrap(); assert!(rust.rust); assert!( rust.producers .contains(&"scripts/analyze_rust_md3.py".into()) ); assert!(rust.tests.contains("scripts/test_rust_md3.py")); } struct FailTests { commands: Vec, } impl Runner for FailTests { fn run(&mut self, c: &Cmd) -> Result<()> { self.commands.push(c.clone()); if c.args.iter().any(|a| a == "unittest") { Err("failed test".into()) } else { Ok(()) } } } #[test] fn failing_tests_stop_before_storage_and_success_checks() { let g = Graph::default(); let p = Plan { tests: ["scripts/test_one.py".into()].into_iter().collect(), producers: vec![], reasons: BTreeMap::new(), fallback: vec![], rust: false, }; let mut runner = FailTests { commands: vec![] }; assert!(execute(Path::new("."), &g, &p, "python3", &mut runner).is_err()); assert_eq!(runner.commands.len(), 1); } }