pathlint 0.0.12

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 current 0.0.x 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 six subcommands: check (default), doctor, where, sort, init, and catalog (with list and relations). 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"

Editor support (JSON Schema)

pathlint.toml ships with a JSON Schema generated from the live Rust types. Add this single line at the top of your config to get autocomplete and inline validation in Taplo (the dominant TOML LSP) and the Even Better TOML VS Code extension:

#:schema https://raw.githubusercontent.com/ShortArrow/pathlint/main/schemas/pathlint.schema.json

Pin to a specific release for reproducibility:

#:schema https://github.com/ShortArrow/pathlint/releases/download/v0.0.11/pathlint.schema.json

The schema is also attached as pathlint.schema.json to every GitHub Release alongside the binaries.

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.