gruff-rs 0.1.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
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(&registry_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:?}"
        );
}