Skip to main content

fallow_api/audit_run/
base_files.rs

1//! Base-side file reads, and the check that lets an audit reuse the head run
2//! as its base snapshot.
3
4use std::io::Write as _;
5use std::path::{Path, PathBuf};
6
7use rustc_hash::{FxHashMap, FxHashSet};
8
9/// Whether the head run can stand in for the base snapshot.
10///
11/// True when no changed file can change a finding: each one is a Fallow cache
12/// artifact, a documentation file, or a JS/TS source whose base version has
13/// the same token stream (whitespace and comment changes). The audit then
14/// reuses the head keys as the base keys, which attributes every finding as
15/// inherited, without a base worktree.
16#[must_use]
17#[expect(
18    clippy::implicit_hasher,
19    reason = "fallow standardizes on FxHashSet across the workspace"
20)]
21pub fn can_reuse_current_as_base(
22    root: &Path,
23    cache_dir: Option<&Path>,
24    base_ref: &str,
25    changed_files: &FxHashSet<PathBuf>,
26) -> bool {
27    let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
28        return false;
29    };
30    let canonical_cache_dir = cache_dir.and_then(|dir| dunce::canonicalize(dir).ok());
31    // Spawn the batched base-file reader lazily: a changeset of only cache
32    // artifacts or docs never touches git, so it spawns zero processes.
33    let mut reader: Option<BaseFileReader> = None;
34    for path in changed_files {
35        if cache_dir
36            .is_some_and(|dir| is_fallow_cache_artifact(path, dir, canonical_cache_dir.as_deref()))
37        {
38            continue;
39        }
40        if !is_analysis_input(path) {
41            if is_non_behavioral_doc(path) {
42                continue;
43            }
44            return false;
45        }
46        let Ok(current) = std::fs::read_to_string(path) else {
47            return false;
48        };
49        let Ok(relative) = path.strip_prefix(&git_root) else {
50            return false;
51        };
52        let reader = match reader.as_mut() {
53            Some(reader) => reader,
54            None => {
55                let Some(spawned) = BaseFileReader::spawn(root) else {
56                    return false;
57                };
58                reader.insert(spawned)
59            }
60        };
61        let base = match reader.read(base_ref, relative) {
62            BaseRead::Content(base) => base,
63            BaseRead::Missing | BaseRead::Error => return false,
64        };
65        if current == base {
66            continue;
67        }
68        if !js_ts_tokens_equivalent(path, &current, &base) {
69            return false;
70        }
71    }
72    true
73}
74
75/// The export names of changed files at the merge base of `base_ref` and
76/// `HEAD`, the base side of the audit diff.
77///
78/// `files` pairs the head path of each file with its base path (the path
79/// before a rename), both relative to `root`. The result maps each head path
80/// that git could read to the export names of its base version. A file that
81/// did not exist at the base maps to an empty set. A file that git cannot
82/// read is left out, and a read error stops the remaining reads, so a broken
83/// pipe never reads as a file without exports.
84#[must_use]
85pub fn export_names_at_base(
86    root: &Path,
87    base_ref: &str,
88    files: &[(String, String)],
89) -> FxHashMap<String, FxHashSet<String>> {
90    let mut names = FxHashMap::default();
91    if files.is_empty() {
92        return names;
93    }
94    let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
95        return names;
96    };
97    let git_root = dunce::canonicalize(&git_root).unwrap_or(git_root);
98    let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
99    let Some(mut reader) = BaseFileReader::spawn(&root) else {
100        return names;
101    };
102    let commit = crate::diff_risk::merge_base(&root, base_ref);
103    for (head, base) in files {
104        let base_path = root.join(base);
105        let Ok(relative) = base_path.strip_prefix(&git_root) else {
106            continue;
107        };
108        let exports = match reader.read(&commit, relative) {
109            BaseRead::Content(source) => {
110                fallow_engine::module_graph::export_names_in_source(&base_path, &source)
111            }
112            BaseRead::Missing => FxHashSet::default(),
113            BaseRead::Error => break,
114        };
115        names.insert(head.clone(), exports);
116    }
117    names
118}
119
120/// A long-lived `git cat-file --batch` child process that reads the base
121/// version of changed files without one `git show` for each file.
122///
123/// Requests and responses are strictly lockstep (one request line, one
124/// response), so the pipe buffers cannot deadlock. A missing object yields
125/// [`BaseRead::Missing`], and content is read with lossy UTF-8 conversion.
126///
127/// The child is a [`fallow_process::ScopedChild`], so an interrupt
128/// (SIGINT/SIGTERM) during a large read loop kills the `cat-file` process
129/// through the signal registry instead of leaving it orphaned.
130pub struct BaseFileReader {
131    /// The registered `cat-file --batch` child. `Drop` takes it and calls the
132    /// consuming wait after it closes stdin, which reaps the child and
133    /// deregisters its PID.
134    child: Option<fallow_process::ScopedChild>,
135    /// `Drop` takes and drops it before the blocking wait, which closes the
136    /// pipe so the wait cannot block.
137    stdin: Option<std::process::ChildStdin>,
138    stdout: std::io::BufReader<std::process::ChildStdout>,
139}
140
141impl BaseFileReader {
142    /// Spawn one `git cat-file --batch` process in `root`.
143    ///
144    /// Returns `None` when the spawn fails or the stdio pipes are not
145    /// available. The caller then treats the files as not reusable.
146    #[must_use]
147    pub fn spawn(root: &Path) -> Option<Self> {
148        let mut command = fallow_engine::changed_files::git_command_in(root);
149        command
150            .args(["cat-file", "--batch"])
151            .stdin(std::process::Stdio::piped())
152            .stdout(std::process::Stdio::piped())
153            .stderr(std::process::Stdio::null());
154        let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
155        let stdin = child.take_stdin()?;
156        let stdout = child.take_stdout()?;
157        Some(Self {
158            child: Some(child),
159            stdin: Some(stdin),
160            stdout: std::io::BufReader::new(stdout),
161        })
162    }
163
164    /// Read the base version of the repository-relative path `relative` at
165    /// `base_ref`.
166    ///
167    /// Writes one `<base_ref>:<path>` request line (forward slashes) and reads
168    /// exactly one response. A ` missing` header yields [`BaseRead::Missing`].
169    /// A parse or IO error, or a path with a newline (which would corrupt the
170    /// request stream), yields [`BaseRead::Error`].
171    pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
172        use std::io::{BufRead, Read};
173
174        let relative = relative.to_string_lossy().replace('\\', "/");
175        if relative.contains('\n') {
176            return BaseRead::Error;
177        }
178
179        let Some(stdin) = self.stdin.as_mut() else {
180            return BaseRead::Error;
181        };
182        if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
183            return BaseRead::Error;
184        }
185
186        let mut header = String::new();
187        if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
188            return BaseRead::Error;
189        }
190        // `git cat-file --batch` reports a missing object as `<spec> missing\n`.
191        if header.trim_end().ends_with(" missing") {
192            return BaseRead::Missing;
193        }
194        // Otherwise the header is `<oid> <type> <size>\n`.
195        let Some(size) = header
196            .trim_end()
197            .rsplit(' ')
198            .next()
199            .and_then(|raw| raw.parse::<usize>().ok())
200        else {
201            return BaseRead::Error;
202        };
203        let mut buf = vec![0u8; size];
204        if self.stdout.read_exact(&mut buf).is_err() {
205            return BaseRead::Error;
206        }
207        // Consume the one newline after the object content. An off-by-one
208        // here corrupts every later read in the batch.
209        let mut newline = [0u8; 1];
210        if self.stdout.read_exact(&mut newline).is_err() {
211            return BaseRead::Error;
212        }
213
214        BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
215    }
216}
217
218/// Outcome of one batched base-file read. "The object does not exist at base"
219/// and "the pipe or parse failed" stay apart, so a transient `git cat-file`
220/// failure never reads as an empty base file.
221#[derive(Debug, PartialEq, Eq)]
222pub enum BaseRead {
223    /// The object exists at base; lossy UTF-8 content.
224    Content(String),
225    /// `git cat-file` reported the object as ` missing`: the file is new
226    /// relative to base.
227    Missing,
228    /// A pipe write or read, or a header parse, failed (or the path cannot be
229    /// requested). Later reads from this reader are not reliable.
230    Error,
231}
232
233impl Drop for BaseFileReader {
234    fn drop(&mut self) {
235        // Close stdin so the child sees EOF and exits, then reap it through the
236        // blocking wait of the scoped child, which also deregisters the PID.
237        self.stdin.take();
238        if let Some(child) = self.child.take() {
239            let _ = child.wait();
240        }
241    }
242}
243
244fn is_fallow_cache_artifact(
245    path: &Path,
246    cache_dir: &Path,
247    canonical_cache_dir: Option<&Path>,
248) -> bool {
249    path.starts_with(cache_dir)
250        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
251}
252
253pub(super) fn is_analysis_input(path: &Path) -> bool {
254    matches!(
255        path.extension().and_then(|ext| ext.to_str()),
256        Some(
257            "js" | "jsx"
258                | "ts"
259                | "tsx"
260                | "mjs"
261                | "mts"
262                | "cjs"
263                | "cts"
264                | "vue"
265                | "svelte"
266                | "astro"
267                | "mdx"
268                | "css"
269                | "scss"
270        )
271    )
272}
273
274pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
275    matches!(
276        path.extension().and_then(|ext| ext.to_str()),
277        Some("md" | "markdown" | "txt" | "rst" | "adoc")
278    )
279}
280
281/// Text that fallow reads outside the token stream. The token comparison
282/// skips comments, so a change to a suppression comment, a JSDoc visibility
283/// tag or a JSDoc `import()` type can change the findings with no token
284/// change. Keep `import(` for imports in comments even though executable
285/// template literals are compared by their source spans.
286const REUSE_BLOCKING_MARKERS: &[&str] = &[
287    "fallow-ignore",
288    "@expected-unused",
289    "@public",
290    "@internal",
291    "@beta",
292    "@alpha",
293    "@api",
294    "import(",
295];
296
297fn has_reuse_blocking_marker(source: &str) -> bool {
298    REUSE_BLOCKING_MARKERS
299        .iter()
300        .any(|marker| source.contains(marker))
301}
302
303pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
304    if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
305        return false;
306    }
307    if !matches!(
308        path.extension().and_then(|ext| ext.to_str()),
309        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
310    ) {
311        return false;
312    }
313    fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    /// A severed request pipe is an error, never a missing object or empty
321    /// content, so a caller stops its scan instead of reading every later
322    /// file as empty at base.
323    #[test]
324    fn a_severed_request_pipe_is_an_error() {
325        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
326        let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
327
328        reader.stdin.take();
329
330        assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
331    }
332}