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::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/// A long-lived `git cat-file --batch` child process that reads the base
78/// version of changed files without one `git show` for each file.
79///
80/// Requests and responses are strictly lockstep (one request line, one
81/// response), so the pipe buffers cannot deadlock. A missing object yields
82/// [`BaseRead::Missing`], and content is read with lossy UTF-8 conversion.
83///
84/// The child is a [`fallow_process::ScopedChild`], so an interrupt
85/// (SIGINT/SIGTERM) during a large read loop kills the `cat-file` process
86/// through the signal registry instead of leaving it orphaned.
87pub struct BaseFileReader {
88    /// The registered `cat-file --batch` child. `Drop` takes it and calls the
89    /// consuming wait after it closes stdin, which reaps the child and
90    /// deregisters its PID.
91    child: Option<fallow_process::ScopedChild>,
92    /// `Drop` takes and drops it before the blocking wait, which closes the
93    /// pipe so the wait cannot block.
94    stdin: Option<std::process::ChildStdin>,
95    stdout: std::io::BufReader<std::process::ChildStdout>,
96}
97
98impl BaseFileReader {
99    /// Spawn one `git cat-file --batch` process in `root`.
100    ///
101    /// Returns `None` when the spawn fails or the stdio pipes are not
102    /// available. The caller then treats the files as not reusable.
103    #[must_use]
104    pub fn spawn(root: &Path) -> Option<Self> {
105        let mut command = Command::new("git");
106        command
107            .args(["cat-file", "--batch"])
108            .current_dir(root)
109            .stdin(std::process::Stdio::piped())
110            .stdout(std::process::Stdio::piped())
111            .stderr(std::process::Stdio::null());
112        clear_ambient_git_env(&mut command);
113        let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
114        let stdin = child.take_stdin()?;
115        let stdout = child.take_stdout()?;
116        Some(Self {
117            child: Some(child),
118            stdin: Some(stdin),
119            stdout: std::io::BufReader::new(stdout),
120        })
121    }
122
123    /// Read the base version of the repository-relative path `relative` at
124    /// `base_ref`.
125    ///
126    /// Writes one `<base_ref>:<path>` request line (forward slashes) and reads
127    /// exactly one response. A ` missing` header yields [`BaseRead::Missing`].
128    /// A parse or IO error, or a path with a newline (which would corrupt the
129    /// request stream), yields [`BaseRead::Error`].
130    pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
131        use std::io::{BufRead, Read};
132
133        let relative = relative.to_string_lossy().replace('\\', "/");
134        if relative.contains('\n') {
135            return BaseRead::Error;
136        }
137
138        let Some(stdin) = self.stdin.as_mut() else {
139            return BaseRead::Error;
140        };
141        if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
142            return BaseRead::Error;
143        }
144
145        let mut header = String::new();
146        if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
147            return BaseRead::Error;
148        }
149        // `git cat-file --batch` reports a missing object as `<spec> missing\n`.
150        if header.trim_end().ends_with(" missing") {
151            return BaseRead::Missing;
152        }
153        // Otherwise the header is `<oid> <type> <size>\n`.
154        let Some(size) = header
155            .trim_end()
156            .rsplit(' ')
157            .next()
158            .and_then(|raw| raw.parse::<usize>().ok())
159        else {
160            return BaseRead::Error;
161        };
162        let mut buf = vec![0u8; size];
163        if self.stdout.read_exact(&mut buf).is_err() {
164            return BaseRead::Error;
165        }
166        // Consume the one newline after the object content. An off-by-one
167        // here corrupts every later read in the batch.
168        let mut newline = [0u8; 1];
169        if self.stdout.read_exact(&mut newline).is_err() {
170            return BaseRead::Error;
171        }
172
173        BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
174    }
175}
176
177/// Outcome of one batched base-file read. "The object does not exist at base"
178/// and "the pipe or parse failed" stay apart, so a transient `git cat-file`
179/// failure never reads as an empty base file.
180#[derive(Debug, PartialEq, Eq)]
181pub enum BaseRead {
182    /// The object exists at base; lossy UTF-8 content.
183    Content(String),
184    /// `git cat-file` reported the object as ` missing`: the file is new
185    /// relative to base.
186    Missing,
187    /// A pipe write or read, or a header parse, failed (or the path cannot be
188    /// requested). Later reads from this reader are not reliable.
189    Error,
190}
191
192impl Drop for BaseFileReader {
193    fn drop(&mut self) {
194        // Close stdin so the child sees EOF and exits, then reap it through the
195        // blocking wait of the scoped child, which also deregisters the PID.
196        self.stdin.take();
197        if let Some(child) = self.child.take() {
198            let _ = child.wait();
199        }
200    }
201}
202
203fn is_fallow_cache_artifact(
204    path: &Path,
205    cache_dir: &Path,
206    canonical_cache_dir: Option<&Path>,
207) -> bool {
208    path.starts_with(cache_dir)
209        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
210}
211
212pub(super) fn is_analysis_input(path: &Path) -> bool {
213    matches!(
214        path.extension().and_then(|ext| ext.to_str()),
215        Some(
216            "js" | "jsx"
217                | "ts"
218                | "tsx"
219                | "mjs"
220                | "mts"
221                | "cjs"
222                | "cts"
223                | "vue"
224                | "svelte"
225                | "astro"
226                | "mdx"
227                | "css"
228                | "scss"
229        )
230    )
231}
232
233pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
234    matches!(
235        path.extension().and_then(|ext| ext.to_str()),
236        Some("md" | "markdown" | "txt" | "rst" | "adoc")
237    )
238}
239
240/// Text that fallow reads outside the token stream. The token comparison
241/// skips comments, so a change to a suppression comment, a JSDoc visibility
242/// tag or a JSDoc `import()` type can change the findings with no token
243/// change. `import(` also covers a dynamic import whose template literal
244/// content the tokenizer does not keep.
245const REUSE_BLOCKING_MARKERS: &[&str] = &[
246    "fallow-ignore",
247    "@expected-unused",
248    "@public",
249    "@internal",
250    "@beta",
251    "@alpha",
252    "@api",
253    "import(",
254];
255
256fn has_reuse_blocking_marker(source: &str) -> bool {
257    REUSE_BLOCKING_MARKERS
258        .iter()
259        .any(|marker| source.contains(marker))
260}
261
262pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
263    if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
264        return false;
265    }
266    if !matches!(
267        path.extension().and_then(|ext| ext.to_str()),
268        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
269    ) {
270        return false;
271    }
272    fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    /// A severed request pipe is an error, never a missing object or empty
280    /// content, so a caller stops its scan instead of reading every later
281    /// file as empty at base.
282    #[test]
283    fn a_severed_request_pipe_is_an_error() {
284        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
285        let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
286
287        reader.stdin.take();
288
289        assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
290    }
291}