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