Skip to main content

fallow_api/audit_run/
snapshot.rs

1//! The attribution key snapshot of one audit side, and the rules that make a
2//! base snapshot comparable with the head run.
3
4use std::path::Path;
5
6use fallow_engine::changed_files::RenamedFile;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use super::AuditAnalysesView;
10use crate::AuditProgrammaticKeySnapshot;
11use crate::audit_keys::{
12    ComplexityBaseline, complexity_baseline, complexity_baseline_keys, dead_code_keys,
13    relative_key_path, remap_complexity_baseline_for_renames, remap_keys_for_renames, styling_keys,
14};
15use crate::review_deltas::{boundary_edge_keys, cycle_keys};
16
17/// Attribution keys of one audit side (the base commit, or the head run when
18/// the base pass is skipped).
19///
20/// Every key set is root-relative, so a base snapshot taken in a temporary
21/// worktree joins the head run by key.
22#[derive(Debug, Clone, Default)]
23pub struct AuditKeySnapshot {
24    /// Semantic identity of a type-aware dead-code pass, `None` for a
25    /// syntactic pass.
26    pub type_aware_identity: Option<fallow_types::semantic::SemanticAnalysisIdentity>,
27    /// Sorted reasons and omissions of the semantic queries that did not
28    /// complete. Two sides with different gaps cannot compare semantic keys.
29    pub type_aware_gap_signature: Vec<String>,
30    /// Dead-code keys captured before type-aware refinement. `None` when the
31    /// pass ran without type-aware analysis (then `dead_code` is already
32    /// syntactic). The degraded attribution path compares these.
33    pub syntactic_dead_code: Option<FxHashSet<String>>,
34    /// Dead-code keys.
35    pub dead_code: FxHashSet<String>,
36    /// Complexity findings by identity key, with their metric values. The
37    /// new-only gate compares the metric values of matched findings.
38    pub health: ComplexityBaseline,
39    /// Styling keys.
40    pub styling: FxHashSet<String>,
41    /// Clone-group keys.
42    pub dupes: FxHashSet<String>,
43    /// Instance locations of the clone groups, for the `diverged-clone-sibling`
44    /// rule. The paths stay base paths: the rename remap does not move them.
45    pub dupe_groups: Vec<crate::diff_risk::clones::BaseCloneGroup>,
46    /// Cross-zone boundary edge keys (`<from_zone>->-<to_zone>`), one for each
47    /// zone pair.
48    pub boundary_edges: FxHashSet<String>,
49    /// Canonical circular-dependency keys.
50    pub cycles: FxHashSet<String>,
51    /// Exports-aware public-export keys (`<rel_path>::<name>`). Empty unless
52    /// the surface computed them from the retained module graph.
53    pub public_api: FxHashSet<String>,
54    /// Branching totals per root-relative path. Threshold-blind and
55    /// suppression-blind, so a threshold override or an ignore comment cannot
56    /// move the head-versus-base comparison.
57    pub branching: FxHashMap<String, fallow_types::extract::FileBranching>,
58    /// Complexity findings of the inherited bases (see
59    /// [`AuditKeySnapshot::absorb_inherited`]). A head finding is introduced
60    /// only when it is introduced against `health` and against each of these.
61    pub inherited_health: Vec<ComplexityBaseline>,
62}
63
64impl AuditKeySnapshot {
65    /// Take the attribution keys of one set of analyses.
66    #[must_use]
67    pub fn from_view(view: &AuditAnalysesView<'_>) -> Self {
68        let mut snapshot = Self::default();
69        if let Some(dead_code) = view.dead_code.as_ref() {
70            snapshot.type_aware_identity =
71                dead_code.type_aware.and_then(|meta| meta.identity.clone());
72            snapshot.type_aware_gap_signature = dead_code
73                .type_aware
74                .map_or_else(Vec::new, type_aware_gap_signature);
75            snapshot.syntactic_dead_code = dead_code.syntactic_keys.cloned();
76            snapshot.dead_code = dead_code_keys(dead_code.results, dead_code.root);
77            snapshot.boundary_edges = boundary_edge_keys(&dead_code.results.boundary_violations);
78            snapshot.cycles = cycle_keys(&dead_code.results.circular_dependencies, dead_code.root);
79            snapshot.public_api = dead_code.public_api.cloned().unwrap_or_default();
80        }
81        if let Some(health) = view.health.as_ref() {
82            snapshot.health = complexity_baseline(health.report, health.root);
83            snapshot.styling = styling_keys(health.report, health.root);
84            snapshot.branching = health.branching.map_or_else(FxHashMap::default, |by_file| {
85                branching_keys(by_file, health.root)
86            });
87        }
88        if let Some(duplication) = view.duplication.as_ref() {
89            snapshot.dupes = duplication
90                .clone_groups
91                .iter()
92                .map(|group| crate::audit_keys::dupe_group_key(group, duplication.root))
93                .collect();
94            snapshot.dupe_groups = duplication
95                .clone_groups
96                .iter()
97                .map(|group| crate::diff_risk::clones::BaseCloneGroup {
98                    instances: crate::diff_risk::clone_spans(group, duplication.root),
99                })
100                .collect();
101        }
102        snapshot
103    }
104
105    /// Treat the findings of an inherited base as inherited too.
106    ///
107    /// An inherited base is a commit of the default branch that the change
108    /// merged. A key set takes the union, so a key that either side has is not
109    /// introduced. Complexity findings keep one baseline per side, because
110    /// their match rule pairs each base finding with at most one head finding.
111    /// The clone locations, the branching totals and the type-aware identity
112    /// stay those of the primary base. When the type-aware identity or the
113    /// query gaps differ, the refined dead-code keys stay out. `inherited`
114    /// must already use head paths.
115    pub fn absorb_inherited(&mut self, inherited: Self) {
116        let inherited_syntactic = inherited
117            .syntactic_dead_code
118            .unwrap_or_else(|| inherited.dead_code.clone());
119        if let Some(syntactic) = self.syntactic_dead_code.as_mut() {
120            syntactic.extend(inherited_syntactic);
121        }
122        // Refined keys of another semantic identity do not compare with the
123        // keys of this side, so they could hide a real finding.
124        if self.type_aware_identity == inherited.type_aware_identity
125            && self.type_aware_gap_signature == inherited.type_aware_gap_signature
126        {
127            self.dead_code.extend(inherited.dead_code);
128        }
129        self.styling.extend(inherited.styling);
130        self.dupes.extend(inherited.dupes);
131        self.boundary_edges.extend(inherited.boundary_edges);
132        self.cycles.extend(inherited.cycles);
133        self.public_api.extend(inherited.public_api);
134        self.inherited_health.push(inherited.health);
135        self.inherited_health.extend(inherited.inherited_health);
136    }
137
138    /// Relocate the keys of renamed files onto their head paths.
139    ///
140    /// Applies to every path-keyed family (dead code, complexity, styling,
141    /// duplication, cycles, public API, branching). Boundary-edge keys are
142    /// zone-pair keys without a path, and the type-aware identity has no path,
143    /// so they stay as they are. The syntactic dead-code keys of the degraded
144    /// type-aware path also stay as they are.
145    pub fn remap_for_renames(&mut self, renames: &[RenamedFile], root: &Path) {
146        let rename_map: FxHashMap<String, String> = renames
147            .iter()
148            .filter_map(|rename| {
149                let from = relative_key_path(&rename.from, root);
150                let to = relative_key_path(&rename.to, root);
151                (from != to).then_some((from, to))
152            })
153            .collect();
154        if rename_map.is_empty() {
155            return;
156        }
157        self.dead_code = remap_keys_for_renames(&self.dead_code, &rename_map);
158        self.health =
159            remap_complexity_baseline_for_renames(std::mem::take(&mut self.health), &rename_map);
160        self.styling = remap_keys_for_renames(&self.styling, &rename_map);
161        self.dupes = remap_keys_for_renames(&self.dupes, &rename_map);
162        self.cycles = remap_keys_for_renames(&self.cycles, &rename_map);
163        self.public_api = remap_keys_for_renames(&self.public_api, &rename_map);
164        // The branching payload is keyed by a bare path, not by an opaque key
165        // string, so it needs its own remap.
166        self.branching = self
167            .branching
168            .drain()
169            .map(|(path, totals)| match rename_map.get(&path) {
170                Some(renamed) => (renamed.clone(), totals),
171                None => (path, totals),
172            })
173            .collect();
174    }
175
176    /// The public key snapshot of the programmatic audit output. Its health
177    /// set also holds the styling keys.
178    #[must_use]
179    pub fn to_programmatic(&self) -> AuditProgrammaticKeySnapshot {
180        let mut health = complexity_baseline_keys(&self.health);
181        health.extend(self.styling.iter().cloned());
182        AuditProgrammaticKeySnapshot {
183            dead_code: self.dead_code.clone(),
184            health,
185            dupes: self.dupes.clone(),
186        }
187    }
188}
189
190/// Re-key absolute branching paths into the root-relative key space of the
191/// audit, so base and head entries join and the rename remap can move them.
192#[must_use]
193pub fn branching_keys(
194    by_file: &fallow_engine::health::BranchingByFile,
195    root: &Path,
196) -> FxHashMap<String, fallow_types::extract::FileBranching> {
197    by_file
198        .iter()
199        .map(|(path, totals)| (relative_key_path(path, root), *totals))
200        .collect()
201}
202
203/// Why type-aware base and head attribution cannot be compared directly, or
204/// `None` when the comparison is sound (fully syntactic runs included).
205///
206/// With a reason, the audit does not fail. It compares the syntactic key sets
207/// that each side captured before refinement, so `--gate new-only` keeps
208/// working when base and head resolve incompatible semantic identities.
209/// Compatibility comes from `SemanticAnalysisIdentity::incompatible_fields`,
210/// not raw equality: a deferred project-config hash and an absent identity
211/// both mean a side ran no semantic queries, which is compatible with any
212/// concrete identity on the other side (#2102).
213#[must_use]
214pub fn type_aware_attribution_degrade_reason(
215    base: Option<&AuditKeySnapshot>,
216    head: Option<&fallow_types::envelope::TypeAwareMeta>,
217) -> Option<&'static str> {
218    let base = base?;
219    let base_identity = base.type_aware_identity.as_ref();
220    let head_identity = head.and_then(|meta| meta.identity.as_ref());
221    if let (Some(base_identity), Some(head_identity)) = (base_identity, head_identity)
222        && !base_identity.incompatible_fields(head_identity).is_empty()
223    {
224        return Some("their semantic analysis identities are incompatible");
225    }
226    if let Some(head) = head
227        && base.type_aware_gap_signature != type_aware_gap_signature(head)
228    {
229        return Some("their incomplete semantic query reasons or omissions differ");
230    }
231    None
232}
233
234/// The warning that an audit records when it degrades type-aware attribution
235/// for `reason`.
236#[must_use]
237pub fn type_aware_degrade_warning(reason: &str) -> String {
238    format!(
239        "audit compared base and head with syntactic attribution because {reason} \
240(usually a tsconfig or compiler-options change between base and head); \
241type-aware refinement still applies to head findings, and \
242semantic-only findings stay out of the new-only gate for this run; set \
243audit.typeAware: false or pass --no-type-aware to keep the gate syntactic"
244    )
245}
246
247/// Sorted reasons and omissions of the semantic queries that did not complete.
248#[must_use]
249pub fn type_aware_gap_signature(meta: &fallow_types::envelope::TypeAwareMeta) -> Vec<String> {
250    let mut signature = meta
251        .queries
252        .iter()
253        .filter(|query| query.status != fallow_types::semantic::SemanticCompleteness::Complete)
254        .map(|query| {
255            let mut omissions = query
256                .omissions
257                .iter()
258                .map(|omission| format!("{:?}:{}", omission.reason_code, omission.count))
259                .collect::<Vec<_>>();
260            omissions.sort();
261            format!(
262                "{:?}:{:?}:{}",
263                query.capability,
264                query.reason_code,
265                omissions.join(",")
266            )
267        })
268        .collect::<Vec<_>>();
269    signature.sort();
270    signature
271}