pathlint
Verify that each command on
PATHresolves from the installer you expect.
⚠ Pre-alpha (0.0.x). Schema and CLI surface are still moving; until 0.1.0 lands, both minor and patch releases may break the TOML schema or the CLI. The 0.0.2 binary is functional — just don't bake it into anything load-bearing yet.
Why
Most "PATH problems" come from one place: the wrong copy of an executable resolves first. Examples:
- I
cargo install runexon this machine, but the binary that runs is the older one fromwinget— same name, different file. pythonshould come frommise, not from the Microsoft StoreWindowsAppsstub.nodeshould come fromvolta, not from the systemaptinstall.- macOS
gccshould come from Homebrew, not from/usr/bin/gcc.
which python will tell you what wins, but won't tell you whether
that's what should win in a form you can commit to a dotfiles repo
and check on every machine.
pathlint makes that intent explicit: write down "runex should
come from cargo, not from winget" once, and the tool checks it
on every machine you own.
How it works
Two TOML concepts:
[[expect]]— per-command expectations. "command X should be resolved from source S." This is what users actually write.[source.<name>]— how to recognize an installer on disk ("cargolives at~/.cargo/bin"). pathlint ships built-in defaults forcargo,mise,volta,aqua,winget,choco,scoop,brew_arm,brew_intel,apt,pacman,dnf,pkg,flatpak,snap,WindowsApps, and more — users only override when their layout is non-standard.
For each [[expect]], pathlint resolves the command against the real
PATH, looks at where the winning binary lives, and matches that
location to the source labels.
Status
The 0.0.x line ships a working pathlint / pathlint init /
pathlint catalog list. The TOML schema and CLI surface are still
moving, but the resolve / match / report pipeline is in place and
covered by tests. See docs/PRD.md for the full design.
What pathlint won't tell you
pathlint is path-prefix based: it resolves the command, looks at
the resolved binary's full path, and asks "does any defined source's
per-OS path appear in it as a substring?". That makes it fast (no
package-manager calls, no network), but it leaves blind spots you
should know about:
- AUR / Homebrew tap /
make install/ any custom prefix. If a binary lands somewhere not listed in your[source.<name>]entries,pathlintreportsNG (unknown source)even when the install is legitimate. Add a[source.my_prefix]for it, or accept that pathlint can't tell that case apart from a real misordering. - Symlinked system dirs. On Arch / openSUSE TW / Solus,
/usr/sbin → /usr/bin.which lsreports/usr/sbin/ls, so the built-inapt/pacman/dnfsource (/usr/bin) doesn't match. Add[source.usr_sbin] linux = "/usr/sbin"to yourpathlint.tomlif you hit this. - Which package owns this binary.
pathlintdoes not calldpkg -S/rpm -qf/pacman -Qo/brew which-formula. That's intentional in 0.0.x for speed and offline correctness; revisiting is on the 0.2 list.
The full set of known limitations and future trade-offs lives in docs/PRD.md §14, §16.
Usage
# Check the current process PATH against ./pathlint.toml
# Check the User-only or Machine-only PATH (Windows registry)
# Verbose: also show n/a expectations and the resolved PATH
# Drop a starter pathlint.toml in the current directory
# Inspect every known source (built-in + user-defined)
# Find a command's provenance and uninstall hint
# Filter doctor diagnostics for CI
pathlint.toml (minimal example)
[[]]
= "runex"
= ["cargo"]
= ["winget"]
[[]]
= "python"
= ["mise"]
= ["WindowsApps", "choco"]
[[]]
= "node"
= ["mise", "volta"]
[[]]
= "gcc"
= ["mingw", "msys"]
= ["strawberry"]
= ["windows"]
Add kind = "executable" to also verify the resolved path is an
actual executable file — catches the case where a directory of the
same name shadows the binary, or where the file the symlink points
at has gone missing:
[[]]
= "rustc"
= ["cargo"]
= "executable"
No [source.*] section is needed for any of the names above —
they're all in the built-in catalog. The whole file is the user's
intent.
To override a built-in (mise installed in a non-standard location):
[]
= "D:/tools/mise"
To add a new source:
[]
= "$HOME/dotfiles/bin"
os = [...] accepts windows | macos | linux | termux | unix.
Match is substring + case-insensitive, after env-var expansion (both
%VAR% and $VAR work everywhere) and slash normalization.
Working with mise
mise serves binaries from two distinct places, and pathlint exposes each as its own source so rules can be specific:
mise_shims—$HOME/.local/share/mise/shims/<bin>on Unix,$LocalAppData/mise/shims/<bin>on Windows. This is the layer shells front-load when you runmise activate. It's the recommended source to reference inpreferfor most rules.mise_installs—$HOME/.local/share/mise/installs/<tool>/<ver>/bin/<bin>. Hit whenmise activaterewrites PATH directly (no shims), or when a plugin (cargo-*,npm-*, ...) ships its bin underinstalls/<plugin>/<ver>/bin.mise— catch-all that matches both layers. Useful when you don't care which mise mode is in use; rules written before 0.0.3 keep working unchanged.
# Strict: only accept mise's shim layer.
[[]]
= "python"
= ["mise_shims"]
# Looser: anything mise serves is fine.
[[]]
= "node"
= ["mise"]
pathlint where <command> is plugin-aware: when the resolved
binary lives under mise/installs/<segment>/... and <segment>
starts with cargo- / npm- / pipx- / go- / aqua-, the
output adds a provenance: line and a mise uninstall ... hint
so you don't have to remember which plugin you used:
$ pathlint where lazygit
lazygit
resolved: ~/.local/share/mise/installs/cargo-jesseduffield-lazygit/0.61/bin/lazygit
sources: mise_installs, mise
provenance: cargo (via mise plugin `cargo-jesseduffield-lazygit`)
hint: mise uninstall cargo:jesseduffield-lazygit (best-guess; verify with `mise plugins ls`)
The provenance is a path heuristic — it never causes
prefer = ["cargo"] to match a mise-served binary. Source
labels stay catalog-driven; provenance is purely a where
display.
If you set MISE_DATA_DIR or XDG_DATA_HOME to a non-standard
location, override the three sources in your pathlint.toml:
[]
= "/data/tools/mise"
[]
= "/data/tools/mise/shims"
[]
= "/data/tools/mise/installs"
Pinning the catalog version
The built-in source catalog evolves: a new pathlint version may
change a source's per-OS path (because winget reshuffled its
layout, say). If you want a guarantee that your pathlint.toml
runs against a sufficiently fresh catalog, declare a minimum:
= 1
When the running binary embeds an older catalog, pathlint exits
with code 2 and a message naming the gap, instead of silently
matching against stale rules. pathlint catalog list prints the
embedded version on its first line so you can pick a value.
The opposite direction is not enforced — running against a newer
catalog is always fine. Bumping catalog_version is reserved for
real path or semantics changes; adding a new source does not bump
it, so old rules don't break.
Installation
# From crates.io
# From source (latest main)
# Pre-built binaries
# https://github.com/ShortArrow/pathlint/releases
# Linux x86_64 / Windows x86_64 / macOS x86_64 / macOS aarch64
Documentation
- 日本語 README
- PRD (English) — the full design, including the built-in source catalog
- PRD (日本語)
- Release process — how to cut a new version
- リリース手順 (日本語)
- Changelog
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.