pathlint 0.0.41

Lint the PATH environment variable against declarative ordering rules.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# pathlint

๐ŸŒ **English** | [ๆ—ฅๆœฌ่ชž]docs/README.jp.md

[![crates.io]https://img.shields.io/crates/v/pathlint.svg]https://crates.io/crates/pathlint
[![CI]https://github.com/ShortArrow/pathlint/actions/workflows/ci.yml/badge.svg]https://github.com/ShortArrow/pathlint/actions/workflows/ci.yml
[![License: MIT OR Apache-2.0]https://img.shields.io/crates/l/pathlint.svg]#license

> 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.

---

## What it is

Most "PATH problems" come from one place: **the wrong copy of an
executable resolves first.** `which python` tells you what wins,
but not 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 in a `pathlint.toml`,
and the tool checks it on every machine you own.

## Install

```sh
# 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
```

## 60-second try

```sh
# Drop a starter pathlint.toml in the current directory
pathlint init

# Edit pathlint.toml: add one [[expect]] for a tool you actually
# care about. e.g. "rg should come from cargo, not from winget":
#
#   [[expect]]
#   command = "rg"
#   prefer  = ["cargo"]
#   avoid   = ["winget"]

# Run the check
pathlint                          # = pathlint check

# If something fails, ask why
pathlint check --explain
```

That's the loop. `[[expect]]` is the user-facing concept;
everything else is convenience around it.

Discovery is monorepo-aware (0.0.41+): when the current directory
has no `pathlint.toml`, parent directories are searched up to the
enclosing `.git`, so one config at the repo root covers every
package. `--scope=local` / `--scope=global` pins discovery to the
repo-local or the user-global (`$XDG_CONFIG_HOME/pathlint/`)
layer, and `pathlint --scope=global init` writes the starter file
there directly.

## `pathlint.toml` (minimal example)

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

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

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

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

No `[source.*]` section is needed for any of the names above โ€”
they're all in the built-in catalog (`cargo`, `mise`, `volta`,
`aqua`, `winget`, `choco`, `scoop`, `brew_arm`, `brew_intel`,
`apt`, `pacman`, `dnf`, `pkg`, `flatpak`, `snap`, `windows_apps`,
and more). The whole file is the user's intent.

To override a built-in (mise installed in a non-standard location):

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

To add a new source:

```toml
[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.

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]`):

```toml
[[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:

```toml
[[expect]]
command = "rustc"
prefer  = ["cargo"]
kind    = "executable"
```

## Where next

- `pathlint check --explain` โ€” multi-line diagnosis when an
  expectation fails (resolved / matched / prefer / avoid /
  diagnosis / hint).
- `pathlint check --json` โ€” machine-readable output for CI
  pipelines.
- `pathlint lint` โ€” lint PATH itself (duplicates, missing dirs,
  8.3 short names, env-var-shortenable entries, malformed entries,
  same-command-different-dir shadows, writeable directories,
  relative entries). Independent of `[[expect]]`. New name as of
  0.0.34; pre-0.0.34 this surface was `pathlint doctor`.
- `pathlint doctor` โ€” selfcheck pathlint itself (binary on PATH,
  `pathlint.toml` discoverable + parseable, `env_lookup` working).
  New behaviour as of 0.0.34.
- `pathlint trace <command>` โ€” show where a command resolves from,
  which sources match it, and the most plausible uninstall command.
  Plugin-aware for mise (see [Working with mise]#working-with-mise).
- `pathlint sort --dry-run` โ€” propose a PATH order that satisfies
  every applicable `[[expect]]` rule. Read-only by design; PRD ยง4
  forbids PATH mutation.
- `pathlint catalog list` โ€” every known source with its per-OS
  path. `--names-only` for a compact listing; `--all` to see every
  per-OS field even when only one is active on the current host.
- `pathlint catalog relations` โ€” declared relations between
  sources (alias / conflict / served-by-via / depends-on /
  prefer-order-over).
- `pathlint --target user` / `--target machine` โ€” Windows-only;
  read PATH from the per-user or per-machine registry instead of
  the inherited process env. The other subcommands accept the same
  flag. On Windows, the default `--target process` additionally
  reads HKCU + HKLM raw at start-up and overlays the registry's
  authored form (`%LocalAppData%\...`) on each matching entry,
  so doctor displays what the user typed in `regedit` rather than
  the OS-expanded literal โ€” see [docs/PRD.md ยง10.1]docs/PRD.md#101-path-entry-rawexpanded-duality-0023
  for the full rule.
- `pathlint check --json | jq '.[] | select(.kind != "ok")'` and
  similar machine pipelines โ€” every JSON-emitting subcommand has a
  stable `kind` discriminator (since 0.0.15).

Full design and rationale: [docs/PRD.md](docs/PRD.md) (English),
[docs/PRD.jp.md](docs/PRD.jp.md) (ๆ—ฅๆœฌ่ชž).

## 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 every popular installer; 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.

## 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
  alone. Reference the built-in `os_baseline_linux_sbin` source
  alongside the package manager:

  ```toml
  [[expect]]
  command = "ls"
  prefer = ["pacman", "os_baseline_linux_sbin"]
  ```
- **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](docs/PRD.md).

## 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.

```toml
# 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 trace <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 trace 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 `trace`
display.

If you set `MISE_DATA_DIR` or `XDG_DATA_HOME` to a non-standard
location, override the three sources in your `pathlint.toml`:

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

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

[source.mise_installs]
unix = "/data/tools/mise/installs"
```

## Operational details

The 0.0.x line ships seven subcommands: `check` (default), `lint`
(new in 0.0.34), `doctor`, `trace`, `sort`, `init`, and `catalog`
(with `list` and `relations`). The legacy `pathlint where`
(alias of `pathlint trace`) and `--rules` (alias of `--config`)
spellings were removed in 0.0.22 after a deprecation-warning
runway; use `pathlint trace` and `--config`. The TOML schema and CLI surface
are still moving, but the resolve / match / report pipeline is
in place and covered by tests.

`pathlint --version` typically runs in well under 50 ms on a
modern host. Verify on your hardware with `scripts/bench.sh`,
which wraps `hyperfine` around `--version`, `--help`, and
`catalog list --names-only`.

### 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][ebt] VS Code extension:

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

Pin to a specific release for reproducibility (replace `<TAG>`
with the version you want, e.g. `v0.0.40`):

```toml
#:schema https://github.com/ShortArrow/pathlint/releases/download/<TAG>/pathlint.schema.json
```

The schema is also attached as `pathlint.schema.json` to every
GitHub Release alongside the binaries. As of 0.0.15 each release
also ships `check.schema.json`, which describes the JSON shape of
`pathlint check --json` output for downstream consumers.

[Taplo]: https://taplo.tamasfe.dev/
[ebt]: https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml

### Streaming findings to a log shipper

`pathlint lint --json` emits an array of findings. To feed log
shippers that expect one-event-per-line (Cloudflare Logpush, Loki,
Datadog, Splunk HEC, ELK / Fluent Bit, etc.), flatten the array
with `jq`:

```sh
pathlint lint --json | jq -c '.[]'
```

Each line is a single finding with the schema documented in
`pathlint.schema.json`, so downstream parsers can rely on the
same `kind` discriminator and field shape as the array form.
For unattended runs, set `--no-glyphs` and `--color=never` to
guarantee the underlying JSON is uncoloured and ASCII even when
stdout is a terminal.

A SARIF 2.1.0 output mode (`pathlint lint --sarif`) for GitHub
Code Scanning and other static-analysis aggregators is planned
for a future release, so pathlint findings can appear in the
same PR-annotation surface as clippy and cargo-audit.

### 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:

```toml
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.

## Documentation

Each translated doc has a language switcher at the top.

- [PRD]docs/PRD.md โ€” full design, including the built-in source
  catalog
- [Architecture]docs/ARCHITECTURE.md โ€” 5-minute repo map for new
  contributors
- [Release]docs/RELEASE.md โ€” how to cut a new version
- [Changelog]CHANGELOG.md โ€” breaking-change migration notes
- [Releases]https://github.com/ShortArrow/pathlint/releases โ€”
  version history with auto-generated notes

## License

Licensed under either of:

- Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT]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.