Useful? A star is how other developers find it — ★ GitHub · letools.dev/tools/paths-le
A path in a config file is a promise about the filesystem, and nothing
checks it. The import still compiles because the bundler resolves it
differently. The asset still loads because the symlink is still there.
The .. in the middle of that path means something other than it looks
like. paths-le reads the paths out of your files and then goes and looks:
does it exist, does it leave the tree, is it written the way it resolves,
and is anything on the way a link.
It is the second frontend of
Paths-LE, the VS Code
extension — one product, two frontends, one repository, so the two can
never read a document differently. The corpus both build against lives at
crate/fixtures/,
and CI fails on drift.
The other four ways to run it
Same engine, four other front doors. Pick the one that fits where you are; nothing here is a lesser version of anything else.
| Where | What you get | Install |
|---|---|---|
| VS Code | The extraction, in your editor, on a keystroke | Marketplace |
| Cursor, VSCodium, Windsurf | The same extension | Open VSX |
| Any MCP agent, via Node | extract_paths over stdio — the same tool this binary offers |
npx paths-le-mcp · npm |
| Zed | The MCP server as a context server | add it by hand (no listing yet) |
Which MCP server should you run? They answer identically —
fixtures/mcp-extract-paths.json runs against both and CI fails if they
diverge. Take npx paths-le-mcp if Node is already there and you only
want extract_paths. Take paths-le mcp if you want one static binary
with no runtime, or if you want paths_le_audit too — resolving paths
against the filesystem is this binary's half, and the npm server
deliberately touches no files.
All sixteen LE tools are on letools.dev.
Sixty seconds
|
./pkg.json:3:12 ./docs//guide.md [non-canonical — contains a duplicate separator]
./src/app.ts:2:16 ./gone.ts [missing — no such file or directory]
./src/app.ts:3:16 ../../escape/out [escapes root — resolves outside /repo]
5 paths in 3 files — 3 findings
The report is JSON on stdout — one object per line, one line per file — the summary above is stderr, and the exit code is the answer: 0 clear · 1 findings · 2 the question was malformed.
Every verdict is checkable by hand against the same filesystem. That is the rule the tool is built to: if a claim cannot be verified that way, it does not get made.
Install
| Route | Command | Worth knowing |
|---|---|---|
| cargo | cargo install paths-le |
Any platform, needs Rust 1.88+. |
| From source | git clone https://github.com/nolindnaidoo/paths-lecd paths-le/crate && cargo build --release |
The same build CI runs. |
No runtime, no browser, no network — it reads files and asks the filesystem about them, and that is all it ever does.
What it reads
Every text file in the tree. Nine formats have a parser behind them — JSON/JSONC, YAML, TOML, CSV, dotenv, JavaScript, TypeScript, HTML, CSS/SCSS/LESS — and everything else is read by a generic text scan: Python, Go, Markdown, XML, a Dockerfile, a Makefile.
A directory is walked the way ripgrep walks one — .gitignore honoured,
hidden files skipped — so what it looks at is the answer you already have
in your head. A file named explicitly is always read, ignore rules
included.
The scan claims two shapes and nothing else: a quoted token, which
gets the full path heuristic, and an undelimited run that carries a
path separator. That second rule is why os.path in a Python file is
not reported as a file — an extension and an attribute are the same
shape, and only the quoting tells them apart. Because a scan is generous,
its paths are not resolved unless you pass --resolve: a false
positive would otherwise arrive as a missing finding rather than a
quiet extra row.
A binary file — a NUL byte in the first 8KB, ripgrep's own test — is
skipped with no report line and counted in the summary. A file that
looked like text and could not be read as it is named, and --strict
fails the run on it.
Reading every text file means reading the ones you may not want: a
committed lockfile is text, and its integrity hashes and version ranges
match the path heuristic the same way example.com does. None of them
can become a finding — the resolver declines to claim about a value that
does not commit to being a path — but they are rows, and there are a lot
of them. On the 501-file tree the budget job scans, one lockfile
accounts for 2,000 of the 3,000 paths reported — two thirds of the
output from one file. A real TypeScript application measured the same
shape: 5,367 of 6,555. The walker is ripgrep's, so an .ignore file
naming bun.lock is the lever, and it is the same lever you already use
for rg.
The verdicts
| verdict | meaning | counts as a finding |
|---|---|---|
ok |
exists, canonical, inside the root | no |
symlinked |
exists; the path or a component is a link, target reported | with --deny-symlinks |
non-canonical |
exists, but the written form is not how it resolves | yes |
missing |
does not exist | yes |
escapes-root |
a relative path that resolves above the root | yes |
unresolved |
not checked, or not a filesystem path | no |
Five rules keep this usable rather than noisy — each one came from running it over real repositories and throwing out the answers that were technically true and practically useless:
- An import written without an extension resolves to the file it
names.
./dedupefindsdedupe.ts, and says so in the verdict. The candidate list is fixed —.ts .tsx .js .jsx .mjs .cjs .json— and every answer is a file you canls.tsconfigpath maps and bundler aliases stay out of scope. missingrequires the value to commit to being a path. It earns that two ways: explicit syntax (./x,../x,/x,C:\x), or a file extension after a separator (src/app.ts). Everything else —image/png,@heroui/styles,io.github.you/tool,^1.101.0,example.com— still resolves tookwhen something is there, and comes backunresolvedwhen it is not. Its absence is not evidence it was ever a path.- A leading
./or../is idiomatic, not a finding. A check that fires on every relative import in every codebase is a check nobody reads.non-canonicalmeans genuinely ambiguous: a duplicate separator, a trailing slash, mixed separators, or a..in the middle of a path. - An absolute path never "escapes". It is absolute by intent; it is judged on existence alone.
- A symlink is a fact by default, and a finding when you ask. The
extension treats resolving a link as an ordinary step, so that is the
default here;
--deny-symlinksis for the audits that exist to catch an unexpected one. - Canonicalisation counts without being asked.
normalizePathin the extension defines canonical form, so a path that deviates is one it would have rewritten — an audit that stayed quiet about that would be withholding the thing it was asked for. - The root is the enclosing git repository. Rooting at the directory you named instead makes every cross-package import in a monorepo an escape.
Run over the eleven repositories these rules were developed against,
six report zero findings and the rest report one or two — each of which
is a genuinely absent path. The cost of that quiet is stated plainly
above: an extensionless path written without a leading ./, like
docs/api, is no longer reported when it goes missing, because that
shape is also how bare module specifiers are written.
Options
--resolve check what a generic scan found against the filesystem too
--no-resolve report every path as written; skip the filesystem entirely
--root <dir> the boundary a relative path may not escape
(default: the enclosing git repository)
--deny-symlinks treat a symlink as a finding too
--format <format> force a format instead of inferring it from the name
--stdin read one document from stdin
--follow-symlinks resolve through symlinks when walking a tree
--hidden walk hidden files and directories too
--no-ignore walk files that .gitignore excludes
A relative path resolves against the directory of the file it was found in, never the working directory — that is what it means to the code that contains it.
In CI
- name: No broken paths
run: paths-le .
Exit 1 fails the step on a real finding. Exit 2 means the tool could not answer — an unreadable file, a directory it cannot enter — and fails it too, because an audit that silently skipped something is worse than no audit.
As an MCP server
Two tools, both returning { ok, data, diagnostics, meta }:
extract_paths— content in, paths out. Touches no filesystem. The npm server ships the same tool with byte-identical output; one corpus runs against both.paths_le_audit— files or directories in, the same reports the CLI writes to stdout.
ok means the check ran, never that the answer was yes. A file full of
broken paths is a result, not an error.
What it will not do
- It does not rewrite files. There is no
--fix. - It has no style opinions. Where you put your paths is your business; whether they point at anything is not an opinion.
- It does not learn your bundler. It appends a known extension and
looks — that is a filesystem question.
tsconfigpath maps, bundler aliases andnode_modulesresolution need a config file to be right about, and half-guessing them would produce confident wrong answers. - It never touches the network. An
https://path is classified and left alone.
Full behaviour, including what is ported from the extension bug-for-bug and why, is in SPEC.md; the engineering standard this crate is held to is in AGENTS.md, and what changed is in CHANGELOG.md.
Documentation
| What | Where |
|---|---|
| What this tool is allowed to say — scope, output contract, refusals, non-goals | SPEC.md |
| How the code is written and held together — architecture, invariants, the gates | AGENTS.md |
| The VS Code extension this shares its extraction with | README.md |
| What changed | CHANGELOG.md |
| The tool's page, and the other fifteen | letools.dev/tools/paths-le |
More from the LE family
Sixteen single-purpose tools for the work in front of every model. Each ships a Rust CLI and an MCP server. One page: letools.dev
Get it out
- String-LE — Extract every string in a codebase, with its position, so a person can read them
- Numbers-LE — Extract every hardcoded number in a codebase, so a person can check them
- Units-LE — Extract every quantity with its unit, normalized, and refuse the ambiguous ones by name
- Dates-LE — Extract every date and timestamp, and the exact instant each one resolves to
- IDs-LE — Extract every UUID, ULID, NanoID, ObjectId and Snowflake, and decode the time inside
- IPs-LE — Extract every IP address, CIDR block and MAC, normalized and classified by scope
- URLs-LE — Extract every URL in a codebase, with its protocol and exact position
- Paths-LE — Extract every file path in a codebase, and say whether it still points at anything
- Colors-LE — Extract every color in a codebase, and say which ones are not in your palette
Check it
- Regex-LE — Find every regex in a codebase, and report which can be driven into catastrophic backtracking
- Versions-LE — Find where one dependency is constrained differently across a repository's manifests
- i18n-LE — Identify the i18n library a project uses, then audit its catalogs by that library's rules
- Scrape-LE — Check whether a page is scrapeable before the scraper is written, and say when it cannot tell
Guard it
- Secrets-LE — Find hardcoded credentials in a codebase, and never print one into the report
- EnvSync-LE — Compare the dotenv files in a tree, and say which keys are missing from which
- Unicode-LE — Find the Unicode that hides meaning — bidi controls, invisibles, homoglyphs, mixed scripts
Each stands on its own: no shared crate, no published core. Where two of them agree, it is because the same answer was right twice.
Contact — nolindnaidoo.com · GitHub · LinkedIn
Also by nolindnaidoo
Rust — pixelcoords and pixelactions are one loop: pixelcoords answers where, pixelactions acts there. Their own tools, their own voice — not part of the LE family.
- pixelcoords — Freeze your screen, mark regions, get pixel-exact coordinates and crops pixelcoords.dev · crates.io · docs.rs
- pixelactions — Consume human-verified coordinates, perform the interaction, confirm it landed pixelactions.dev · crates.io · docs.rs
License
MIT — see LICENSE.