paths-le 0.2.2

Extract every file path in a codebase, and say whether it still points at anything
# Changelog

The Rust CLI and MCP server. The VS Code extension has its own
[CHANGELOG](../CHANGELOG.md) and its own version — the two products in
this repository release on their own cadence.

Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.2] - 2026-08-15

### Added

- **The crates.io page carries a demo of the CLI.** It had the icon and
  nothing else, because the only recording in the repository was of the
  VS Code extension reading an editor buffer — a clip of something this
  binary does not do. `assets/demo.tape` records the real binary against
  the files in `assets/demo/`, so the clip is reproducible (`cd assets
  && vhs demo.tape`) rather than an artifact nobody can regenerate.

### Fixed

- **`paths-le a.yaml` audits the file instead of failing.** A filename
  with no directory in front of it exited 2 with `: No such file or
  directory` — an error naming no file at all. `Path::parent` answers
  `Some("")` for a bare name rather than `None`, so the root came out
  empty and `canonicalize` refused it. `./a.yaml` and an absolute path
  were read; the same file named the way a person types it was not.

- **A relative path in such a file is resolved, not called an escape.**
  The same empty parent became the base every relative path was joined
  against, so with an explicit `--root` every one of them resolved
  outside it: a file that was there and a file that was not came back
  with one verdict, `escapes-root`. A directory argument was never
  affected, which is why the corpus stayed green — every test in it
  names its files by absolute path.

## [0.2.1] - 2026-08-15

### Fixed

- **The crates.io page shows the icon.** It lived only in the repository
  README, and that file is not the one `cargo publish` ships — the
  published README is this directory's. A relative path would not have
  fixed it: the crate is published from `crate/`, so crates.io resolves
  a relative link against `path_in_vcs` and looks for the asset below
  the crate directory rather than beside it. The image is an absolute
  URL, which every surface renders.

  No demo goes with it. `src/assets/images/demo.gif` records the
  extension reading an editor buffer, which is not what this binary
  does; the demo that belongs here is a recording of the CLI, and there
  is not one yet.

## [0.2.0] - 2026-08-14

Point it at a repository and it reads the repository. 0.1.0 read the
eight formats it had a parser for and walked past everything else, which
is most of a codebase.

### Added

- **Every text file in the tree is read now.** Python, Go, Markdown,
  XML, a Dockerfile, a Makefile, a shell script — anything that is text
  and not a binary. A file whose extension means nothing to this tool is
  no longer skipped by the walk or refused when you name it.

  **Your finding count will move, and it may move a long way.** Over two
  real codebases the paths reported went from 1,930 to 6,598 and from
  487 to 6,555. Most of that is coverage you did not have; some of it is
  one file. In the second case 5,367 of those 6,555 came out of a single
  committed lockfile, and on the 501-file tree this crate's own budget
  test scans, a lockfile accounts for 2,000 of 3,000 paths. Nothing in a
  lockfile can become a finding — the resolver declines to claim about a
  value that does not commit to being a path — but they are rows. 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`.

  A file read this way has its paths reported as written and **not**
  checked against the filesystem unless you ask with `--resolve`
  (`resolveScanned` on the MCP audit tool). A raw-text scan is generous
  by construction, and resolving what it finds would turn a false
  positive into a `missing` finding — a claim — rather than a quiet
  extra row. `--no-resolve` still wins over both.

  Two shapes are claimed 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.
  `fixtures/documents/paths.py` pins exactly what it claims and what it
  does not.

- **YAML is read by a real parser.** Every CI config, Kubernetes
  manifest and compose file in a repository was invisible to this tool
  and is now ordinary. Keys count as well as values, an alias resolves
  to its anchor, and a multi-document file is read through. `saphyr`
  reads it here where `js-yaml` reads it in the extension; positions
  come from a forward-moving text search rather than from either parser,
  which is the same design TOML's positions use and the reason the two
  frontends can agree at all.

- **Markdown is pinned by the shared corpus**
  (`fixtures/documents/paths.md`). It was advertised in the
  `extract_paths` schema with nothing holding the two servers to reading
  it the same way. Both run the case now.

### Changed

- **`--format` accepts any name.** One this engine does not recognise
  reads the document with the generic scan instead of refusing it. The
  report's `format` field names what was actually used, which is where a
  typo shows up.

- **`extract_paths` answers a call with neither `format` nor
  `filename`** instead of refusing it, on both servers. It scans the
  content and reports `fileType: "unknown"`.

- **A binary file gets no report line at all.** A NUL byte in the first
  8KB — ripgrep's own test — means the file was never a text candidate.
  Reporting each one as skipped made `--strict` exit 2 on every
  repository holding an image, which is every repository. They are
  counted instead: the summary ends `, 16 binary files skipped`, and the
  MCP audit carries a `binary` diagnostic. A file that looked like text
  and could not be read as it keeps its named `skipped` diagnostic and
  still fails `--strict`.

- **The unsupported-format diagnostic is gone**, having existed for the
  one message the scan replaced.

### Fixed

- **Paths were reported with backslashes on Windows.** `file`,
  `resolution.canonical` and `resolution.symlink` all came back spelled
  `\`, with a `\\?\` prefix on anything resolved — so the same tree
  audited on two machines produced two reports that could not be
  diffed, and neither matched the `/` the source files were written
  with. Every path in a report now spells its separators forward, on
  every platform.

- **Every absolute path was called `non-canonical` on Windows.** The
  separator rule fired on any backslash rather than on a genuine mix of
  both, so the platform's own canonical form was reported as deviating
  from canonical form — and an ordinary `src\lib\a.ts` with it.

- **A file your editor saved with a byte-order mark read differently
  here than in the editor.** Three invisible bytes, added by Notepad,
  Excel and a PowerShell redirect and stripped by VS Code before the
  extension ever sees a file. They shifted every column on line one, and
  in front of a `{` they made the parser reject the whole document —
  which is indistinguishable from a file with no paths in it.

- **A CSV cell could lose its first character.** A cell led by U+0085
  came back as `/a.txt` here and `\u{85}/a.txt` from the npm server,
  classified `absolute` against `file`, because the reader trimmed with
  Rust's idea of whitespace rather than JavaScript's. The two languages
  disagree about exactly two characters and both of them are reachable.

- **A format name with an invisible character around it resolved two
  ways.** `\u{feff}json` was read as JSON by the npm server and fell
  through to the generic scan here, so the same argument produced
  different answers depending on which server an agent reached.

- **Five findings on a tree of compose files, all five wrong.** A
  colon-joined composite — a volume mount
  (`/etc/localtime:/etc/localtime:ro`), a `PATH` entry, an `scp` target,
  a `file:line` reference — starts with `/` and so had evidence enough
  for a `missing` verdict about a string that was never one path. It
  still resolves to `ok` when something by that whole name is really
  there; only the unprovable negative is withheld.

- **A run failed because the repository contained a PNG.** Every
  repository has one, plus a zip and something the runner cannot read,
  and exiting 2 on those made the tool unusable in CI — the one place it
  is worth the most. Such a file is named on stderr and carried in the
  report with a `skipped` diagnostic, and the exit code reflects what
  was found. `--strict` restores zero tolerance for a pipeline that
  wants it. An audit that gives up part way through a file still fails
  without being asked.

- **A file that is text but undecodable used to vanish from the
  report** — which reads, to whoever ran it, as a file that was clean.
  It is named instead.

## [0.1.0] - 2026-08-08

First release. The extension's extraction engine, ported and pinned
against a shared corpus, plus the half an editor cannot do.

### Added

- **Extraction for all eight formats** the extension supports — JSON and
  JSONC, TOML, CSV, dotenv, JavaScript, TypeScript, HTML and
  CSS/SCSS/LESS — reproducing the extension's output for every case in
  `fixtures/`. Positions are 1-based, and columns count UTF-16 code
  units so they match what an editor reports.
- **Resolution**, which has no extension equivalent: each path is
  checked against the filesystem and gets one verdict — `ok`,
  `symlinked`, `non-canonical`, `missing`, `escapes-root` or
  `unresolved`. A relative path resolves against the directory of the
  file it was found in. An import written without an extension is probed
  against a fixed list (`.ts .tsx .js .jsx .mjs .cjs .json`) and the
  substitution is reported. A `missing` verdict requires the value to
  commit to being a path — explicit `./` syntax, or a file extension
  after a separator — so a MIME type, a package specifier or a version
  range comes back `unresolved` instead. Absence is only reported where
  absence is evidence.
- **A tree walker** using ripgrep's `ignore`, so a directory is walked
  the way `rg` walks one. A file named explicitly is read whatever the
  ignore rules say.
- **The CLI**: JSON reports on stdout one per line, a human summary on
  stderr, and exit codes as the API — 0 clear, 1 findings, 2 the
  question was malformed. `--strict`, `--no-resolve`, `--root`,
  `--format`, `--stdin`, `--follow-symlinks`, `--hidden`, `--no-ignore`.
- **The MCP server** (`paths-le mcp`) with two tools: `extract_paths`,
  shared byte-for-byte with the npm server and pinned by
  `fixtures/mcp-extract-paths.json`, and `paths_le_audit`, which returns
  the same reports the CLI writes.
- **The shared corpus** at `fixtures/`, read by both frontends.
  `../scripts/check-extraction-parity.ts` runs it against the extension;
  `src/extract/corpus.rs` runs it against this crate.

### Notes on parity

The extension is the reference implementation for extraction, and
several of its behaviours are ported as they stand rather than fixed —
the double-emission of a path-like dotenv key, TOML positions from a
text search, a bare domain classified as a file, and a `data:` URI
inside `srcset` splitting on its own base64 commas. Each is listed in
[SPEC.md](SPEC.md) and pinned in `fixtures/` on both sides, because
fixing one on one side only is how two frontends stop agreeing.

[0.2.2]: https://crates.io/crates/paths-le/0.2.2
[0.2.1]: https://crates.io/crates/paths-le/0.2.1
[0.2.0]: https://crates.io/crates/paths-le/0.2.0
[0.1.0]: https://crates.io/crates/paths-le/0.1.0