Skip to main content

cuke_dedup/
analysis.rs

1//! Rule evaluation over the shared step-definition representation.
2
3mod evidence;
4mod pairs;
5mod similarity;
6mod suppression;
7mod usage;
8
9use crate::config::Config;
10use crate::model::{AnalysisResult, FeatureStep, StepDefinition};
11use anyhow::{bail, Result};
12use serde::Serialize;
13use std::collections::BTreeMap;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub(crate) struct CandidateSourceCensus {
18    pub(crate) evaluated: usize,
19    pub(crate) skipped: u64,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub(crate) struct AnalysisCensus {
25    pub(crate) truncated: bool,
26    pub(crate) candidate_comparisons_evaluated: usize,
27    pub(crate) skipped_candidate_comparisons: u64,
28    pub(crate) truncated_structural_classes: usize,
29    pub(crate) candidate_sources: BTreeMap<String, CandidateSourceCensus>,
30}
31
32#[derive(Debug)]
33/// Analysis data plus diagnostics describing how complete the result is.
34///
35/// Callers that need to render partial results before failing should use
36/// [`analyze_with_diagnostics`]. Callers that only accept complete analysis can use [`analyze`].
37#[non_exhaustive]
38pub struct AnalysisOutcome {
39    /// Findings and analyzed inputs available before completeness handling.
40    pub result: AnalysisResult,
41    /// Diagnostics describing analysis that was bounded before it could finish.
42    ///
43    /// A non-empty list means findings are a subset of what a complete run would report, so the
44    /// absence of a finding proves nothing. Findings that are present remain valid.
45    pub incomplete: Vec<String>,
46}
47
48/// Evaluates every configured rule against extracted definitions and feature steps.
49pub fn analyze(
50    definitions: Vec<StepDefinition>,
51    feature_steps: Vec<FeatureStep>,
52    config: &Config,
53) -> Result<AnalysisResult> {
54    let outcome = analyze_with_diagnostics(definitions, feature_steps, config)?;
55    if !outcome.incomplete.is_empty() {
56        bail!(outcome.incomplete.join("; "));
57    }
58    Ok(outcome.result)
59}
60
61/// Runs analysis while preserving partial results when bounded work cannot complete.
62///
63/// A non-empty [`AnalysisOutcome::incomplete`] means findings are a subset of a complete run, so
64/// the absence of a finding proves nothing. This form lets CLI and library consumers write
65/// diagnostic reports and decide their own severity. Candidate-generation truncation and matcher
66/// compilation limits are both surfaced through `incomplete`.
67pub fn analyze_with_diagnostics(
68    definitions: Vec<StepDefinition>,
69    feature_steps: Vec<FeatureStep>,
70    config: &Config,
71) -> Result<AnalysisOutcome> {
72    Ok(analyze_internal(definitions, feature_steps, config, false, None)?.0)
73}
74
75/// Analyzes definitions with indirect usage merged from every selected Ruby source.
76/// Missing metadata in the older entry points conservatively disables Ruby unused findings.
77pub fn analyze_with_step_usage(
78    definitions: Vec<StepDefinition>,
79    feature_steps: Vec<FeatureStep>,
80    config: &Config,
81    usage: &crate::source_adapter::IndirectStepUsage,
82) -> Result<AnalysisOutcome> {
83    Ok(analyze_internal(definitions, feature_steps, config, false, Some(usage))?.0)
84}
85
86pub(crate) fn analyze_for_cli(
87    indirect_usage: &crate::source_adapter::IndirectStepUsage,
88    definitions: Vec<StepDefinition>,
89    feature_steps: Vec<FeatureStep>,
90    config: &Config,
91) -> Result<(
92    AnalysisOutcome,
93    AnalysisCensus,
94    suppression::UnmatchedSuppressionOutcome,
95)> {
96    analyze_internal(
97        definitions,
98        feature_steps,
99        config,
100        true,
101        Some(indirect_usage),
102    )
103}
104
105fn analyze_internal(
106    definitions: Vec<StepDefinition>,
107    feature_steps: Vec<FeatureStep>,
108    config: &Config,
109    include_unmatched_suppressions: bool,
110    indirect_usage: Option<&crate::source_adapter::IndirectStepUsage>,
111) -> Result<(
112    AnalysisOutcome,
113    AnalysisCensus,
114    suppression::UnmatchedSuppressionOutcome,
115)> {
116    config.validate_analysis_limits()?;
117    let mut findings = Vec::new();
118    if let Some(first) = definitions.first() {
119        let domain = first.analysis_profile().comparison_domain;
120        if definitions
121            .iter()
122            .any(|d| d.analysis_profile().comparison_domain != domain)
123        {
124            bail!("Ruby and JS/TS definitions require separate analysis runs with explicit definition and feature roots");
125        }
126    }
127    let suppressions = suppression::SuppressionIndex::new(config, &definitions);
128    let pair_analysis =
129        pairs::analyze_definition_pairs(&definitions, config, &suppressions, &mut findings);
130    let overlap_budget = config
131        .max_candidate_comparisons
132        .saturating_sub(pair_analysis.census.candidate_comparisons_evaluated);
133    let mut usage = usage::analyze_feature_usage(
134        &definitions,
135        &feature_steps,
136        config,
137        &suppressions,
138        &mut findings,
139        overlap_budget,
140    );
141    usage::apply_indirect_usage(&definitions, indirect_usage, config, &mut usage);
142    usage::analyze_unused(
143        &definitions,
144        &usage.used,
145        config,
146        &suppressions,
147        &mut findings,
148    );
149    let unmatched_suppressions = if include_unmatched_suppressions {
150        suppressions.unmatched()
151    } else {
152        suppression::UnmatchedSuppressionOutcome::default()
153    };
154    findings.sort_by(|left, right| {
155        left.primary
156            .path
157            .cmp(&right.primary.path)
158            .then(left.primary.line.cmp(&right.primary.line))
159            .then(left.primary.column.cmp(&right.primary.column))
160            .then(left.rule.cmp(&right.rule))
161    });
162    let mut census = pair_analysis.census;
163    census.candidate_comparisons_evaluated = census
164        .candidate_comparisons_evaluated
165        .saturating_add(usage.overlap_census.evaluated);
166    census.skipped_candidate_comparisons = census
167        .skipped_candidate_comparisons
168        .saturating_add(usage.overlap_census.skipped);
169    census.truncated |= usage.overlap_census.skipped > 0;
170    census
171        .candidate_sources
172        .insert("matcherOverlap".to_owned(), usage.overlap_census);
173    let mut incomplete = usage.incomplete;
174    incomplete.extend(pair_analysis.incomplete);
175    Ok((
176        AnalysisOutcome {
177            result: AnalysisResult {
178                definitions,
179                feature_steps,
180                findings,
181            },
182            incomplete,
183        },
184        census,
185        unmatched_suppressions,
186    ))
187}
188
189#[cfg(test)]
190mod tests;