paths-le 0.2.2

Extract every file path in a codebase, and say whether it still points at anything
paths-le-0.2.2 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 sixteen 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

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

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

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.

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.

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

License

MIT — see LICENSE.