Files
pdfcraft/xtask/src/gates.rs
T

287 lines
12 KiB
Rust

//! Quality gates: layering, wasm, the CI bundle, corpora and the robustness sweep.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, anyhow, bail};
use crate::layers;
pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
c
}
fn run(mut cmd: Command, what: &str) -> anyhow::Result<()> {
eprintln!("$ {what}");
let status = cmd.status().with_context(|| format!("{what}: failed to spawn"))?;
if !status.success() {
bail!("{what}: exited with {status}");
}
Ok(())
}
fn metadata() -> anyhow::Result<serde_json::Value> {
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().context("cargo metadata")?;
if !out.status.success() {
bail!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr));
}
Ok(serde_json::from_slice(&out.stdout)?)
}
pub fn layers(_: &[String]) -> anyhow::Result<()> {
let crates = layers::from_metadata(&metadata()?).map_err(|e| anyhow!(e))?;
println!("Dependency layering (plan/architecture.md §3)\n");
println!("{:<24} {:<14} workspace deps", "crate", "layer");
for c in &crates {
let ws: Vec<String> = c
.deps
.iter()
.filter(|d| d.workspace)
.map(|d| format!("{}{}", layers::short_name(&d.name), if d.kind == layers::DepKind::Dev { " (dev)" } else { "" }))
.collect();
println!("{:<24} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
}
let violations = layers::check(&crates);
println!();
if violations.is_empty() {
println!("OK: {} crates, no layering violations.", crates.len());
return Ok(());
}
for v in &violations {
println!(" - {v}");
}
bail!("{} layering violation(s)", violations.len())
}
pub fn wasm(_: &[String]) -> anyhow::Result<()> {
let crates = layers::from_metadata(&metadata()?).map_err(|e| anyhow!(e))?;
let set: Vec<String> = crates
.into_iter()
.filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Standalone(_))))
.map(|c| c.name)
.collect();
let mut failed = Vec::new();
for pkg in &set {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
if run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_err() {
failed.push(pkg.clone());
}
}
if failed.is_empty() {
println!("wasm32: {} crates ok", set.len());
Ok(())
} else {
bail!("wasm32 check failed for: {}", failed.join(", "))
}
}
pub fn ci(_: &[String]) -> anyhow::Result<()> {
type Step = (&'static str, Box<dyn Fn() -> anyhow::Result<()>>);
let steps: Vec<Step> = vec![
("fmt", Box::new(|| run_args(&["fmt", "--all", "--", "--check"]))),
("clippy", Box::new(|| run_args(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))),
("test", Box::new(|| run_args(&["test", "--workspace"]))),
("layers", Box::new(|| layers(&[]))),
("wasm", Box::new(|| wasm(&[]))),
("assets", Box::new(|| crate::assets::run(&[]))),
("deny", Box::new(|| deny(&[]))),
("parity", Box::new(|| crate::parity::run(&[]))),
];
for (i, (name, f)) in steps.iter().enumerate() {
eprintln!("\n=== ci: {name} ===");
if let Err(e) = f() {
println!("\nCI: {} passed, FAILED at `{name}`: {e:#}", i);
bail!("ci failed at `{name}`");
}
}
println!("\nCI: all {} steps passed", steps.len());
Ok(())
}
/// Dependency licences, bans, sources and advisories (`deny.toml`). Skipped with a hint when
/// cargo-deny is not installed, except under CI (`CI` set), where it is required.
pub fn deny(_: &[String]) -> anyhow::Result<()> {
let installed = Command::new("cargo-deny").arg("--version").output().is_ok_and(|o| o.status.success());
if !installed {
if std::env::var_os("CI").is_some() {
bail!("cargo-deny is required in CI: cargo install cargo-deny --locked");
}
eprintln!("deny: SKIPPED, cargo-deny is not installed (cargo install cargo-deny --locked)");
return Ok(());
}
run_args(&["deny", "--log-level", "error", "check"])
}
fn run_args(args: &[&str]) -> anyhow::Result<()> {
let mut c = cargo();
c.args(args);
run(c, &format!("cargo {}", args.join(" ")))
}
pub fn corpus(_: &[String]) -> anyhow::Result<()> {
let dir = root().join("corpus");
std::fs::create_dir_all(&dir)?;
let pdfjs = dir.join("pdfjs");
if pdfjs.join("test/pdfs").is_dir() {
println!("corpus/pdfjs already present");
} else {
let mut c = Command::new("git");
c.current_dir(&dir).args(["clone", "-q", "--depth", "1", "--filter=blob:none", "--sparse", "https://github.com/mozilla/pdf.js.git", "pdfjs"]);
run(c, "git clone --sparse mozilla/pdf.js")?;
let mut c = Command::new("git");
c.current_dir(&pdfjs).args(["sparse-checkout", "set", "test/pdfs"]);
run(c, "git sparse-checkout set test/pdfs")?;
}
println!(
"Corpora (git-ignored, never committed; tests skip when absent):\n corpus/pdfjs/test/pdfs pdf.js test files committed in-repo (Apache-2.0 repository; individual files keep their own terms)"
);
Ok(())
}
/// `cargo xtask check [--update-baseline]`: run `printcraft-cli check` over the corpus and compare
/// with `xtask/baselines/<corpus>.json` (the list of files known not to open/render cleanly).
/// Fails on any crash, and on any file that regressed from `ok`.
pub fn check(args: &[String]) -> anyhow::Result<()> {
let update = args.iter().any(|a| a == "--update-baseline");
let corpus = root().join("corpus/pdfjs/test/pdfs");
if !corpus.is_dir() {
bail!("corpus missing: run `cargo xtask corpus` first");
}
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
let report = root().join("target/check-pdfjs.json");
let mut c = Command::new(root().join("target/release/printcraft-cli"));
c.args(["check"]).arg(&corpus).args(["--timeout", "20", "--dpi", "36", "--json"]).arg(&report);
run(c, "printcraft-cli check corpus/pdfjs")?;
let results: Vec<serde_json::Value> = serde_json::from_str(&std::fs::read_to_string(&report)?)?;
let name =
|v: &serde_json::Value| Path::new(v["file"].as_str().unwrap_or("")).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let status = |v: &serde_json::Value| v["status"].as_str().unwrap_or("?").split(':').next().unwrap_or("?").to_string();
let current: BTreeMap<String, String> = results.iter().filter(|v| status(v) != "ok").map(|v| (name(v), status(v))).collect();
let crashes: Vec<&String> = current.iter().filter(|(_, s)| s.as_str() == "crash").map(|(f, _)| f).collect();
let baseline_path = root().join("xtask/baselines/pdfjs.json");
if update {
std::fs::create_dir_all(baseline_path.parent().expect("has parent"))?;
std::fs::write(&baseline_path, serde_json::to_string_pretty(&current)? + "\n")?;
println!("baseline updated: {} known non-ok files", current.len());
}
let baseline: BTreeMap<String, String> =
std::fs::read_to_string(&baseline_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
let regressions: Vec<String> = current.iter().filter(|(f, _)| !baseline.contains_key(*f)).map(|(f, s)| format!("{f} ({s})")).collect();
let fixed: Vec<&String> = baseline.keys().filter(|f| !current.contains_key(*f)).collect();
println!(
"\n{} files: {} ok, {} known issues, {} regressions, {} newly fixed, {} crashes",
results.len(),
results.len() - current.len(),
current.len() - regressions.len(),
regressions.len(),
fixed.len(),
crashes.len()
);
if !fixed.is_empty() {
println!("newly fixed (run with --update-baseline): {fixed:?}");
}
if !crashes.is_empty() {
bail!("crashes: {crashes:?}");
}
if !regressions.is_empty() {
bail!("regressions: {regressions:?}");
}
Ok(())
}
/// `cargo xtask text-oracle [--limit N]`: compare `printcraft-cli text` with poppler's `pdftotext`
/// (external oracle process, never linked) over the corpus. Reports word-level F1 per file and the
/// median. Plan target (M2): median ≥ 0.97.
pub fn text_oracle(args: &[String]) -> anyhow::Result<()> {
let limit: usize = args.iter().position(|a| a == "--limit").and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(usize::MAX);
if Command::new("pdftotext").arg("-v").output().is_err() {
bail!("pdftotext (poppler) not found; install it to run the text oracle (brew install poppler / apt install poppler-utils)");
}
run_args(&["build", "--release", "-p", "printcraft-cli"])?;
let corpus = root().join("corpus/pdfjs/test/pdfs");
let mut files: Vec<PathBuf> =
std::fs::read_dir(&corpus)?.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
files.sort();
let cli = root().join("target/release/printcraft-cli");
let mut scores = Vec::new();
let mut worst: Vec<(f64, String)> = Vec::new();
for f in files.iter().take(limit) {
let ours = run_capture(Command::new(&cli).arg("text").arg(f), 20);
let theirs = run_capture(Command::new("pdftotext").arg("-q").arg(f).arg("-"), 20);
let (Some(ours), Some(theirs)) = (ours, theirs) else { continue };
let (a, b) = (words(&ours), words(&theirs));
if b.len() < 20 {
continue; // image-only or near-empty per the oracle
}
let score = f1(&a, &b);
scores.push(score);
worst.push((score, f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()));
}
if scores.is_empty() {
bail!("no comparable files");
}
scores.sort_by(f64::total_cmp);
worst.sort_by(|a, b| a.0.total_cmp(&b.0));
let median = scores[scores.len() / 2];
let mean = scores.iter().sum::<f64>() / scores.len() as f64;
println!("text oracle: {} files with text; median F1 {:.3}, mean {:.3}, p10 {:.3}", scores.len(), median, mean, scores[scores.len() / 10]);
println!("lowest:");
for (s, n) in worst.iter().take(12) {
println!(" {s:.3} {n}");
}
Ok(())
}
fn run_capture(cmd: &mut Command, timeout_s: u64) -> Option<String> {
let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn().ok()?;
let start = std::time::Instant::now();
let mut out = String::new();
let mut stdout = child.stdout.take()?;
let reader = std::thread::spawn(move || {
use std::io::Read;
let mut s = String::new();
let _ = stdout.read_to_string(&mut s);
s
});
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed().as_secs() > timeout_s => {
let _ = child.kill();
let _ = child.wait();
return None;
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
Err(_) => return None,
}
}
out.push_str(&reader.join().ok()?);
Some(out)
}
fn words(s: &str) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
*m.entry(w.to_lowercase()).or_insert(0) += 1;
}
m
}
fn f1(a: &BTreeMap<String, usize>, b: &BTreeMap<String, usize>) -> f64 {
let common: usize = a.iter().map(|(w, n)| (*n).min(*b.get(w).unwrap_or(&0))).sum();
let (na, nb): (usize, usize) = (a.values().sum(), b.values().sum());
if na == 0 || nb == 0 {
return 0.0;
}
let (p, r) = (common as f64 / na as f64, common as f64 / nb as f64);
if p + r == 0.0 { 0.0 } else { 2.0 * p * r / (p + r) }
}