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}