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