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