Skip to content

vault-lint.py cannot see into a non-UTF-8 note, and reports it as clean #12

Description

@djdarcy

vault-lint.py cannot see into a non-UTF-8 note, and reports it as clean

Problem

vault-lint.py reads every note as UTF-8 with errors="replace", in four places:

Line Call What it reads
115 p.read_text(encoding="utf-8", errors="replace") every note, for link checking
217 src.open("r", encoding="utf-8", errors="replace", newline="") the --fix read
276 moc.read_text(encoding="utf-8", errors="replace") MOC freshness
288 manifest.read_text(encoding="utf-8", errors="replace") manifest coverage

This is the same defect generate-backlinks.py carried until v0.2.10, and errors="replace" fails the same way errors="ignore" did. A UTF-16 document decoded as UTF-8 becomes NUL-interleaved text — #\x00 \x00T\x00i… — because NUL is a valid UTF-8 byte and is never replaced. Every [[wikilink]] in the file therefore fails to match.

The consequence is not a crash. It is a clean bill of health:

$ python vault-lint.py <vault-with-a-utf16-note> --check
vault-lint: 0 broken links, 0 orphans outside expected dirs — OK

The note in question contains a broken link. The linter reports zero because it cannot see any links at all. A tool whose entire job is to find broken links silently reports success on the one class of file where it is blind — and there is nothing in the output to suggest anything was skipped.

This matters more now than it did last week. As of v0.2.10 its sibling generate-backlinks.py does detect and report these files by name. A user who runs both tools gets a warning from one and silence from the other about the same file, which reads as a contradiction rather than a gap.

The latent half

Line 238 writes the --fix result back with encoding="utf-8" unconditionally. If a UTF-16 file ever reached that write, its NUL-interleaved decode would be written out as single-byte UTF-8 and the document would be destroyed.

This is currently unreachable. --fix only rewrites a note where it found a mechanically fixable broken link, and such a note parses to zero links — so the same bug that hides the file also keeps --fix away from it. The hazard is real but latent, and it is worth noting that fixing the read without fixing the write would make it reachable for the first time. Both belong in the same change.

Proposed solution

Reuse read_note_text() from generate-backlinks.py. vault-lint.py already loads that module — _load_backlinks_module() at line 47 — precisely so there is "one parsing truth"; today it only takes FENCE_RE off it. Taking the reader as well is the same decision applied one layer down.

_GB = _load_backlinks_module()

FENCE_RE = _GB.FENCE_RE
read_note_text = _GB.read_note_text     # one reading truth, as well as one parsing truth

Then replace the four read sites. The --fix path needs more than a substitution, because it must also write back in the encoding it read rather than silently converting — read_note_text would need to report the encoding it chose, or the fix path needs to refuse non-UTF-8 files outright.

Refusing is probably right, and cheaper: the vault spec requires UTF-8, generate-backlinks.py now tells the user which files violate it, and --fix already refuses everything it cannot resolve unambiguously. "I will not rewrite a file whose encoding I would have to change" fits that existing posture exactly.

Design considerations

  • --fix must keep preserving line endings. v0.2.9 fixed a silent LF→CRLF conversion on Windows by using newline="" on both read and write. Any change to the read path has to keep that guarantee, and a byte-level reader interacts with it — this is the one place where care is needed rather than a mechanical swap.
  • Reporting should not be duplicated. If both tools print their own encoding report, a user running both sees it twice. Better for vault-lint.py to surface the finding in its own report vocabulary (a link class, or a new counter) than to reuse report_encoding_issues() verbatim.
  • --check's exit code is the real deliverable. It gates CI and hooks. A vault containing an unreadable note should probably not exit 0, whatever the link count says.
  • Rejected: making vault-lint.py self-sufficient. Duplicating the decode logic would give the two tools two definitions of "what a note says", which is the exact coupling _load_backlinks_module() was written to prevent.

Acceptance criteria

  • vault-lint.py decodes notes via generate-backlinks.py's read_note_text() rather than assuming UTF-8
  • A broken wikilink inside a UTF-16 note is reported by --check
  • A vault containing a non-UTF-8 note is surfaced in the report rather than passing silently
  • --fix refuses to rewrite a note that is not UTF-8, and says why
  • --fix still preserves line endings exactly on the files it does rewrite (v0.2.9 regression guard)
  • The MOC-freshness and manifest-coverage reads handle a non-UTF-8 file without crashing
  • Tests cover a UTF-16 note with a broken link, and the --fix refusal
  • tests/checklists/v0.2.10__Tool__generate-backlinks-encoding-detection.md Section 5 is updated from "records the gap" to "verifies the fix"

Related issues

Analysis

The defect was found while fixing the same bug in generate-backlinks.py for v0.2.10. Reproduction steps are recorded in tests/checklists/v0.2.10__Tool__generate-backlinks-encoding-detection.md, Section 5, which deliberately documents the wrong behaviour so it can be flipped to a verification when this is fixed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions