Skip to main content

cuke_dedup/
analysis.rs

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