Download xtask/src/lib.rs from nmarshall/urt-rs: direct link, hf CLI and curl.
- Browser
- Download file 61 kB
-
https://huggingface.co/nmarshall/urt-rs/resolve/main/xtask/src/lib.rs
- Command line
-
hf download hf://nmarshall/urt-rs/xtask/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/nmarshall/urt-rs/resolve/main/xtask/src/lib.rs
61 kB
| //! 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<T> = std::result::Result<T, String>; | |
| type Set = BTreeSet<String>; | |
| fn python_program(value: Option<&str>) -> Result<String> { | |
| 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<String> { | |
| python_program(std::env::var("PYTHON").ok().as_deref()) | |
| } | |
| mod python_tests { | |
| use super::*; | |
| 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}"); | |
| } | |
| } | |
| fn explicit_nix_dependency_is_accepted() { | |
| let value = "/nix/store/example-python3/bin/python3"; | |
| assert_eq!(python_program(Some(value)).unwrap(), value); | |
| } | |
| } | |
| pub struct Graph { | |
| nodes: Set, | |
| edges: BTreeMap<String, Set>, | |
| owners: BTreeMap<String, String>, | |
| outputs: BTreeMap<String, Set>, | |
| groups: BTreeMap<String, Vec<String>>, | |
| dynamic: Set, | |
| unsafe_nodes: Set, | |
| warnings: Vec<String>, | |
| } | |
| fn clean_path(s: &str) -> Result<String> { | |
| 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<Self> { | |
| 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<String, String> { | |
| 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<Vec<String>> { | |
| // Report dependencies can pass through imported helpers. Imports alone | |
| // never require executing an imported module's main(). | |
| let mut required: BTreeMap<String, Set> = | |
| 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::<Vec<_>>() | |
| )); | |
| }; | |
| done.insert(next.clone()); | |
| order.push(next); | |
| } | |
| Ok(order) | |
| } | |
| } | |
| struct Plan { | |
| tests: Set, | |
| producers: Vec<String>, | |
| reasons: BTreeMap<String, String>, | |
| fallback: Vec<String>, | |
| 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<Plan> { | |
| 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, | |
| }) | |
| } | |
| struct Cmd { | |
| program: String, | |
| args: Vec<String>, | |
| } | |
| impl Cmd { | |
| fn new(program: &str, args: impl IntoIterator<Item = impl Into<String>>) -> 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::<Vec<_>>() | |
| .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<String> { | |
| 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<Set> { | |
| text.split('\0') | |
| .filter(|s| !s.is_empty()) | |
| .map(clean_path) | |
| .collect() | |
| } | |
| fn changes(root: &Path, since: Option<&str>) -> Result<Set> { | |
| 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<BTreeMap<String, Vec<u8>>> { | |
| 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<usize> { | |
| 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<String>) -> 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(()) | |
| } | |
| 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) | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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"])); | |
| } | |
| 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); | |
| } | |
| 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()); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| } | |
| 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); | |
| } | |
| 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()); | |
| } | |
| 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); | |
| } | |
| 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()); | |
| } | |
| 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()); | |
| } | |
| } | |
| fn malformed_ast_records_are_rejected() { | |
| assert!(Graph::default().scanner("bad\tinput").is_err()); | |
| } | |
| 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"]); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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() | |
| ); | |
| } | |
| 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"])); | |
| } | |
| 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(()) | |
| } | |
| } | |
| 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" | |
| ); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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); | |
| } | |
| fn missing_output_is_an_error() { | |
| let (t, g) = fixture(); | |
| assert!(snapshots(&t.0, &g.outputs["scripts/analyze_first.py"]).is_err()); | |
| } | |
| 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()); | |
| } | |
| 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()); | |
| } | |
| 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")); | |
| } | |
| } | |
| mod integration_tests { | |
| use super::*; | |
| 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<Cmd>, | |
| } | |
| 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(()) | |
| } | |
| } | |
| } | |
| 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); | |
| } | |
| } | |