Skip to content

Skip a companion candidate that is not a ranges document - #46

Merged
imnasnainaec merged 8 commits into
mainfrom
skip-unreadable-companions
Sep 22, 2026
Merged

imnasnainaec merged 8 commits into
mainfrom
skip-unreadable-companions

Conversation

@imnasnainaec

@imnasnainaec imnasnainaec commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #38.

What changed

  • Lexicon.load() no longer raises from companion discovery.
    • A candidate that exists but cannot be read as <lift-ranges> is skipped, not fatal — extending to every non-companion the treatment Resolve companion ranges across filename case differences #19 gave the .lift itself.
    • Catches LiftError and OSError: a file that cannot be read at all failed the same way.
    • RangesFile.load() is unchanged and still raises.
  • New unreadable-ranges-file warning, addressed to the offending file and naming how the document reaches it.
    • sibling → the conventional companion beside 'Dict.lift' could not be read as a ranges document…
    • header href → this file, named by header range 'etymology' href 'pictures.png', could not be read…
    • Which files are reported, and the route named for each, are re-derived from the header and the folder as they stand at validation, like every other companion-folder check. Only the parser's reason comes from the load — as a tracked companion's content does, which the schema layer reads from memory rather than disk.
    • Candidates are matched to rejections by file identity rather than by spelling, so one file reached under two spellings is parsed, keyed and reported once.
  • Companion-folder findings require companion discovery to have run.
    • ambiguous-ranges-file, dangling-ranges-href and unreadable-ranges-file are silent under resolve_ranges=False.
    • missing-media is unaffected — media is never resolved into the model, so nothing was opted out of.

Before / after

Zero-byte Dict.lift-ranges beside an intact Dict.lift:

before   error: ...\pkg\Dict.lift-ranges: not well-formed XML: Document is empty, line 1, column 1
         exit 2, no Problem stream — the tool that exists to report a broken export cannot reach the report

after    warning [unreadable-ranges-file] Dict.lift-ranges: the conventional companion beside
           'Dict.lift' could not be read as a ranges document, so it is not loaded:
           not well-formed XML: Document is empty, line 1, column 1
         0 error(s), 1 warning(s)
         exit 0  (exit 1 under --strict)

Decisions worth a second opinion

  • Severity is warning, matching both neighbours.
    • ambiguous-ranges-file has the identical consequence: the file goes untracked, so it is absent from a relocating save(path) and carried through verbatim by save_zip. An in-place save() never touches it.
  • The gate is by subject — a finding about a companion is unwanted when companions are out of scope, accurate or not.
    • The alternative is to gate by soundness: gate a check iff it reads lexicon.ranges_files. That is ambiguous-ranges-file and dangling-ranges-href, whose suppression guards cannot fire on an empty dict and so report cases a resolving load would have withheld; unreadable-ranges-file and missing-media read nothing from the model and would stay ungated.
    • Divergence is one cell: under resolve_ranges=False the alternative still reports unreadable-ranges-file. It would also land as Fixed rather than Changed.
  • The issue's second fix option is not implemented. Failing only for an href the document asserts, while skipping the sibling and basename guesses, would leave a zero-byte sidecar fatal via a relative href. The asserted/guessed distinction survives in the message text instead.

Notes

  • Lexicon._rejected_ranges is the "record why a candidate was rejected" plumbing Second companion defining the same range id is dropped, causing false undefined-range-value #29 also wants for addressing findings to the right file.
  • Not addressed here: on macOS a companion found under a candidate's spelling is tracked, and now reported, under that spelling rather than its name on disk, because resolve() does not canonicalize case there. Pre-existing — ranges_files keys have always behaved this way — and undocumented.
  • One test flipped (…fails_the_load → …is_skipped), nine added. One of the nine is gated on a filesystem that folds case while resolve() keeps the spelling given, so it runs on the macOS leg only.
  • scripts/check.py green — 664 passed, 4 skipped, 97.90% coverage. mkdocs build --strict green.

🤖 Generated with Claude Code


This change is Reviewable

imnasnainaec and others added 2 commits September 17, 2026 10:27
Companion discovery loaded whatever existed at a candidate path and let
RangesFile.load's refusal propagate, so one unusable file cost the whole
lexicon its entries. A zero-byte or truncated .lift-ranges beside the
.lift - an interrupted export, a failed sync, a partial checkout - was
enough, with no unusual href involved.

_resolve_ranges now catches LiftError and OSError around the companion
load and records the rejection instead, extending to every non-companion
the treatment the .lift itself already got: skipped, not fatal. The
record is a private dict keyed by resolved path, left None until
discovery runs so "nothing was rejected" stays distinguishable from
"nothing was looked at".

Validation reports each rejection as a new unreadable-ranges-file
warning, addressed to the offending file and naming the route that
reached it. _ranges_candidates now yields that route alongside each path,
deduping on the path so the surviving source is the one load used; a
broken sidecar is then fixed in the file, while an href pointing at an
unrelated file is fixed in the header, and an intact file a stray href
happens to name is never reported as though it were the defect.

RangesFile.load is unchanged and still raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three companion-folder checks derive from the .lift's path and a
directory listing, never from what the load accepted, so they reported
on companions even for a lexicon loaded with resolve_ranges=False. That
is the caller saying companions are out of scope for this load, which
makes a finding about one unwanted whether or not it is accurate.

Each of the two older checks also holds a suppression guard that reads
lexicon.ranges_files - a collision where one of the colliding files
loaded under an exact name, an href whose range a loaded sibling
supplies anyway - and neither guard can fire on an empty dict, so both
reported cases a resolving load would have withheld.

_rejected_ranges already distinguishes "discovery ran" from "discovery
never ran", so it gates the whole block; no second flag is needed.
missing-media stays ungated: media is never resolved into the model, so
there is nothing for a caller to have opted out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec self-assigned this Sep 17, 2026
Path.resolve() canonicalizes case on Windows but not on macOS, so a
companion found under a candidate's spelling is recorded under that
spelling rather than the name it has on disk. Two tests compared the
reported path to the on-disk spelling with ==, which held only where
resolve() folds case.

samefile() settles it the way the rest of the companion code already
does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec imnasnainaec added the 🟨Medium Medium-priority PR label Sep 17, 2026
imnasnainaec and others added 4 commits September 17, 2026 11:49
…tand

The rejection record was replayed verbatim by validation, while every
other companion-folder check re-derives from the current header and a
live directory listing. The two disagreed as soon as either input moved:
deleting a rejected file drew both dangling-ranges-href and
unreadable-ranges-file, and repointing a header href left the warning
quoting an href the document no longer carried.

Validation now walks the current candidates and looks each resolved path
up in the record, so a file that is gone, or that nothing names any more,
drops out, and the route named is the one the header describes. Only the
parser's reason stays from the load - as the content of a tracked
companion does, which the schema layer reads from memory rather than
from disk. The record has nothing left to carry but that reason, so
_Rejection goes and the dict holds plain strings.

Rejections also join the identity test that already guarded
ranges_files. Nothing skipped a candidate resolving to an
already-rejected file, so a second spelling of one file - a .. segment,
a symlink, a case variant - parsed it again and overwrote its entry with
the later route; on macOS, where resolve() leaves those spellings
distinct, it also keyed and reported the same file twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_resolve_ranges keys a rejection under the spelling resolve() returned
for the candidate that reached it, and guards that dict with an exact
test and a _same_file test, since resolve() canonicalizes case on
Windows but not on macOS. Validation looked the same dict up by == only,
so on macOS a candidate reaching a rejected file under a second spelling
found nothing and the warning went silent - a case-only edit to a header
href being one way in.

The lookup now falls back to _same_file, and dedups on the key that
matched rather than on the candidate's spelling, so two spellings of one
rejected file still report once.

The test needs a filesystem that folds case while resolve() keeps the
spelling given, which is macOS and not Windows or Linux. It probes for
that behavior rather than for a platform name: a macOS volume formatted
case-sensitive does not have it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The problem-code table and the layer list in the validation guide gave
different counts - eleven against ten - where the table was right. A
twelfth code lands in both, so they now agree, matching the table's
fourteen rows less the two the schema layers produce. The changelog
counted eleven with the table and follows.

0.1.0 has not shipped, so nothing else here is a change to record: a
companion candidate that cannot be read is part of what discovery does
in the first release, described where it is looked up rather than as a
delta against behavior no reader has seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unreadable-companion paragraph in the folder guide ran to three
sentences where the paragraph above it states the same kind of rule in
one, and wedged a three-item aside between its subject and verb. It
keeps the example readers actually hit, drops the two that were there
for completeness, and loses the "still raises" framing, which implies a
release that has not happened.

The companion-folder gate in the validation guide named a lexicon as
what put companions out of scope, where the caller is what did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec marked this pull request as ready for review September 17, 2026 18:46
Comment thread src/sil_lift/_validate.py
add_ranges_file attaches a companion and writes its header range/@href
without reading the folder, so _rejected_ranges stays None and the three
companion-folder codes stay silent after a resolve_ranges=False load.
That is what those checks need: they re-derive from the folder as it
stands, and on what such a load holds, a header range supplied by a
sibling companion that went unloaded reads as a dangling href. The guide
and the docstrings stated the rule as companion discovery having run,
which reads as though putting a companion back in scope brings them
back.

The validation guide, Lexicon.iter_problems and add_ranges_file now say
it does not, the gate carries the misfire it avoids, and a test pins the
silence.

The four normalization fixtures and the probe name beside them are
spelled with \N{...} escapes. They differ only in which accent is
decomposed, so an editor or tool that normalizes the source would
collapse the distinction they exist to hold - silently, since a probe
that no longer decomposes reports the filesystem as insensitive and
skips its tests rather than failing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@imnasnainaec
imnasnainaec merged commit 7b1dd32 into main Sep 22, 2026
12 of 13 checks passed
@imnasnainaec
imnasnainaec deleted the skip-unreadable-companions branch September 22, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🟨Medium Medium-priority PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A companion candidate that is not a ranges document fails the whole load, so a zero-byte .lift-ranges makes the lexicon unloadable

2 participants