pathlint 0.0.9

Lint the PATH environment variable against declarative ordering rules.
Documentation

pathlint

crates.io CI License: MIT OR Apache-2.0

Verify that each command on PATH resolves 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 runex on this machine, but the binary that runs is the older one from winget — same name, different file.
  • python should come from mise, not from the Microsoft Store WindowsApps stub.
  • node should come from volta, not from the system apt install.
  • macOS gcc should 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:

  1. [[expect]] — per-command expectations. "command X should be resolved from source S." This is what users actually write.
  2. [source.<name>] — how to recognize an installer on disk ("cargo lives at ~/.cargo/bin"). pathlint ships built-in defaults for cargo, 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, pathlint reports NG (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 ls reports /usr/sbin/ls, so the built-in apt / pacman / dnf source (/usr/bin) doesn't match. Add [source.usr_sbin] linux = "/usr/sbin" to your pathlint.toml if you hit this.
  • Which package owns this binary. pathlint does not call dpkg -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
pathlint                          # = pathlint check

# Check the User-only or Machine-only PATH (Windows registry)
pathlint --target user
pathlint --target machine

# Verbose: also show n/a expectations and the resolved PATH
pathlint --verbose

# Explain why each NG fired (resolved / matched / prefer / avoid /
# diagnosis / hint), 0.0.7+
pathlint check --explain

# Same data, machine-readable (CI), 0.0.7+
pathlint check --json

# Drop a starter pathlint.toml in the current directory
pathlint init
pathlint init --emit-defaults     # also embeds the full source catalog

# Inspect every known source (built-in + user-defined)
pathlint catalog list             # paths for the running OS
pathlint catalog list --all       # every per-OS field
pathlint catalog list --names-only
pathlint catalog relations        # 0.0.9+: declared source relations
pathlint catalog relations --json # 0.0.9+: same, machine-readable

# Find a command's provenance and uninstall hint
pathlint where lazygit            # who installed this binary?
pathlint where lazygit --json     # 0.0.6+: machine-readable output

# Filter doctor diagnostics for CI
pathlint doctor --exclude shortenable,missing
pathlint doctor --include duplicate,malformed
pathlint doctor --json            # 0.0.7+: machine-readable output

# Propose a PATH order satisfying every [[expect]] rule (read-only)
pathlint sort                     # 0.0.8+: before/after diff
pathlint sort --json              # 0.0.8+: SortPlan JSON

pathlint.toml (minimal example)

[[expect]]
command = "runex"
prefer  = ["cargo"]
avoid   = ["winget"]

[[expect]]
command = "python"
prefer  = ["mise"]
avoid   = ["WindowsApps", "choco"]

[[expect]]
command = "node"
prefer  = ["mise", "volta"]

[[expect]]
command = "gcc"
prefer  = ["mingw", "msys"]
avoid   = ["strawberry"]
os      = ["windows"]

Add severity = "warn" to a rule to keep its NG visible without blocking CI (exit stays 0; the line is tagged [warn] instead of [NG]):

[[expect]]
command  = "rg"
prefer   = ["cargo"]
severity = "warn"   # 0.0.7+ — nudge, not a hard fail

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:

[[expect]]
command = "rustc"
prefer  = ["cargo"]
kind    = "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):

[source.mise]
windows = "D:/tools/mise"

To add a new source:

[source.my_dotfiles_bin]
unix = "$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 run mise activate. It's the recommended source to reference in prefer for most rules.
  • mise_installs$HOME/.local/share/mise/installs/<tool>/<ver>/bin/<bin>. Hit when mise activate rewrites PATH directly (no shims), or when a plugin (cargo-*, npm-*, ...) ships its bin under installs/<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.
[[expect]]
command = "python"
prefer  = ["mise_shims"]

# Looser: anything mise serves is fine.
[[expect]]
command = "node"
prefer  = ["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:

[source.mise]
unix = "/data/tools/mise"

[source.mise_shims]
unix = "/data/tools/mise/shims"

[source.mise_installs]
unix = "/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:

require_catalog = 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
cargo install pathlint

# From source (latest main)
cargo install --git https://github.com/ShortArrow/pathlint

# Pre-built binaries
# https://github.com/ShortArrow/pathlint/releases
# Linux x86_64 / Windows x86_64 / macOS x86_64 / macOS aarch64

Documentation

License

Licensed under either of:

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.