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