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