Skip to main content

drep/languages/runner/
mod.rs

1//! Run a project's own deterministic checkers and turn their output into Findings.
2//!
3//! This is the gating half of analysis. Tool findings are precise - they come
4//! from the rules the project itself configured - so they block, while the
5//! LLM's semantic findings inform. Keeping the two apart by *source* rather
6//! than by severity is what makes the gate calibratable at all.
7//!
8//! Three states, deliberately distinct:
9//!
10//! - [`ToolStatus::Ok`] the tool ran; its findings are authoritative.
11//! - [`ToolStatus::Skipped`] the project has not configured this tool, so it
12//!   has no opinion here. A pass.
13//! - [`ToolStatus::Unavailable`] the tool should have run and could not.
14//!   **Not** a pass - reporting it as clean is the same "unanalyzed is not
15//!   clean" mistake that would let a commit gate rubber-stamp commits.
16
17use std::path::{Path, PathBuf};
18use std::process::Stdio;
19use std::time::Duration;
20
21use tokio::process::Command;
22
23use crate::analysis::findings::Finding;
24use crate::languages::spec::ToolSpec;
25
26pub mod parsers;
27
28pub use parsers::parse_output;
29
30#[cfg(test)]
31mod tests;
32
33/// A tool that has not produced output by now is hung, not slow.
34///
35/// Deterministic checkers are fast; this is not the LLM path. The Python
36/// reference uses 120s; matching it keeps the two implementations comparable
37/// in bug reports.
38pub const TOOL_TIMEOUT: Duration = Duration::from_secs(120);
39
40/// Whether a tool ran, declined to run, or failed to.
41///
42/// The three are distinct because only the third is a problem: `Skipped` is
43/// the project exercising a choice, `Unavailable` is drep failing to check
44/// something it was supposed to.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ToolStatus {
47    /// The tool ran. Its findings are authoritative.
48    Ok,
49    /// The project has not configured this tool, so it has no opinion here.
50    Skipped,
51    /// The tool should have run and could not. **Not** a pass.
52    Unavailable,
53}
54
55/// What happened when one tool was asked to check some files.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ToolOutcome {
58    pub tool: &'static str,
59    pub status: ToolStatus,
60    pub findings: Vec<Finding>,
61    pub detail: String,
62}
63
64/// The tool produced output we could not parse.
65///
66/// Raised rather than swallowed: unparseable output means we do not know
67/// whether the file is clean, and guessing "clean" is the failure this module
68/// exists to prevent.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ToolOutputError(pub String);
71
72impl std::fmt::Display for ToolOutputError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(&self.0)
75    }
76}
77
78impl std::error::Error for ToolOutputError {}
79
80/// Whether a path is a regular file that the OS will execute.
81///
82/// `pub(crate)` because `cli::init` needs the same answer when it decides
83/// whether a git hook will actually run - git ignores a non-executable hook
84/// silently, which is the same class of failure this function was written for.
85/// Two definitions of "executable" that could disagree is exactly the bug.
86///
87/// One function with the `cfg` inside its body, not two cfg-gated
88/// definitions. Two definitions means the inactive one is unreachable on
89/// this platform, so every mutation of it survives by construction and
90/// shows up as an untestable finding in `cargo mutants`. This way the
91/// mutation lands in code the tests actually run.
92pub(crate) fn is_executable(path: &Path) -> bool {
93    #[cfg(unix)]
94    {
95        use std::os::unix::fs::PermissionsExt;
96        match path.metadata() {
97            Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
98            Err(_) => false,
99        }
100    }
101    // Windows has no executable bit; existence is the only signal there is.
102    #[cfg(not(unix))]
103    {
104        path.is_file()
105    }
106}
107
108/// Find the executable for a tool, preferring the project's own copy.
109///
110/// Repo-local first so a project is checked by the version its CI runs -
111/// `node_modules/.bin/eslint` rather than whatever happens to be installed
112/// globally, which may resolve plugins differently or not at all.
113///
114/// A path that exists but is not executable is **skipped**, not failed -
115/// half-installed shims on `PATH` would otherwise block a tool that
116/// happens to be on PATH under the same name.
117pub fn resolve_tool(spec: &ToolSpec, root: &Path) -> Option<PathBuf> {
118    resolve_tool_in(spec, root, std::env::var_os("PATH").as_deref())
119}
120
121/// `resolve_tool`, against an explicit `PATH` value. See [`which_first_in`]
122/// for why this seam exists.
123pub(crate) fn resolve_tool_in(
124    spec: &ToolSpec,
125    root: &Path,
126    path: Option<&std::ffi::OsStr>,
127) -> Option<PathBuf> {
128    for relative in spec.local_paths {
129        let candidate = root.join(relative);
130        if is_executable(&candidate) {
131            return Some(candidate);
132        }
133    }
134
135    // `first()`, not `[0]`. A `ToolSpec` with an empty `command` is a
136    // definitions bug, but this function is documented never to panic and a
137    // panic here would take the whole gate down rather than reporting the
138    // tool unavailable.
139    let name = spec.command.first()?;
140    which_first_in(name, path?).map(PathBuf::from)
141}
142
143/// Look up `command` on PATH, mirroring `shutil.which` from the Python
144/// reference (which `std::process::Command` does not expose directly).
145///
146/// Crucially checks executability, not just existence: a half-installed
147/// shim on PATH that is not executable would otherwise be reported as `Ok`
148/// by `tool_status` only to fail when `run_tool` actually executed it.
149/// Using the same `is_executable` helper the repo-local branch uses keeps
150/// the two paths consistent — a path that passes one is rejected by the
151/// other is the bug this guard exists to prevent.
152/// `which_first`, against an explicit `PATH` value.
153///
154/// Split out so tests can exercise PATH lookup without mutating the process
155/// environment. `std::env::set_var` is `unsafe` in edition 2024 because any
156/// other thread reading the environment concurrently is a data race - and
157/// these tests run beside ones that spawn `git`, which reads `PATH` to find
158/// it. A test-local mutex cannot fix that: it excludes other tests that take
159/// the same mutex, not every reader in the process. Passing the value in
160/// removes the shared mutable state instead of guarding it.
161fn which_first_in(command: &str, path: &std::ffi::OsStr) -> Option<String> {
162    for dir in std::env::split_paths(path) {
163        let candidate = dir.join(command);
164        if is_executable(&candidate) {
165            return Some(candidate.to_string_lossy().into_owned());
166        }
167    }
168    None
169}
170
171/// Whether the project has opted into this tool.
172///
173/// "Style adherence where defined": a repo with no eslint config has not
174/// chosen eslint's defaults, so running it would invent findings the
175/// project never asked for.
176pub fn is_configured(spec: &ToolSpec, root: &Path) -> bool {
177    spec.config_files
178        .iter()
179        .any(|name| root.join(name).exists())
180}
181
182/// Whether this tool will run here, without running it.
183///
184/// The single derivation of eligibility, so `drep doctor` reports exactly
185/// what `drep check` will do. Deriving it twice means doctor confidently
186/// says "ready" for a tool check then skips - the failure doctor exists
187/// to prevent.
188pub fn tool_status(spec: &ToolSpec, root: &Path) -> ToolOutcome {
189    if !is_configured(spec, root) {
190        let listed = spec.config_files[..spec.config_files.len().min(3)].join(", ");
191        return ToolOutcome {
192            tool: spec.name,
193            status: ToolStatus::Skipped,
194            findings: Vec::new(),
195            detail: format!("not configured (add one of: {listed})"),
196        };
197    }
198
199    if resolve_tool(spec, root).is_none() {
200        let looked = if spec.local_paths.is_empty() {
201            "PATH".to_owned()
202        } else {
203            format!("{}, then PATH", spec.local_paths.join(", "))
204        };
205        return ToolOutcome {
206            tool: spec.name,
207            status: ToolStatus::Unavailable,
208            findings: Vec::new(),
209            detail: format!("configured but not found (looked in {looked})"),
210        };
211    }
212
213    ToolOutcome {
214        tool: spec.name,
215        status: ToolStatus::Ok,
216        findings: Vec::new(),
217        detail: "ready".to_owned(),
218    }
219}
220
221/// Run one deterministic tool over some files.
222///
223/// Never returns an error and never panics for an absent or failing tool;
224/// that is reported as [`ToolStatus::Unavailable`] so the caller can surface
225/// it rather than mistake it for a clean result.
226pub async fn run_tool(spec: &ToolSpec, root: &Path, files: &[String]) -> ToolOutcome {
227    let eligibility = tool_status(spec, root);
228    if eligibility.status != ToolStatus::Ok {
229        return eligibility;
230    }
231
232    // `tool_status` just confirmed the resolution succeeded, but the type
233    // system needs the unwrap; the `if` above is the real check.
234    let Some(executable) = resolve_tool(spec, root) else {
235        return ToolOutcome {
236            tool: spec.name,
237            status: ToolStatus::Unavailable,
238            findings: Vec::new(),
239            detail: "configured but not found".to_owned(),
240        };
241    };
242
243    // Absolutised before spawning. `resolve_tool` returns `root.join(relative)`
244    // for a repo-local hit, and the child gets `current_dir(root)` below - so a
245    // relative `root` like "repo" produced "repo/node_modules/.bin/eslint"
246    // resolved *from* "repo", i.e. "repo/repo/node_modules/...". It works today
247    // only because the CLI passes "." and the tests pass absolute temp dirs.
248    let executable = if executable.is_relative() {
249        std::env::current_dir()
250            .map(|cwd| cwd.join(&executable))
251            .unwrap_or(executable)
252    } else {
253        executable
254    };
255
256    let mut argv: Vec<String> = Vec::with_capacity(spec.command.len() + files.len());
257    argv.push(executable.to_string_lossy().into_owned());
258    argv.extend(spec.command[1..].iter().map(|s| (*s).to_owned()));
259    // A repository can contain a file whose name begins with `-`, and every
260    // checker here would read `--fix` as an option rather than a path. `--`
261    // is the conventional guard but is not universally supported across
262    // ruff/eslint/tsc/gofmt/go vet/clippy, whereas a `./` prefix is
263    // unambiguous to any argument parser and leaves ordinary paths untouched.
264    // A whole-project tool is invoked bare. `cargo clippy` rejects a path
265    // argument outright ("unexpected argument"), so appending files made every
266    // Rust run fail - reported honestly as `Unavailable`, which is why it
267    // surfaced as exit 2 on every Rust repository rather than as wrong
268    // findings. Its output is narrowed back to `files` after parsing.
269    if spec.accepts_files {
270        // A repository can contain a file whose name begins with `-`, and every
271        // checker here would read `--fix` as an option rather than a path. `--`
272        // is the conventional guard but is not universally supported across
273        // ruff/eslint/tsc/gofmt/go vet/clippy, whereas a `./` prefix is
274        // unambiguous to any argument parser and leaves ordinary paths untouched.
275        argv.extend(files.iter().map(|f| {
276            if f.starts_with('-') {
277                format!("./{f}")
278            } else {
279                f.clone()
280            }
281        }));
282    }
283
284    let mut command = Command::new(&argv[0]);
285    command.args(&argv[1..]);
286    command.current_dir(root);
287    command.stdin(Stdio::null());
288    command.stdout(Stdio::piped());
289    command.stderr(Stdio::piped());
290    command.kill_on_drop(true);
291
292    let child = match command.spawn() {
293        Ok(child) => child,
294        Err(err) => {
295            return ToolOutcome {
296                tool: spec.name,
297                status: ToolStatus::Unavailable,
298                findings: Vec::new(),
299                detail: format!("{} could not be executed: {err}", spec.name),
300            };
301        }
302    };
303
304    // `wait_with_output` drains both pipes into buffers before returning,
305    // and rejects on any IO error from the spawn or the read.
306    let output = match tokio::time::timeout(TOOL_TIMEOUT, child.wait_with_output()).await {
307        Ok(Ok(output)) => output,
308        Ok(Err(err)) => {
309            return ToolOutcome {
310                tool: spec.name,
311                status: ToolStatus::Unavailable,
312                findings: Vec::new(),
313                detail: format!("{} could not be executed: {err}", spec.name),
314            };
315        }
316        Err(_) => {
317            return ToolOutcome {
318                tool: spec.name,
319                status: ToolStatus::Unavailable,
320                findings: Vec::new(),
321                detail: format!("{} timed out after {}s", spec.name, TOOL_TIMEOUT.as_secs()),
322            };
323        }
324    };
325
326    // The exit code alone is not a verdict: ruff/eslint/clippy exit non-zero
327    // *because* they found issues, and that is the success path. But it is not
328    // irrelevant either. A tool that exits non-zero having produced no
329    // diagnostics at all did not run - a bad config, a crash, a bad
330    // invocation - and reporting that as `Ok` with zero findings is precisely
331    // the "unavailable is not a pass" failure this module exists to prevent.
332    // So the rule is the conjunction: non-zero AND nothing on the diagnostics
333    // stream.
334    let stdout = String::from_utf8_lossy(&output.stdout);
335    let stderr = String::from_utf8_lossy(&output.stderr);
336    let (diagnostics, other) = if spec.diagnostics_stream == "stderr" {
337        (stderr.as_ref(), stdout.as_ref())
338    } else {
339        (stdout.as_ref(), stderr.as_ref())
340    };
341
342    let parse_result = parse_output(
343        spec,
344        diagnostics,
345        files.first().map(String::as_str).unwrap_or(""),
346    );
347    match parse_result {
348        Ok(findings)
349            if findings.is_empty() && !output.status.success() && diagnostics.trim().is_empty() =>
350        {
351            // Exited non-zero, said nothing on the stream we read for
352            // diagnostics, and produced no findings. Whatever it did, it did
353            // not check the files. The other stream usually carries the real
354            // error, so it becomes the detail.
355            ToolOutcome {
356                tool: spec.name,
357                status: ToolStatus::Unavailable,
358                findings: Vec::new(),
359                detail: format!(
360                    "{} exited {} without producing diagnostics: {}",
361                    spec.name,
362                    output
363                        .status
364                        .code()
365                        .map_or("by signal".to_owned(), |c| c.to_string()),
366                    truncate(other.trim(), 200)
367                ),
368            }
369        }
370        Ok(findings) => ToolOutcome {
371            tool: spec.name,
372            status: ToolStatus::Ok,
373            findings: retain_requested(spec, findings, files),
374            detail: truncate(other.trim(), 200),
375        },
376        Err(err) => ToolOutcome {
377            tool: spec.name,
378            status: ToolStatus::Unavailable,
379            findings: Vec::new(),
380            detail: format!("{err}. other stream: {}", truncate(other.trim(), 200)),
381        },
382    }
383}
384
385/// Narrow a whole-project tool's findings to the files actually being checked.
386///
387/// A no-op for a tool that took the file list as arguments - it only reported
388/// on what it was given. For one that did not (`cargo clippy`), the output
389/// covers the entire crate, and a commit gate that blocked on pre-existing
390/// issues in untouched code would be unusable: the author cannot fix what they
391/// did not write, and every commit would fail until the whole crate was clean.
392///
393/// Paths are compared after stripping a leading `./`, because the tool reports
394/// them relative to the project root and the caller's list may carry the
395/// prefix the dash-guard adds.
396fn retain_requested(spec: &ToolSpec, findings: Vec<Finding>, files: &[String]) -> Vec<Finding> {
397    if spec.accepts_files {
398        return findings;
399    }
400    let wanted: std::collections::BTreeSet<&str> =
401        files.iter().map(|f| normalize_path(f)).collect();
402    findings
403        .into_iter()
404        .filter(|finding| wanted.contains(normalize_path(&finding.file_path)))
405        .collect()
406}
407
408/// A path with any leading `./` removed, for comparison.
409fn normalize_path(path: &str) -> &str {
410    path.strip_prefix("./").unwrap_or(path)
411}
412
413/// Truncate a string to at most `max` bytes, on a char boundary.
414///
415/// The Python reference uses `s.strip()[:200]`, which slices bytes. For
416/// ASCII that is identical; for multibyte UTF-8 we still need to land on
417/// a boundary to keep the result valid.
418pub(crate) fn truncate(s: &str, max: usize) -> String {
419    if s.len() <= max {
420        return s.to_owned();
421    }
422    // Searched rather than walked with a mutable counter. A `while` loop
423    // decrementing `end` is correct, but it admits a mutation - `-=` swapped for
424    // `/=`, which is `end / 1` and therefore a no-op - that spins forever
425    // instead of failing. An infinite loop is a worse failure mode than a wrong
426    // answer, and this formulation cannot express it: the range is finite.
427    //
428    // Byte 0 is always a char boundary, so the search always succeeds.
429    let end = (0..=max)
430        .rev()
431        .find(|&i| s.is_char_boundary(i))
432        .unwrap_or(0);
433    s[..end].to_owned()
434}