amont_runtime/hooks/python_tools.rs
1//! pre-commit-ruff and pre-commit-pyright — the two Python hooks.
2//!
3//! Both are scoped: they only fire when the repo opts in (a ruff/pyright config
4//! or the matching `[tool.*]` table), so a JS repo never pulls a Python tool.
5//!
6//! Both prefer the repo's PINNED tool over an ambient latest, in the same order
7//! the shell versions established: `uv run --no-sync` (the lockfile-pinned one
8//! CI runs) → the worktree's .venv → the MAIN worktree's .venv (a linked
9//! worktree has none of its own) → PATH → uvx (unpinned LATEST, with a warning,
10//! because it flags issues the CI-pinned version does not — phantom failures).
11
12use super::common::{
13 fail, fixing_enabled, hl, ok, repo_root, restage, run as run_tool, run_quiet, staged_files,
14 warn, which, Restaged,
15};
16use crate::check::Outcome;
17use crate::git;
18use std::path::Path;
19use std::process::{Command, Stdio};
20
21/// The extensions both Python checks consume. Exported so `registry.rs`
22/// declares the scope from the same constant — see `lint_json_yaml::EXTS` for
23/// the drift this prevents.
24pub const EXTS: &[&str] = &[".py", ".pyi"];
25
26fn tool_runs(root: &str, argv: &[String]) -> bool {
27 let Some((p, rest)) = argv.split_first() else {
28 return false;
29 };
30 Command::new(p)
31 .args(rest)
32 .arg("--version")
33 .current_dir(root)
34 .stdin(Stdio::null())
35 .stdout(Stdio::null())
36 .stderr(Stdio::null())
37 .status()
38 .map(|s| s.success())
39 .unwrap_or(false)
40}
41
42fn main_worktree_venv(tool: &str) -> Option<String> {
43 let common = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
44 let p = Path::new(&common).parent()?.join(".venv/bin").join(tool);
45 p.is_file().then(|| p.to_string_lossy().into_owned())
46}
47
48/// Returns (argv, warned_about_unpinned).
49///
50/// Every branch yields a RESOLVED path, never a bare name. `Command::new` does
51/// no PATHEXT resolution, so a bare `uv`/`uvx`/`ruff` cannot execute
52/// `uv.exe`/`ruff.cmd` on Windows: the spawn fails with "program not found" and
53/// a `Severity::Block` check reports an installed tool as broken. That is the
54/// incident `common::program` was written for, and these were the last places
55/// still handing `Command` a bare name — three of them AFTER `which()` had
56/// already succeeded and thrown the answer away.
57fn resolve_python_tool(root: &str, tool: &str) -> Option<(Vec<String>, bool)> {
58 if let Some(uv) = which("uv") {
59 let argv = vec![uv, "run".into(), "--no-sync".into(), tool.into()];
60 if tool_runs(root, &argv) {
61 return Some((argv, false));
62 }
63 }
64 let local = format!("{root}/.venv/bin/{tool}");
65 if Path::new(&local).is_file() {
66 return Some((vec![local], false));
67 }
68 if let Some(v) = main_worktree_venv(tool) {
69 return Some((vec![v], false));
70 }
71 // The path `which` already resolved, rather than probing and discarding it.
72 if let Some(found) = which(tool) {
73 return Some((vec![found], false));
74 }
75 if let Some(uvx) = which("uvx") {
76 return Some((vec![uvx, tool.into()], true));
77 }
78 None
79}
80
81fn opts_in(root: &str, configs: &[&str], table: &str) -> bool {
82 if configs.iter().any(|c| Path::new(root).join(c).is_file()) {
83 return true;
84 }
85 std::fs::read_to_string(Path::new(root).join("pyproject.toml"))
86 .map(|t| t.lines().any(|l| l.trim_start().starts_with(table)))
87 .unwrap_or(false)
88}
89
90pub fn ruff(_args: &[std::ffi::OsString]) -> Outcome {
91 let files = staged_files(EXTS);
92 if files.is_empty() {
93 return Outcome::Passed;
94 }
95 let root = repo_root();
96 if !opts_in(&root, &["ruff.toml", ".ruff.toml"], "[tool.ruff") {
97 return Outcome::Passed;
98 }
99 let Some((argv, unpinned)) = resolve_python_tool(&root, "ruff") else {
100 warn("ruff config found but no ruff/uvx binary. Install ruff or uv.");
101 return Outcome::Unavailable;
102 };
103 if unpinned {
104 warn(&format!(
105 "No pinned ruff found (.venv); using {} (latest) — may flag issues the CI-pinned ruff doesn't.",
106 hl("uvx ruff")
107 ));
108 }
109
110 // The registry has always declared `Fix::Rewrite` for this check, and
111 // `amont list --json` reported `"fix":"rewrite"` — which `agents_md`
112 // explicitly tells agents to trust — while no fixing code existed
113 // anywhere. Only prettier and the manifest's externals ever called
114 // `restage`. Rather than downgrade the declaration, the fixing is now
115 // real.
116 //
117 // Repair FIRST and QUIETLY, then let the check passes below decide. Ruff
118 // legitimately leaves findings it cannot fix, unlike `cargo fmt`, so the
119 // repair pass's own exit code says nothing about the verdict — and running
120 // it loudly would print the surviving offenders twice.
121 let mut repaired = false;
122 if fixing_enabled() {
123 let _ = run_quiet(&root, &argv, &with_files(&["check", "--fix"], &files));
124 let _ = run_quiet(&root, &argv, &with_files(&["format"], &files));
125 match restage(&files) {
126 Restaged::Staged => repaired = true,
127 Restaged::Failed(stuck) => {
128 fail(&format!(
129 "ruff rewrote these files but {} failed — the index still holds the OLD \
130 content: {}",
131 hl("git add"),
132 stuck.join(", ")
133 ));
134 return Outcome::Failed;
135 }
136 Restaged::Nothing => {}
137 }
138 }
139
140 let mut failed = false;
141 if !run_tool(&root, &argv, &with_files(&["check"], &files)) {
142 fail(&format!(
143 "Ruff lint issues. Run {}. Offenders above.",
144 hl("ruff check --fix")
145 ));
146 failed = true;
147 }
148 if !run_tool(&root, &argv, &with_files(&["format", "--check"], &files)) {
149 fail(&format!(
150 "Ruff found unformatted files. Run {} on the files listed above.",
151 hl("ruff format")
152 ));
153 failed = true;
154 }
155
156 if failed {
157 // A repair that could not finish the job still blocks — and whatever it
158 // DID fix is already staged, so the next attempt starts from there.
159 return Outcome::Failed;
160 }
161 if repaired {
162 ok("Ruff fixed and re-staged");
163 return Outcome::Fixed;
164 }
165 // An unpinned ruff RAN, and its verdict was clean — that is a pass, not a
166 // gap. The caveat above is advice about which ruff spoke, not a claim that
167 // none did.
168 ok("Ruff passed");
169 Outcome::Passed
170}
171
172/// `<sub…> --force-exclude -- <files>`.
173///
174/// `--force-exclude` on every pass so ruff honours the project's `exclude`
175/// even though the paths are handed to it explicitly. `--` before the file
176/// list because a staged file named e.g. `-x.py` would otherwise be read as a
177/// flag by ruff's own parser.
178fn with_files(sub: &[&str], files: &[String]) -> Vec<String> {
179 let mut argv: Vec<String> = sub.iter().map(|s| (*s).to_string()).collect();
180 argv.push("--force-exclude".into());
181 argv.push("--".into());
182 argv.extend(files.iter().cloned());
183 argv
184}
185
186pub fn pyright(_args: &[std::ffi::OsString]) -> Outcome {
187 let files = staged_files(EXTS);
188 if files.is_empty() {
189 return Outcome::Passed;
190 }
191 let root = repo_root();
192 if !opts_in(
193 &root,
194 &["pyrightconfig.json", "pyrightconfig.jsonc"],
195 "[tool.pyright",
196 ) {
197 return Outcome::Passed;
198 }
199 let Some((argv, _)) = resolve_python_tool(&root, "pyright") else {
200 warn("pyright config found but no pyright binary. Install pyright or uv.");
201 return Outcome::Unavailable;
202 };
203 // Scoped to the STAGED files, not the whole tree: fast enough for a
204 // pre-commit hook while catching the per-file errors that are the usual
205 // local-vs-CI gap. Pyright still resolves the whole workspace for imports,
206 // so inference is unchanged; only the reported set is scoped. CI's
207 // whole-tree run stays the authority for cross-file-only errors.
208 // PYRIGHT HAS NO `--` SEPARATOR, and this check used to pass one.
209 //
210 // Every other tool driven from this repository takes `--` to mean "flags
211 // are over" — ruff, prettier, yamllint, node, yq all do, and each has a
212 // regression test for a staged file named like a flag. Pyright does not.
213 // It has a hand-rolled argument parser that treats `--` as an ordinary
214 // path, so `pyright -- a.py` reports
215 //
216 // File or directory ".../--" does not exist
217 //
218 // and exits 4. `run_tool` sees a non-zero status, and a check declared
219 // `Severity::Block` blocked the commit. Not on bad code — on ALL code. Any
220 // repository with a `pyrightconfig.json` or a `[tool.pyright]` table could
221 // not commit a `.py` file at all while pyright was installed, and the
222 // message it got named a file nobody had written.
223 //
224 // It survived because the check's only test asserted that it does NOTHING
225 // in a repo with no config, which is the one path that never reaches this
226 // line. `linters.rs` now has both halves — a type error must block, and
227 // clean code must not — and it was the second of those that caught this in
228 // its first run. A check that fails on everything satisfies "reports a type
229 // error" perfectly.
230 //
231 // `./` in place of `--` keeps the protection that was intended. `./-p.py`
232 // begins with a dot, so no argument parser can mistake it for a flag, and
233 // pyright resolves it against the working directory `run_tool` already
234 // sets. Git reports staged paths relative to the repository root with
235 // forward slashes on every platform, so this is safe to prepend blindly.
236 let with_files: Vec<String> = files.iter().map(|f| format!("./{f}")).collect();
237 if !run_tool(&root, &argv, &with_files) {
238 fail("Pyright type errors. Please fix");
239 return Outcome::Failed;
240 }
241 ok("pyright passed");
242 Outcome::Passed
243}