use super::*;
#[test]
pub(crate) fn json_report_uses_schema_version() {
let report = AnalysisReport {
schema_version: "gruff.analysis.v1".to_string(),
tool: ToolInfo {
name: "gruff-rs".to_string(),
version: VERSION.to_string(),
},
run: RunInfo {
project_root: ".".to_string(),
format: "json".to_string(),
fail_on: "none".to_string(),
generated_at: Utc::now().to_rfc3339(),
},
summary: Summary {
advisory: 0,
warning: 0,
error: 0,
total: 0,
},
paths: PathSummary {
analysed_files: 0,
ignored_paths: Vec::new(),
missing_paths: Vec::new(),
},
diagnostics: Vec::new(),
suppressions: Vec::new(),
findings: Vec::new(),
score: ScoreReport {
composite: 100.0,
grade: "A".to_string(),
pillars: Vec::new(),
top_offenders: Vec::new(),
},
baseline: None,
suppressed_findings: Vec::new(),
};
let rendered = render_report(&report, OutputFormat::Json);
assert!(rendered.contains("\"schemaVersion\": \"gruff.analysis.v1\""));
}
const CALIBRATION_BASELINE_MANIFEST: &str = r#"[package]
name = "calibration-fixture"
version = "0.1.0"
edition = "2021"
description = "Calibration baseline."
license = "MIT"
[dependencies]
serde = "1"
"#;
const CALIBRATION_BASELINE_LOCKFILE: &str = r#"# This file is automatically @generated by Cargo.
version = 3
[[package]]
name = "calibration-fixture"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "serde"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
pub(crate) fn calibration_baseline(root: &Path) {
fs::create_dir_all(root.join("src")).expect("calibration src dir");
fs::write(root.join("README.md"), "# Calibration\n").expect("calibration readme");
fs::write(root.join("Cargo.toml"), CALIBRATION_BASELINE_MANIFEST)
.expect("calibration manifest");
fs::write(root.join("Cargo.lock"), CALIBRATION_BASELINE_LOCKFILE)
.expect("calibration lockfile");
}
pub(crate) fn write_lib(root: &Path, content: &str) {
fs::write(root.join("src/lib.rs"), content).expect("calibration lib");
}
pub(crate) fn baseline_with_lib(root: &Path, lib_content: &str) {
calibration_baseline(root);
write_lib(root, lib_content);
}
pub(crate) fn module_with_n_items(count: usize) -> String {
let mut body = String::from("/// Probe.\n");
for index in 0..count {
body.push_str(&format!("/// Item {index}.\npub fn item_{index}() {{}}\n"));
}
body
}
pub(crate) fn module_with_n_mod_decls(count: usize) -> String {
let mut body = String::from("/// Root.\n");
for index in 0..count {
body.push_str(&format!("mod child_{index} {{ pub fn unused() {{}} }}\n"));
}
body
}
type Setup = Box<dyn Fn(&Path)>;
pub(crate) struct CalibrationCase {
rule_id: &'static str,
positive: Setup,
negative: Setup,
}
pub(crate) fn case(rule_id: &'static str, positive: Setup, negative: Setup) -> CalibrationCase {
CalibrationCase {
rule_id,
positive,
negative,
}
}
pub(crate) fn run_calibration_case(case: &CalibrationCase) -> (bool, bool) {
let positive_dir = tempdir().expect("calibration positive tempdir");
(case.positive)(positive_dir.path());
let positive_report = run_project_analysis(
positive_dir.path(),
AnalysisOptions {
paths: vec![PathBuf::from(".")],
no_config: true,
no_baseline: true,
..default_test_options()
},
)
.expect("calibration positive analysis succeeds");
let positive_fired = positive_report
.findings
.iter()
.any(|finding| finding.rule_id == case.rule_id);
let negative_dir = tempdir().expect("calibration negative tempdir");
(case.negative)(negative_dir.path());
let negative_report = run_project_analysis(
negative_dir.path(),
AnalysisOptions {
paths: vec![PathBuf::from(".")],
no_config: true,
no_baseline: true,
..default_test_options()
},
)
.expect("calibration negative analysis succeeds");
let negative_fired = negative_report
.findings
.iter()
.any(|finding| finding.rule_id == case.rule_id);
(positive_fired, negative_fired)
}
mod cases_a;
mod cases_b;
mod cases_c;
pub(crate) fn calibration_cases() -> Vec<CalibrationCase> {
let mut cases = cases_a::cases();
cases.extend(cases_b::cases());
cases.extend(cases_c::cases());
cases
}
#[test]
pub(crate) fn rule_calibration_matrix_covers_every_rule() {
let _guard = analysis_lock();
let cases = calibration_cases();
let registry = crate::rules::builtin_registry();
let cases_by_rule: BTreeSet<&str> = cases.iter().map(|case| case.rule_id).collect();
let registry_ids: BTreeSet<&str> = registry
.definitions()
.iter()
.map(|definition| definition.id)
.collect();
let missing_cases: Vec<&str> = registry_ids.difference(&cases_by_rule).copied().collect();
let stale_cases: Vec<&str> = cases_by_rule.difference(®istry_ids).copied().collect();
let mut report_lines: Vec<String> = Vec::new();
let mut missing_positive: Vec<&str> = Vec::new();
let mut leaked_negative: Vec<&str> = Vec::new();
for case in &cases {
let (positive_fired, negative_fired) = run_calibration_case(case);
report_lines.push(format!(
"{rule}: positive={positive} negative={negative}",
rule = case.rule_id,
positive = if positive_fired { "FIRES" } else { "MISS" },
negative = if negative_fired { "FIRES" } else { "silent" },
));
if !positive_fired {
missing_positive.push(case.rule_id);
}
if negative_fired {
leaked_negative.push(case.rule_id);
}
}
eprintln!("\n=== Calibration matrix ===");
for line in &report_lines {
eprintln!("{line}");
}
eprintln!(
"Coverage: {}/{} rules have calibration cases. Missing: {missing_cases:?}. Stale: {stale_cases:?}",
cases_by_rule.len(),
registry_ids.len(),
);
eprintln!(
"Under-strict (positive missed): {missing_positive:?}\nOver-strict (negative fired): {leaked_negative:?}\n"
);
assert!(
missing_cases.is_empty(),
"calibration matrix missing cases for rules: {missing_cases:?}"
);
assert!(
stale_cases.is_empty(),
"calibration matrix references unknown rules: {stale_cases:?}"
);
assert!(
missing_positive.is_empty() && leaked_negative.is_empty(),
"calibration mismatches: under-strict={missing_positive:?}, over-strict={leaked_negative:?}"
);
}