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 + the
77/// project config and package metadata. Wires
78/// `fallow_engine::project_analysis::public_api_package_entry_points` (the R4 exports-aware
79/// entry set) into the retained graph's public export key query.
80#[must_use]
81pub fn public_export_keys_for(
82    graph: &fallow_engine::module_graph::RetainedModuleGraph,
83    config: &fallow_config::ResolvedConfig,
84    root_pkg: Option<&fallow_config::PackageJson>,
85    workspaces: &[fallow_config::WorkspaceInfo],
86    root: &Path,
87) -> FxHashSet<String> {
88    let public_entries = fallow_engine::project_analysis::public_api_package_entry_points(
89        graph, config, root_pkg, workspaces,
90    );
91    graph.public_export_keys(&public_entries, root)
92}
93
94/// Compute the head-minus-base delta key set, sorted for deterministic output.
95#[must_use]
96#[allow(
97    clippy::implicit_hasher,
98    reason = "callers always pass FxHashSet; generalizing the hasher adds noise for no in-tree benefit"
99)]
100pub fn introduced_keys(head: &FxHashSet<String>, base: &FxHashSet<String>) -> Vec<String> {
101    let mut introduced: Vec<String> = head.difference(base).cloned().collect();
102    introduced.sort_unstable();
103    introduced
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
110    use fallow_types::results::{BoundaryViolation, CircularDependency};
111    use std::path::PathBuf;
112
113    fn boundary(from_zone: &str, to_zone: &str, specifier: &str) -> BoundaryViolationFinding {
114        BoundaryViolationFinding::with_actions(BoundaryViolation {
115            from_path: PathBuf::from("/p/src/a.ts"),
116            to_path: PathBuf::from("/p/src/b.ts"),
117            from_zone: from_zone.to_string(),
118            to_zone: to_zone.to_string(),
119            import_specifier: specifier.to_string(),
120            line: 1,
121            col: 0,
122        })
123    }
124
125    fn cycle(files: &[&str]) -> CircularDependencyFinding {
126        CircularDependencyFinding::with_actions(CircularDependency {
127            files: files.iter().map(PathBuf::from).collect(),
128            length: files.len(),
129            line: 1,
130            col: 0,
131            edges: vec![],
132            is_cross_package: false,
133        })
134    }
135
136    #[test]
137    fn boundary_edges_dedup_per_zone_pair_r2() {
138        // Two imports across the SAME zone pair collapse to one edge key (R2).
139        let findings = vec![
140            boundary("ui", "db", "./b"),
141            boundary("ui", "db", "./c"),
142            boundary("app", "db", "./d"),
143        ];
144        let keys = boundary_edge_keys(&findings);
145        assert_eq!(keys.len(), 2, "one key per zone pair: {keys:?}");
146        assert!(keys.contains("ui->-db"));
147        assert!(keys.contains("app->-db"));
148    }
149
150    #[test]
151    fn boundary_delta_fires_only_on_new_zone_pair() {
152        // Base has ui->db established. Head adds a second ui->db import (no new
153        // pair) plus a genuinely-new app->db pair. Only app->db is introduced.
154        let base = boundary_edge_keys(&[boundary("ui", "db", "./b")]);
155        let head = boundary_edge_keys(&[
156            boundary("ui", "db", "./b"),
157            boundary("ui", "db", "./c"),
158            boundary("app", "db", "./d"),
159        ]);
160        let introduced = introduced_keys(&head, &base);
161        assert_eq!(introduced, vec!["app->-db".to_string()]);
162    }
163
164    #[test]
165    fn cycle_key_is_rotation_independent() {
166        let root = Path::new("/p");
167        let a = cycle(&["/p/a.ts", "/p/b.ts", "/p/c.ts"]);
168        let b = cycle(&["/p/c.ts", "/p/a.ts", "/p/b.ts"]);
169        assert_eq!(cycle_key(&a, root), cycle_key(&b, root));
170    }
171
172    #[test]
173    fn cycle_delta_new_vs_pre_existing() {
174        let root = Path::new("/p");
175        let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
176        let head = cycle_keys(
177            &[
178                cycle(&["/p/a.ts", "/p/b.ts"]),
179                cycle(&["/p/x.ts", "/p/y.ts"]),
180            ],
181            root,
182        );
183        let introduced = introduced_keys(&head, &base);
184        assert_eq!(introduced, vec!["x.ts|y.ts".to_string()]);
185    }
186
187    #[test]
188    fn relocation_fires_no_delta() {
189        // R3: a "moved" cycle whose canonical file set is unchanged from base
190        // produces no delta even though the finding object is freshly built.
191        let root = Path::new("/p");
192        let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
193        let head = cycle_keys(&[cycle(&["/p/b.ts", "/p/a.ts"])], root);
194        assert!(introduced_keys(&head, &base).is_empty());
195    }
196
197    #[test]
198    fn public_api_delta_zero_for_internal_only_one_for_exports() {
199        // Pure set arithmetic mirroring the exports-aware repro: base has the
200        // exports-reachable `pub`; head adds an internal-only `priv` (NOT in the
201        // public set, so absent from both base and head public sets) AND an
202        // exports-reachable `widget`. Only `widget` is an introduced public key.
203        let base: FxHashSet<String> = std::iter::once("src/impl.ts::pub".to_string()).collect();
204        let head: FxHashSet<String> = [
205            "src/impl.ts::pub".to_string(),
206            "src/impl.ts::widget".to_string(),
207        ]
208        .into_iter()
209        .collect();
210        let introduced = introduced_keys(&head, &base);
211        assert_eq!(introduced, vec!["src/impl.ts::widget".to_string()]);
212    }
213}