Skip to main content

fallow_api/
review_deltas.rs

1//! Diff-aware deterministic deltas for the review brief (6.A).
2//!
3//! Three exports-aware / graph-structural deltas, framed new-vs-pre-existing
4//! against the audit base snapshot:
5//!
6//! 1. **boundary-violation-introduced**: a cross-zone edge present at head but not
7//!    at base. R2 (first-edge-only): keyed on the `(from_zone, to_zone)` PAIR, so
8//!    the first import that establishes a zone pair fires once; subsequent imports
9//!    across the same already-established pair do not refire.
10//! 2. **circular-dependency-introduced**: a cycle (canonical, rotation-independent
11//!    file set) present at head but not at base.
12//! 3. **public-API-surface delta**: EXPORTS-AWARE. The set of public-export keys
13//!    (`<rel_path>::<name>`) reachable through `package.json` `exports` +
14//!    re-export reachability, head-minus-base. A symbol re-exported only through
15//!    an internal barrel NOT in `exports` is in neither set, so it yields ZERO
16//!    public-API delta; one reachable through an `exports` path yields exactly
17//!    one (the Aisha repro). R4 (attribute to the exports-mapped copy only) is
18//!    encoded by which modules land in the public-API entry-point set
19//!    (`fallow_engine::project_analysis::public_api_package_entry_points`).
20//!
21//! Relocation-awareness (R3) falls out of the set-difference: a moved file that
22//! preserves its zone pair / public-export key / cycle membership produces no
23//! head-minus-base delta, because the key is path-canonical for cycles and
24//! zone-pair / `(rel_path, name)` for the others, and base already contained it.
25//!
26//! R1 (batch-consolidate public-API to ONE decision per change) is honored at the
27//! summary line: the brief renders a single "public API surface widened by N"
28//! decision, never one-per-symbol, while still carrying the added keys as
29//! evidence.
30
31use std::path::Path;
32
33use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
34use rustc_hash::FxHashSet;
35
36use crate::audit_keys::relative_key_path;
37
38/// The cross-zone edge key for R2 first-edge-only framing: one key per distinct
39/// `(from_zone, to_zone)` pair, NOT per import statement. A second import across
40/// an already-established pair shares this key, so it never re-fires the delta.
41#[must_use]
42pub fn boundary_edge_key(finding: &BoundaryViolationFinding) -> String {
43    format!(
44        "{}->-{}",
45        finding.violation.from_zone, finding.violation.to_zone
46    )
47}
48
49/// Canonical (rotation-independent) cycle key: the sorted root-relative file set.
50#[must_use]
51pub fn cycle_key(finding: &CircularDependencyFinding, root: &Path) -> String {
52    let mut files: Vec<String> = finding
53        .cycle
54        .files
55        .iter()
56        .map(|p| relative_key_path(p, root))
57        .collect();
58    files.sort_unstable();
59    files.dedup();
60    files.join("|")
61}
62
63/// Build the deduped set of cross-zone edge keys for a results' boundary
64/// violations (R2: one per zone pair).
65#[must_use]
66pub fn boundary_edge_keys(findings: &[BoundaryViolationFinding]) -> FxHashSet<String> {
67    findings.iter().map(boundary_edge_key).collect()
68}
69
70/// Build the deduped set of canonical cycle keys.
71#[must_use]
72pub fn cycle_keys(findings: &[CircularDependencyFinding], root: &Path) -> FxHashSet<String> {
73    findings.iter().map(|f| cycle_key(f, root)).collect()
74}
75
76/// Compute the exports-aware public-export key set from a retained graph.
77#[must_use]
78pub fn public_export_keys_for(
79    graph: &fallow_engine::module_graph::RetainedModuleGraph,
80    config: &fallow_config::ResolvedConfig,
81    workspaces: &[fallow_config::WorkspaceInfo],
82    root: &Path,
83) -> FxHashSet<String> {
84    fallow_engine::project_analysis::public_export_keys_for_graph(graph, config, workspaces, root)
85}
86
87/// Compute the head-minus-base delta key set, sorted for deterministic output.
88#[must_use]
89#[allow(
90    clippy::implicit_hasher,
91    reason = "callers always pass FxHashSet; generalizing the hasher adds noise for no in-tree benefit"
92)]
93pub fn introduced_keys(head: &FxHashSet<String>, base: &FxHashSet<String>) -> Vec<String> {
94    let mut introduced: Vec<String> = head.difference(base).cloned().collect();
95    introduced.sort_unstable();
96    introduced
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
103    use fallow_types::results::{BoundaryViolation, CircularDependency};
104    use std::path::PathBuf;
105
106    fn boundary(from_zone: &str, to_zone: &str, specifier: &str) -> BoundaryViolationFinding {
107        BoundaryViolationFinding::with_actions(BoundaryViolation {
108            from_path: PathBuf::from("/p/src/a.ts"),
109            to_path: PathBuf::from("/p/src/b.ts"),
110            from_zone: from_zone.to_string(),
111            to_zone: to_zone.to_string(),
112            import_specifier: specifier.to_string(),
113            line: 1,
114            col: 0,
115        })
116    }
117
118    fn cycle(files: &[&str]) -> CircularDependencyFinding {
119        CircularDependencyFinding::with_actions(CircularDependency {
120            files: files.iter().map(PathBuf::from).collect(),
121            length: files.len(),
122            line: 1,
123            col: 0,
124            edges: vec![],
125            is_cross_package: false,
126        })
127    }
128
129    #[test]
130    fn boundary_edges_dedup_per_zone_pair_r2() {
131        // Two imports across the SAME zone pair collapse to one edge key (R2).
132        let findings = vec![
133            boundary("ui", "db", "./b"),
134            boundary("ui", "db", "./c"),
135            boundary("app", "db", "./d"),
136        ];
137        let keys = boundary_edge_keys(&findings);
138        assert_eq!(keys.len(), 2, "one key per zone pair: {keys:?}");
139        assert!(keys.contains("ui->-db"));
140        assert!(keys.contains("app->-db"));
141    }
142
143    #[test]
144    fn boundary_delta_fires_only_on_new_zone_pair() {
145        // Base has ui->db established. Head adds a second ui->db import (no new
146        // pair) plus a genuinely-new app->db pair. Only app->db is introduced.
147        let base = boundary_edge_keys(&[boundary("ui", "db", "./b")]);
148        let head = boundary_edge_keys(&[
149            boundary("ui", "db", "./b"),
150            boundary("ui", "db", "./c"),
151            boundary("app", "db", "./d"),
152        ]);
153        let introduced = introduced_keys(&head, &base);
154        assert_eq!(introduced, vec!["app->-db".to_string()]);
155    }
156
157    #[test]
158    fn cycle_key_is_rotation_independent() {
159        let root = Path::new("/p");
160        let a = cycle(&["/p/a.ts", "/p/b.ts", "/p/c.ts"]);
161        let b = cycle(&["/p/c.ts", "/p/a.ts", "/p/b.ts"]);
162        assert_eq!(cycle_key(&a, root), cycle_key(&b, root));
163    }
164
165    #[test]
166    fn cycle_delta_new_vs_pre_existing() {
167        let root = Path::new("/p");
168        let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
169        let head = cycle_keys(
170            &[
171                cycle(&["/p/a.ts", "/p/b.ts"]),
172                cycle(&["/p/x.ts", "/p/y.ts"]),
173            ],
174            root,
175        );
176        let introduced = introduced_keys(&head, &base);
177        assert_eq!(introduced, vec!["x.ts|y.ts".to_string()]);
178    }
179
180    #[test]
181    fn relocation_fires_no_delta() {
182        // R3: a "moved" cycle whose canonical file set is unchanged from base
183        // produces no delta even though the finding object is freshly built.
184        let root = Path::new("/p");
185        let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
186        let head = cycle_keys(&[cycle(&["/p/b.ts", "/p/a.ts"])], root);
187        assert!(introduced_keys(&head, &base).is_empty());
188    }
189
190    #[test]
191    fn public_api_delta_zero_for_internal_only_one_for_exports() {
192        // Pure set arithmetic mirroring the exports-aware repro: base has the
193        // exports-reachable `pub`; head adds an internal-only `priv` (NOT in the
194        // public set, so absent from both base and head public sets) AND an
195        // exports-reachable `widget`. Only `widget` is an introduced public key.
196        let base: FxHashSet<String> = std::iter::once("src/impl.ts::pub".to_string()).collect();
197        let head: FxHashSet<String> = [
198            "src/impl.ts::pub".to_string(),
199            "src/impl.ts::widget".to_string(),
200        ]
201        .into_iter()
202        .collect();
203        let introduced = introduced_keys(&head, &base);
204        assert_eq!(introduced, vec!["src/impl.ts::widget".to_string()]);
205    }
206}