brick/tests/diff_cli.rs
trvon d0d52edfd1
fix(check): warn, don't fail, when stored metrics differ from recomputed
`check` treated any difference between stored and recomputed metrics as
a gate failure, so a model written by an older brick (without the
`metrics.resolution` field, or with an older formula) always failed,
even with every threshold met. CI checking an archived or cached model
broke on upgrade.

The gate already uses recomputed metrics, so a mismatch is now a
stderr warning. Only fields present in both copies are compared, so a
missing newer field does not count as a mismatch. Tampered metrics
still cannot pass: the existing test's recomputed cycle count fails the
gate, and it still reports "stored metrics".

Test: a freshly built model with `metrics.resolution` removed passes
`check --min-score 0`; it failed before this change.
2026-09-24 20:57:08 -06:00

384 lines
14 KiB
Rust

use brick::{diff, scan};
use serde_json::Value;
use std::fs;
use std::path::Path;
use std::process::Command;
fn write_model(path: &Path, model: &brick::Building) {
fs::write(path, serde_json::to_vec_pretty(model).unwrap()).unwrap();
}
#[test]
fn fixture_cases_assert_all_six_directional_counts() {
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/diff");
let cases = [
(
"positive",
diff::DiffCounts {
added_nodes: 1,
removed_nodes: 1,
changed_nodes: 1,
added_edges: 2,
removed_edges: 1,
changed_edges: 1,
},
),
(
"negative",
diff::DiffCounts {
added_nodes: 0,
removed_nodes: 0,
changed_nodes: 0,
added_edges: 0,
removed_edges: 0,
changed_edges: 0,
},
),
];
for (name, expected) in cases {
let directory = tempfile::tempdir().unwrap();
let repository = directory.path().join("repository");
fs::create_dir(&repository).unwrap();
let baseline_source = fixtures.join(name).join("baseline/lib.rs");
let current_source = fixtures.join(name).join("current/lib.rs");
fs::copy(baseline_source, repository.join("lib.rs")).unwrap();
let baseline = scan::scan(&repository).unwrap();
fs::copy(current_source, repository.join("lib.rs")).unwrap();
let current = scan::scan(&repository).unwrap();
assert_eq!(diff::between(&baseline, &current).unwrap().counts, expected);
}
}
#[test]
fn check_preserves_existing_gates_and_adds_explicit_diff_gates() {
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
let source = project.join("lib.rs");
fs::write(&source, "fn alpha() {}\n").unwrap();
let baseline_model = scan::scan(&project).unwrap();
let baseline = directory.path().join("baseline.json");
write_model(&baseline, &baseline_model);
fs::write(&source, "fn alpha() {}\nfn beta() { alpha(); }\n").unwrap();
let current_model = scan::scan(&project).unwrap();
let current = directory.path().join("current.json");
write_model(&current, &current_model);
let diff = directory.path().join("diff.json");
let existing = Command::new(env!("CARGO_BIN_EXE_brick"))
.args(["check", current.to_str().unwrap(), "--min-score", "0"])
.output()
.unwrap();
assert!(
existing.status.success(),
"{}",
String::from_utf8_lossy(&existing.stderr)
);
let non_finite = Command::new(env!("CARGO_BIN_EXE_brick"))
.args(["check", current.to_str().unwrap(), "--min-score", "NaN"])
.output()
.unwrap();
assert!(!non_finite.status.success());
assert!(String::from_utf8_lossy(&non_finite.stderr).contains("finite number"));
let passing = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
current.to_str().unwrap(),
"--min-score",
"0",
"--baseline",
baseline.to_str().unwrap(),
"--diff-json",
diff.to_str().unwrap(),
"--max-added-nodes",
"1",
"--max-changed-nodes",
"1",
"--max-added-edges",
"2",
])
.output()
.unwrap();
assert!(
passing.status.success(),
"{}",
String::from_utf8_lossy(&passing.stderr)
);
let value: Value = serde_json::from_slice(&fs::read(&diff).unwrap()).unwrap();
assert_eq!(value["diff_schema_version"], 2);
assert_eq!(value["building_schema_version"], 4);
assert_eq!(value["comparison_policy"], "strict");
assert_eq!(value["baseline_root"], baseline_model.root);
assert_eq!(value["current_root"], current_model.root);
assert!(
value["repository_id"]
.as_str()
.is_some_and(|id| id.starts_with("repository:"))
);
assert_eq!(value["counts"]["added_nodes"], 1);
assert_eq!(value["counts"]["changed_nodes"], 1);
let failing = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
current.to_str().unwrap(),
"--min-score",
"0",
"--baseline",
baseline.to_str().unwrap(),
"--max-added-nodes",
"0",
])
.output()
.unwrap();
assert!(!failing.status.success());
assert!(String::from_utf8_lossy(&failing.stderr).contains("added nodes 1 exceed 0"));
}
#[test]
fn check_rejects_diff_options_without_baseline_and_incompatible_models() {
let directory = tempfile::tempdir().unwrap();
let first = directory.path().join("first");
let second = directory.path().join("second");
fs::create_dir(&first).unwrap();
fs::create_dir(&second).unwrap();
fs::write(first.join("lib.rs"), "fn first() {}\n").unwrap();
fs::write(second.join("lib.rs"), "fn second() {}\n").unwrap();
let first_json = directory.path().join("first.json");
let second_json = directory.path().join("second.json");
write_model(&first_json, &scan::scan(&first).unwrap());
write_model(&second_json, &scan::scan(&second).unwrap());
let missing = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
first_json.to_str().unwrap(),
"--min-score",
"0",
"--max-added-edges",
"0",
])
.output()
.unwrap();
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("require --baseline"));
let maximum = usize::MAX.to_string();
let explicit_maximum = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
first_json.to_str().unwrap(),
"--min-score",
"0",
"--max-added-nodes",
maximum.as_str(),
])
.output()
.unwrap();
assert!(!explicit_maximum.status.success());
assert!(String::from_utf8_lossy(&explicit_maximum.stderr).contains("require --baseline"));
let incompatible = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
second_json.to_str().unwrap(),
"--min-score",
"0",
"--baseline",
first_json.to_str().unwrap(),
])
.output()
.unwrap();
assert!(!incompatible.status.success());
let stderr = String::from_utf8_lossy(&incompatible.stderr);
assert!(stderr.contains("cannot compute structural diff"));
assert!(stderr.contains("scan roots differ"));
}
#[test]
fn relocated_artifacts_compare_after_checkouts_are_deleted() {
let directory = tempfile::tempdir().unwrap();
let mut models = Vec::new();
for name in ["main", "worktree"] {
let root = directory.path().join(name);
fs::create_dir(&root).unwrap();
fs::write(root.join("lib.rs"), "fn alpha() {}\n").unwrap();
let model = scan::scan(&root).unwrap();
let path = directory.path().join(format!("{name}.json"));
write_model(&path, &model);
models.push((path, model));
fs::remove_dir_all(root).unwrap();
}
let output = directory.path().join("diff.json");
let run = |baseline: bool, limit: &str| {
let mut command = Command::new(env!("CARGO_BIN_EXE_brick"));
command.arg("check").arg(&models[1].0).args([
"--min-score",
"0",
"--allow-root-relocation",
]);
if baseline {
command
.arg("--baseline")
.arg(&models[0].0)
.arg("--diff-json")
.arg(&output)
.args(["--max-changed-nodes", limit]);
}
command.output().unwrap()
};
let passing = run(true, "1");
assert!(
passing.status.success(),
"{}",
String::from_utf8_lossy(&passing.stderr)
);
let value: Value = serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
assert_eq!(value["diff_schema_version"], 2);
assert_eq!(value["comparison_policy"], "explicit-root-equivalence");
assert_eq!(value["baseline_root"], models[0].1.root);
assert_eq!(value["current_root"], models[1].1.root);
assert_eq!(value["counts"]["changed_nodes"], 1);
assert_eq!(value["counts"]["added_nodes"], 0);
assert_eq!(value["counts"]["changed_edges"], 0);
assert_eq!(value["changed_nodes"][0]["before"]["name"], "main");
assert_eq!(value["changed_nodes"][0]["after"]["name"], "worktree");
let failing = run(true, "0");
assert!(!failing.status.success());
assert!(String::from_utf8_lossy(&failing.stderr).contains("changed nodes 1 exceed 0"));
let missing = run(false, "0");
assert!(!missing.status.success());
assert!(String::from_utf8_lossy(&missing.stderr).contains("require --baseline"));
for (path, model) in models {
assert_eq!(
fs::read(path).unwrap(),
serde_json::to_vec_pretty(&model).unwrap()
);
}
}
#[test]
fn check_recomputes_metrics_instead_of_trusting_the_file() {
// `check` gated on the metrics stored in the JSON, so editing `cycle_count` and
// `score` turned a failing gate into a pass without changing the graph.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(
project.join("lib.rs"),
"pub fn alpha() { beta(); }\nfn beta() { alpha(); }\n",
)
.unwrap();
let model = scan::scan(&project).unwrap();
assert!(model.metrics.cycle_count > 0);
let honest = directory.path().join("honest.json");
write_model(&honest, &model);
let gate = |path: &Path| {
Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
path.to_str().unwrap(),
"--max-cycles",
"0",
"--min-score",
"0",
])
.output()
.unwrap()
};
assert!(!gate(&honest).status.success());
let mut value: Value = serde_json::to_value(&model).unwrap();
value["metrics"]["cycle_count"] = 0.into();
value["metrics"]["structural_health"]["score"] = 100.0.into();
let tampered = directory.path().join("tampered.json");
fs::write(&tampered, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let output = gate(&tampered);
assert!(!output.status.success(), "tampered metrics passed the gate");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(text.contains("stored metrics"), "{text}");
}
#[test]
fn a_model_from_an_older_build_passes_check_on_recomputed_metrics() {
// Any difference between stored and recomputed metrics failed the gate, so a
// model written before `metrics.resolution` existed always failed, even with
// every threshold met. Gate on the recomputed values; a mismatch is a warning.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(project.join("lib.rs"), "pub fn alpha() -> u32 { 1 }\n").unwrap();
let model = scan::scan(&project).unwrap();
let mut value: Value = serde_json::to_value(&model).unwrap();
value["metrics"]
.as_object_mut()
.unwrap()
.remove("resolution");
let older = directory.path().join("older.json");
fs::write(&older, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_brick"))
.args(["check", older.to_str().unwrap(), "--min-score", "0"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn a_scan_with_no_supported_source_is_reported_and_fails_check() {
// A Go/Java-only directory built "1 nodes / 0 edges; score 100.0" with no
// diagnostic, and `check --min-score 90 --max-cycles 0` passed: an analysis of
// nothing looked like a perfectly healthy repository.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(project.join("main.go"), "package main\nfunc main() {}\n").unwrap();
fs::write(project.join("App.java"), "class App {}\n").unwrap();
let json = directory.path().join("model.json");
let html = directory.path().join("model.html");
let build = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"build",
project.to_str().unwrap(),
"--json",
json.to_str().unwrap(),
"--html",
html.to_str().unwrap(),
])
.output()
.unwrap();
assert!(build.status.success());
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(stderr.contains("no supported source files"), "{stderr}");
let model: Value = serde_json::from_slice(&fs::read(&json).unwrap()).unwrap();
assert!(
model["diagnostics"]
.as_array()
.is_some_and(|d| d.iter().any(|d| d["message"]
.as_str()
.is_some_and(|m| m.contains("no supported source files"))))
);
let check = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
json.to_str().unwrap(),
"--min-score",
"90",
"--max-cycles",
"0",
])
.output()
.unwrap();
assert!(!check.status.success(), "an empty model passed check");
}