gruff-rs 0.4.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::*;
use crate::report_identity::{compute_stable_identity, infer_finding_scope, FindingScope};
use serde::ser::{SerializeStruct, Serializer};

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Severity {
    Advisory,
    Warning,
    Error,
}

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Confidence {
    Low,
    Medium,
    High,
}

#[derive(Debug, Clone, Copy, Serialize, Eq, PartialEq, Ord, PartialOrd)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Pillar {
    Size,
    Complexity,
    DeadCode,
    Waste,
    Maintainability,
    Naming,
    Documentation,
    Modernisation,
    Security,
    SensitiveData,
    TestQuality,
    Design,
}

pub(crate) const SCORE_PILLARS: &[Pillar] = &[
    Pillar::Size,
    Pillar::Complexity,
    Pillar::DeadCode,
    Pillar::Maintainability,
    Pillar::Naming,
    Pillar::Documentation,
    Pillar::Modernisation,
    Pillar::Security,
    Pillar::SensitiveData,
    Pillar::TestQuality,
    Pillar::Design,
];

pub(crate) fn pillar_label(pillar: Pillar) -> &'static str {
    match pillar {
        Pillar::Size => "size",
        Pillar::Complexity => "complexity",
        Pillar::DeadCode => "dead-code",
        Pillar::Waste => "waste",
        Pillar::Maintainability => "maintainability",
        Pillar::Naming => "naming",
        Pillar::Documentation => "documentation",
        Pillar::Modernisation => "modernisation",
        Pillar::Security => "security",
        Pillar::SensitiveData => "sensitive-data",
        Pillar::TestQuality => "test-quality",
        Pillar::Design => "design",
    }
}

#[derive(Debug, Clone)]
pub(crate) struct Finding {
    pub(crate) rule_id: String,
    pub(crate) message: String,
    pub(crate) file_path: String,
    pub(crate) line: Option<usize>,
    pub(crate) end_line: Option<usize>,
    pub(crate) column: Option<usize>,
    pub(crate) severity: Severity,
    pub(crate) pillar: Pillar,
    pub(crate) secondary_pillars: Vec<Pillar>,
    pub(crate) tier: String,
    pub(crate) confidence: Confidence,
    pub(crate) symbol: Option<String>,
    pub(crate) remediation: Option<String>,
    pub(crate) metadata: Value,
    pub(crate) scope: FindingScope,
    pub(crate) fingerprint: String,
    /// Line-insensitive identity intended for external diff tooling.
    /// Computed from `rule_id`, `file_path`, and a stable subject based on
    /// scope/symbol. Independent of `fingerprint`, which
    /// remains line-sensitive so the baseline matcher in
    /// `src/baseline.rs` keeps its existing semantics.
    pub(crate) stable_identity: String,
}

impl Serialize for Finding {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut state = serializer.serialize_struct("Finding", 18)?;
        state.serialize_field("ruleId", &self.rule_id)?;
        state.serialize_field("message", &self.message)?;
        state.serialize_field("file", &self.file_path)?;
        state.serialize_field("filePath", &self.file_path)?;
        state.serialize_field("line", &self.line)?;
        state.serialize_field("endLine", &self.end_line)?;
        state.serialize_field("column", &self.column)?;
        state.serialize_field("severity", &self.severity)?;
        state.serialize_field("pillar", &self.pillar)?;
        state.serialize_field("secondaryPillars", &self.secondary_pillars)?;
        state.serialize_field("tier", &self.tier)?;
        state.serialize_field("confidence", &self.confidence)?;
        state.serialize_field("symbol", &self.symbol)?;
        state.serialize_field("remediation", &self.remediation)?;
        state.serialize_field("metadata", &self.metadata)?;
        state.serialize_field("scope", &self.scope)?;
        state.serialize_field("fingerprint", &self.fingerprint)?;
        state.serialize_field("stableIdentity", &self.stable_identity)?;
        state.end()
    }
}

pub(crate) struct FindingDescriptor {
    pub(crate) rule_id: String,
    pub(crate) message: String,
    pub(crate) file_path: String,
    pub(crate) line: Option<usize>,
    pub(crate) severity: Severity,
    pub(crate) pillar: Pillar,
    pub(crate) confidence: Confidence,
    pub(crate) symbol: Option<String>,
    pub(crate) remediation: Option<String>,
    pub(crate) metadata: Value,
}

impl Finding {
    pub(crate) fn new(descriptor: FindingDescriptor) -> Self {
        let FindingDescriptor {
            rule_id,
            message,
            file_path,
            line,
            severity,
            pillar,
            confidence,
            symbol,
            remediation,
            metadata,
        } = descriptor;
        let scope = infer_finding_scope(&rule_id, symbol.as_deref(), line);
        let mut hasher = Sha256::new();
        hasher.update(rule_id.as_bytes());
        hasher.update(b"\0");
        hasher.update(file_path.as_bytes());
        hasher.update(b"\0");
        hasher.update(line.unwrap_or_default().to_string().as_bytes());
        hasher.update(b"\0");
        hasher.update(symbol.clone().unwrap_or_default().as_bytes());
        let fingerprint = format!("{:x}", hasher.finalize())[..16].to_string();
        let stable_identity =
            compute_stable_identity(&rule_id, &file_path, scope, symbol.as_deref(), &message);

        Self {
            rule_id,
            message,
            file_path,
            line,
            end_line: None,
            column: None,
            severity,
            pillar,
            secondary_pillars: Vec::new(),
            tier: "v0.1".to_string(),
            confidence,
            symbol,
            remediation,
            metadata,
            scope,
            fingerprint,
            stable_identity,
        }
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RunDiagnostic {
    pub(crate) diagnostic_type: String,
    pub(crate) message: String,
    pub(crate) file_path: Option<String>,
    pub(crate) line: Option<usize>,
}

impl RunDiagnostic {
    pub(crate) fn is_failure(&self) -> bool {
        matches!(
            self.diagnostic_type.as_str(),
            "missing-path"
                | "read-error"
                | "parse-error"
                | "manifest-read-error"
                | "manifest-parse-error"
                | "lockfile-read-error"
                | "lockfile-parse-error"
                | "history-error"
                | "gate-config-error"
        )
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AnalysisReport {
    pub(crate) schema_version: String,
    pub(crate) tool: ToolInfo,
    pub(crate) run: RunInfo,
    pub(crate) summary: Summary,
    pub(crate) paths: PathSummary,
    pub(crate) diagnostics: Vec<RunDiagnostic>,
    pub(crate) suppressions: Vec<SuppressionSummary>,
    pub(crate) findings: Vec<Finding>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) suppressed_count: Option<usize>,
    pub(crate) score: ScoreReport,
    pub(crate) baseline: Option<BaselineReport>,
    /// Per-rule introduced/removed/net counts when a baseline or diff
    /// comparison context is active. None on full-tree runs so JSON output
    /// stays byte-identical to pre-ADR-014 consumers (the field is omitted
    /// entirely via `skip_serializing_if`). Populated by `apply_baseline`
    /// and `apply_diff_patch_filter`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) per_rule_deltas: Option<Vec<RuleDelta>>,
    #[serde(skip)]
    pub(crate) suppressed_findings: Vec<SuppressedFinding>,
    /// Severity summary over the full finding set *before* baseline suppression,
    /// consumed by `gate.scope: all`. Internal only (`#[serde(skip)]`) so the JSON
    /// schema is unchanged; equals `summary` when no baseline dropped findings.
    #[serde(skip)]
    pub(crate) all_findings_summary: Option<Summary>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RuleDelta {
    pub(crate) rule_id: String,
    pub(crate) introduced: usize,
    pub(crate) removed: usize,
    pub(crate) net: i64,
}

#[derive(Debug, Serialize)]
pub(crate) struct ToolInfo {
    pub(crate) name: String,
    pub(crate) version: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RunInfo {
    pub(crate) project_root: String,
    pub(crate) format: String,
    pub(crate) fail_on: String,
    pub(crate) generated_at: String,
}

#[derive(Debug, Serialize, Clone, Copy)]
pub(crate) struct Summary {
    pub(crate) advisory: usize,
    pub(crate) warning: usize,
    pub(crate) error: usize,
    pub(crate) total: usize,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PathSummary {
    pub(crate) analysed_files: usize,
    /// Backward-compatible flat list of ignored display paths (`gruff.analysis.v2`).
    pub(crate) ignored_paths: Vec<String>,
    /// Additive per-entry ignore detail: path + why it was ignored. Same data as
    /// `ignoredPaths` plus `source`/`pattern`; new in the changed-code-scope
    /// fix (ADR-018). `ignoredPaths` is retained so existing consumers do not break.
    pub(crate) ignored_path_details: Vec<IgnoredPath>,
    pub(crate) missing_paths: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BaselineReport {
    pub(crate) path: String,
    pub(crate) source: String,
    /// Retained for backward compatibility; equals `unchanged_count` (ADR-002 addendum).
    pub(crate) suppressed: usize,
    /// Current findings not matched by any baseline entry.
    pub(crate) new_count: usize,
    /// Current findings matched by a baseline entry (dropped from the default list).
    pub(crate) unchanged_count: usize,
    /// Baseline entries that match no current finding (resolved since baselining).
    pub(crate) absent_count: usize,
    pub(crate) generated: bool,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SuppressionSummary {
    pub(crate) index: usize,
    pub(crate) rule: String,
    pub(crate) paths: Vec<String>,
    pub(crate) message_contains: Option<String>,
    pub(crate) reason: String,
    pub(crate) suppressed: usize,
}

#[derive(Debug, Clone)]
pub(crate) struct SuppressedFinding {
    pub(crate) finding: Finding,
    pub(crate) suppression: SuppressionSummary,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct ReportSuppressions {
    pub(crate) summaries: Vec<SuppressionSummary>,
    pub(crate) suppressed_findings: Vec<SuppressedFinding>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ScoreReport {
    pub(crate) composite: f64,
    pub(crate) grade: String,
    pub(crate) pillars: Vec<PillarScore>,
    pub(crate) top_offenders: Vec<FileScore>,
}

#[derive(Debug, Serialize)]
pub(crate) struct PillarScore {
    pub(crate) pillar: Pillar,
    pub(crate) score: f64,
    pub(crate) penalty: f64,
    pub(crate) findings: usize,
}

#[derive(Debug)]
pub(crate) struct FileScore {
    pub(crate) file_path: String,
    pub(crate) score: f64,
    pub(crate) findings: usize,
}

impl Serialize for FileScore {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut state = serializer.serialize_struct("FileScore", 4)?;
        state.serialize_field("file", &self.file_path)?;
        state.serialize_field("filePath", &self.file_path)?;
        state.serialize_field("score", &self.score)?;
        state.serialize_field("findings", &self.findings)?;
        state.end()
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BaselineData {
    pub(crate) schema_version: Option<String>,
    pub(crate) entries: Vec<BaselineEntry>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BaselineEntry {
    pub(crate) fingerprint: String,
    pub(crate) rule_id: String,
    pub(crate) file_path: String,
    pub(crate) line: Option<usize>,
    pub(crate) symbol: Option<String>,
    pub(crate) message: String,
}