Skip to content

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

Description

@imnasnainaec

Lexicon.load() raises LiftParseError and hands back nothing when a companion candidate exists but is not a .lift-ranges document. One unusable file costs the whole lexicon, including its entries — where every other candidate that cannot serve as a companion is skipped, and the reference reported as dangling-ranges-href.

The realistic trigger needs no unusual href at all: a zero-byte or truncated Dict.lift-ranges beside Dict.lift — an interrupted export, a failed sync, a partial checkout — makes the lexicon unloadable.

Reproduction

Sibling only, no href involved:

(folder / "Dict.lift").write_bytes(intact_lift)
(folder / "Dict.lift-ranges").write_bytes(b"")      # or a truncated copy
sil_lift.load(folder / "Dict.lift")
zero-byte sibling    LiftParseError: ...\pkg\Dict.lift-ranges: not well-formed XML: Document is empty, line 1, column 1
truncated sibling    LiftParseError: ...\pkg\Dict.lift-ranges: not well-formed XML: ...
intact sibling       loaded ok, ranges_files=['Dict.lift-ranges']

Any header range/@href resolving to any existing non-ranges file does the same. Exact spellings throughout — no case folding involved:

another .lift                  LiftParseError: ...\pkg\Other.lift: root element is <lift>, expected <lift-ranges>
a text file                    LiftParseError: ...\pkg\notes.txt: not well-formed XML: Start tag expected, ...
a png                          LiftParseError: ...\pkg\pictures.png: not well-formed XML: Start tag expected, ...
well-formed XML, wrong root    LiftParseError: ...\pkg\stuff.xml: root element is <stuff>, expected <lift-ranges>

Mechanism

_resolve_ranges (src/sil_lift/_model.py) loads whatever exists:

found = _existing_file(candidate, listings)
if found is None:
    continue
...
self.ranges_files[resolved] = RangesFile.load(found)

Nothing between the existence test and the load asks whether the file is a ranges document. RangesFile.load decides, and its refusal propagates straight out of Lexicon.load.

One file is already exempt: the lexicon itself. #19 added a _same_file check for it precisely because a case-variant href folding onto the .lift took the whole load down, and settled on skipping the candidate and letting dangling-ranges-href report the reference. That establishes the principle — a candidate that exists but cannot serve as a companion is skipped and reported, not fatal. It just was not extended past the lexicon: a second .lift in the folder, or any other file, still ends the load.

Consequences

  • sil-lift validate cannot diagnose it. The CLI catches LiftError, prints error: ..., and exits 2 with no Problem stream — the tool that exists to report a broken export cannot reach the report. (exit 1 is the documented "errors found" code.)
  • Zip packages inherit it: load_zip extracts and calls Lexicon.load.
  • The only workaround is resolve_ranges=False, which drops every companion, so ranges-dependent checks (undefined-range-value, the ranges schema layer) go silent too.

Fix options

  • Never raise from companion discovery. Skip a candidate that does not parse as a ranges document, and report it. This extends Resolve companion ranges across filename case differences #19's existing treatment of the lexicon to every non-companion file, so the whole class behaves one way.
    • Needs somewhere to put the reason: validation re-derives existence independently via _existing_file, so without a record it would report dangling-ranges-href saying "no companion file was found" when one was found and rejected. Either the load path records rejections for validation to read, or a new code covers "found, but not a ranges document".
  • Distinguish an asserted reference from a guessed one. The conventional sibling and an href basename are discovery heuristics — nothing in the document claims those files exist, so failing over them is disproportionate. A relative href the header writes out is an assertion by the document, where a rejection is meaningful. The code already knows which candidate is which, so this line can be drawn if the wholesale skip looks too permissive.
  • Leave load strict and document it. Smallest change, but keeps a zero-byte sidecar fatal to reading a lexicon's entries, which is hard to defend.

The first looks right, with the second as the fallback if silently skipping a malformed genuine companion is judged to hide too much.

Notes

Pre-existing: before #19, _resolve_ranges went straight from candidate.is_file() to RangesFile.load(candidate), so this failed the same way — the reproductions above use exact spellings and never reach the folded lookup. Split out of review discussion on #19, which kept its scope to companion resolution.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions