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