`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.
384 lines
14 KiB
Rust
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, ¤t).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(¤t, ¤t_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");
|
|
}
|