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