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