rpm-spec-analyzer 0.1.1

Visitor-based static analyzer library for RPM .spec files
Documentation
//! RPM113 `unreachable-branch-under-parent` — Phase 8b.
//!
//! Fires when the **first** branch (`%if EXPR`) of a conditional is
//! unsatisfiable under the accumulated ancestor path-condition. The
//! check is `path ∧ branch_effective` UNSAT, where for the first
//! branch `branch_effective == branch_expr` (no prior siblings to
//! negate). The body of the branch is therefore dead code.
//!
//! RPM072 (`constant-condition`) covers the root case `%if 0`;
//! RPM113 is its non-trivial generalisation under nested context.

use rpm_spec::ast::{Conditional, FilesContent, PreambleContent, Span, SpecItem};

use crate::diagnostic::{Applicability, Diagnostic, LintCategory, Severity, Suggestion};
use crate::lint::{Lint, LintMetadata};
use crate::rules::boolean_dnf::Dnf;
use crate::rules::path_cond::{
    BranchAnalyser, PathConditions, analyse_conditional, conjoin, is_unsat,
};
use crate::visit::Visit;

pub static UNREACHABLE_BRANCH_METADATA: LintMetadata = LintMetadata {
    id: "RPM113",
    name: "unreachable-branch-under-parent",
    description: "`%if` branch is unsatisfiable under the conjunction of ancestor conditions; \
         its body can never execute.",
    default_severity: Severity::Warn,
    category: LintCategory::Correctness,
};

#[derive(Debug, Default)]
pub struct UnreachableBranch {
    diagnostics: Vec<Diagnostic>,
    pc: PathConditions,
}

impl UnreachableBranch {
    pub fn new() -> Self {
        Self::default()
    }

    fn maybe_emit(&mut self, eff: &Option<Dnf>, anchor: Span) {
        let Some(eff) = eff else { return };
        let combined = match self.pc.current() {
            Some(path) => match conjoin(path, eff) {
                Some(d) => d,
                None => return, // explosion
            },
            None => eff.clone(),
        };
        if is_unsat(&combined) {
            self.diagnostics.push(
                Diagnostic::new(
                    &UNREACHABLE_BRANCH_METADATA,
                    Severity::Warn,
                    "`%if` branch cannot be satisfied under the enclosing condition",
                    anchor,
                )
                .with_suggestion(Suggestion::new(
                    "remove the dead `%if` block or fix the parent condition",
                    Vec::new(),
                    Applicability::Manual,
                )),
            );
        }
    }
}

impl BranchAnalyser for UnreachableBranch {
    fn pc(&mut self) -> &mut PathConditions {
        &mut self.pc
    }

    fn on_branch(&mut self, idx: usize, eff: &Option<Dnf>, anchor: Span) {
        if idx == 0 {
            self.maybe_emit(eff, anchor);
        }
    }
}

fn walk_top_body(slf: &mut UnreachableBranch, body: &[SpecItem<Span>]) {
    for item in body {
        slf.visit_item(item);
    }
}

fn walk_preamble_body(slf: &mut UnreachableBranch, body: &[PreambleContent<Span>]) {
    for c in body {
        slf.visit_preamble_content(c);
    }
}

fn walk_files_body(slf: &mut UnreachableBranch, body: &[FilesContent<Span>]) {
    for c in body {
        slf.visit_files_content(c);
    }
}

impl<'ast> Visit<'ast> for UnreachableBranch {
    fn visit_top_conditional(&mut self, node: &'ast Conditional<Span, SpecItem<Span>>) {
        analyse_conditional(self, node, walk_top_body);
    }
    fn visit_preamble_conditional(&mut self, node: &'ast Conditional<Span, PreambleContent<Span>>) {
        analyse_conditional(self, node, walk_preamble_body);
    }
    fn visit_files_conditional(&mut self, node: &'ast Conditional<Span, FilesContent<Span>>) {
        analyse_conditional(self, node, walk_files_body);
    }
}

impl Lint for UnreachableBranch {
    fn metadata(&self) -> &'static LintMetadata {
        &UNREACHABLE_BRANCH_METADATA
    }
    fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
        std::mem::take(&mut self.diagnostics)
    }
}

// =====================================================================
// Tests
// =====================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::parse;

    fn run(src: &str) -> Vec<Diagnostic> {
        let outcome = parse(src);
        let mut lint = UnreachableBranch::new();
        lint.visit_spec(&outcome.spec);
        lint.take_diagnostics()
    }

    #[test]
    fn rpm113_flags_negated_nested() {
        let src = "Name: x\n%if !X\n%if X\n%global foo bar\n%endif\n%endif\n";
        let diags = run(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].lint_id, "RPM113");
    }

    #[test]
    fn rpm113_silent_for_satisfiable() {
        // path = X || Y; inner branch = X — SAT under cube{Y+, X-}.
        let src = "Name: x\n%if X || Y\n%if X\n%global foo bar\n%endif\n%endif\n";
        assert!(run(src).is_empty());
    }

    #[test]
    fn rpm113_silent_at_root_level() {
        // %if 0 is RPM072 territory, not RPM113.
        let src = "Name: x\n%if 0\n%global foo bar\n%endif\n";
        assert!(run(src).is_empty());
    }

    #[test]
    fn rpm113_silent_under_taint() {
        // Parent uses Raw-fallback expression — every nested branch
        // is skipped because the analysis cannot proceed.
        let src = "Name: x\n%if 0%{?rhel} > 7 && some_unparseable_thing\n\
                   %if X\n%global foo bar\n%endif\n%endif\n";
        // We don't strictly assert empty — depending on parser behaviour
        // the outer may be Raw or Parsed. The contract: no spurious
        // RPM113 firing when the outer is opaque.
        for d in run(src) {
            assert_ne!(
                d.lint_id, "RPM113",
                "no RPM113 fire under opaque parent: {d:?}"
            );
        }
    }

    #[test]
    fn rpm113_flags_negated_arch() {
        // %ifarch x86_64 → %ifnarch x86_64 is unreachable.
        // Easier formulation: %ifarch x86_64 then %ifnarch x86_64 inside.
        let src = "Name: x\n%ifarch x86_64\n%ifnarch x86_64\n%global foo bar\n%endif\n%endif\n";
        let diags = run(src);
        // Whether this fires depends on whether the parser produces
        // ArchList for both — assert at most one RPM113 (best-effort).
        for d in &diags {
            assert_eq!(d.lint_id, "RPM113");
        }
    }
}