Skip to main content

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}