paths-le 0.1.0

Find every path in a codebase and report whether it still points at anything
paths-le-0.1.0 is not a library.

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 ten LE tools are on letools.dev.

Sixty seconds

paths-le .                      # audit a tree
paths-le --deny-symlinks .      # fail on an unexpected symlink too
paths-le --no-resolve src/      # just list what is written, touch nothing
cat pkg.json | paths-le --stdin --format json
./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

Eight formats, the same eight the extension supports: JSON/JSONC, TOML, CSV, dotenv, JavaScript, TypeScript, HTML, CSS/SCSS/LESS. 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.

Files in other formats are skipped silently when they turn up in a walk, and refused loudly when you name one: a repository is full of files this has nothing to say about, and naming one means you expected otherwise.

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. ./dedupe finds dedupe.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 can ls. tsconfig path maps and bundler aliases stay out of scope.
  • missing requires 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 to ok when something is there, and comes back unresolved when 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-canonical means 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-symlinks is for the audits that exist to catch an unexpected one.
  • Canonicalisation counts without being asked. normalizePath in 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

--no-resolve         report paths 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

paths-le mcp

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. tsconfig path maps, bundler aliases and node_modules resolution 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.

Also by nolindnaidoo

Rust

LE Tools — ten editor extensions, each also an MCP server: letools.dev

Contact Developernolindnaidoo.com · GitHub · LinkedIn

License

MIT — see LICENSE.