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, mirroring `shutil.which` from the Python
208/// reference (which `std::process::Command` does not expose 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/// The Python reference uses `s.strip()[:200]`, which slices bytes. For
513/// ASCII that is identical; for multibyte UTF-8 we still need to land on
514/// a boundary to keep the result valid.
515pub(crate) fn truncate(s: &str, max: usize) -> String {
516 if s.len() <= max {
517 return s.to_owned();
518 }
519 // Searched rather than walked with a mutable counter. A `while` loop
520 // decrementing `end` is correct, but it admits a mutation - `-=` swapped for
521 // `/=`, which is `end / 1` and therefore a no-op - that spins forever
522 // instead of failing. An infinite loop is a worse failure mode than a wrong
523 // answer, and this formulation cannot express it: the range is finite.
524 //
525 // Byte 0 is always a char boundary, so the search always succeeds.
526 let end = (0..=max)
527 .rev()
528 .find(|&i| s.is_char_boundary(i))
529 .unwrap_or(0);
530 s[..end].to_owned()
531}