xbp-analysis 10.57.0

Language-agnostic static analysis (error-handling / Aspirator-style) for XBP
Documentation
//! Analysis orchestration (depends only on ports + domain + rules).

use crate::application::rules::catalog;
use crate::application::suppress::{is_suppressed, parse_inline_suppressions};
use crate::domain::report::{AnalysisReport, SkippedRule};
use crate::domain::request::AnalysisRequest;
use crate::domain::rule::Rule;
use crate::domain::types::{LanguageId, SourceFile};
use crate::ports::inbound::{AnalysisService, LanguageAdapter, LanguageRegistry};
use crate::ports::outbound::{SourceLoader, WorkspaceDiscovery};
use std::time::Instant;

/// Default in-process registry.
pub struct AdapterRegistry {
    adapters: Vec<Box<dyn LanguageAdapter>>,
}

impl AdapterRegistry {
    pub fn new(adapters: Vec<Box<dyn LanguageAdapter>>) -> Self {
        Self { adapters }
    }

    pub fn with_defaults() -> Self {
        Self::new(vec![Box::new(
            crate::languages::rust::RustAdapter::default(),
        )])
    }
}

impl LanguageRegistry for AdapterRegistry {
    fn supported(&self) -> Vec<LanguageId> {
        self.adapters.iter().map(|a| a.id()).collect()
    }

    fn get(&self, id: &LanguageId) -> Option<&dyn LanguageAdapter> {
        self.adapters
            .iter()
            .find(|a| &a.id() == id)
            .map(|a| a.as_ref())
    }
}

/// Default analysis service implementation.
pub struct DefaultAnalysisService {
    registry: AdapterRegistry,
    discovery: Box<dyn WorkspaceDiscovery>,
    loader: Box<dyn SourceLoader>,
}

impl DefaultAnalysisService {
    pub fn new(
        registry: AdapterRegistry,
        discovery: Box<dyn WorkspaceDiscovery>,
        loader: Box<dyn SourceLoader>,
    ) -> Self {
        Self {
            registry,
            discovery,
            loader,
        }
    }

    pub fn with_defaults() -> Self {
        Self::new(
            AdapterRegistry::with_defaults(),
            Box::new(crate::adapters::filesystem::IgnoreWalkDiscovery::default()),
            Box::new(crate::adapters::filesystem::FsSourceLoader),
        )
    }

    fn languages_for_request(&self, request: &AnalysisRequest) -> Vec<LanguageId> {
        if let Some(ref only) = request.language_filter {
            return vec![only.clone()];
        }
        if !request.config.languages.is_empty() {
            return request.config.languages.clone();
        }
        self.registry.supported()
    }

    fn select_rules(
        &self,
        adapter: &dyn LanguageAdapter,
        request: &AnalysisRequest,
        skipped: &mut Vec<SkippedRule>,
    ) -> Vec<Box<dyn Rule>> {
        let caps = adapter.capabilities();
        let mut selected = Vec::new();
        for rule in catalog() {
            let meta = rule.meta();
            if !request.config.rules.allows(&meta.id) {
                continue;
            }
            if !caps.is_superset_of(&meta.required_capabilities) {
                skipped.push(SkippedRule {
                    rule_id: meta.id.clone(),
                    reason: format!(
                        "adapter `{}` missing required capabilities for rule",
                        adapter.id()
                    ),
                });
                continue;
            }
            selected.push(rule);
        }
        selected
    }
}

impl AnalysisService for DefaultAnalysisService {
    fn analyze(&self, request: &AnalysisRequest) -> Result<AnalysisReport, String> {
        let started = Instant::now();
        let languages = self.languages_for_request(request);
        let mut exclusions = request.config.path_exclusions.clone();
        exclusions.extend(request.config.generated_exclusions.iter().cloned());

        let mut paths = if request.paths.is_empty() {
            self.discovery
                .discover(&request.root, &languages, &exclusions)?
        } else {
            request.paths.clone()
        };

        // Workspace mode: include all packages under root already handled by discover.
        let _ = request.workspace;
        paths.sort();
        paths.dedup();

        let mut report = AnalysisReport {
            findings: Vec::new(),
            files_analyzed: 0,
            languages: languages.clone(),
            duration_ms: 0,
            skipped_rules: Vec::new(),
            stats: Default::default(),
        };

        for lang in &languages {
            let Some(adapter) = self.registry.get(lang) else {
                continue;
            };
            let mut sources: Vec<SourceFile> = Vec::new();
            for path in &paths {
                if !adapter.detect(path) {
                    continue;
                }
                match self.loader.load(path, lang.clone()) {
                    Ok(src) => sources.push(src),
                    Err(e) => {
                        // Soft-skip unreadable files
                        let _ = e;
                    }
                }
            }
            report.files_analyzed += sources.len();

            let mut config = request.config.clone();
            for src in &sources {
                let inline = parse_inline_suppressions(&src.content, &src.path);
                config.suppressions.extend(inline);
            }

            let model = adapter.build_model(&sources)?;
            let rules = self.select_rules(adapter, request, &mut report.skipped_rules);
            let mut findings = adapter.analyze_language(&model, &rules);
            findings.extend(adapter.language_specific_findings(&model));

            for f in findings {
                if is_suppressed(&f, &config) {
                    continue;
                }
                if let Some(min) = request.config.min_severity.or(request.config.rules.min_severity)
                {
                    if f.severity < min {
                        continue;
                    }
                }
                report.findings.push(f);
            }
        }

        report.sort_stable();
        report.recompute_stats();
        report.duration_ms = started.elapsed().as_millis() as u64;
        Ok(report)
    }
}

/// Convenience façade used by the CLI.
pub struct AnalysisEngine {
    service: DefaultAnalysisService,
}

impl AnalysisEngine {
    pub fn new() -> Self {
        Self {
            service: DefaultAnalysisService::with_defaults(),
        }
    }

    pub fn analyze(&self, request: &AnalysisRequest) -> Result<AnalysisReport, String> {
        self.service.analyze(request)
    }

    pub fn supported_languages(&self) -> Vec<LanguageId> {
        self.service.registry.supported()
    }
}

impl Default for AnalysisEngine {
    fn default() -> Self {
        Self::new()
    }
}