Skip to main content

cuke_dedup/
analysis.rs

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