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