urt-rs / xtask /src /lib.rs
nmarshall's picture
gameplay: resolve allocated defuse event construction
006789e
Raw History Blame Contribute Delete
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())
}
#[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<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)
}
}
#[derive(Debug)]
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,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
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(())
}
#[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<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(())
}
}
}
#[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);
}
}