LT-22697: Normalize the range-element ids written to LIFT ranges - #1063
Open
imnasnainaec wants to merge 2 commits into
Open
LT-22697: Normalize the range-element ids written to LIFT ranges#1063imnasnainaec wants to merge 2 commits into
imnasnainaec wants to merge 2 commits into
Conversation
FieldWorks holds strings as NFD and normalizes them to NFC when it exports LIFT, but several writes in the ranges file bypass the normalizing helper and emit the NFD unchanged. The part-of-speech and lexical-relation range-element ids go out through XmlUtils.MakeSafeXmlAttribute while the parent attribute of the same element, two lines away, goes through MakeSafeAndNormalizedAttribute. One name then appears in two encodings inside a single element, and a consumer that resolves a range value by string equality - a LIFT importer, a Send/Receive merge - fails to match it. Exports carrying it have been seen from 8.3.12 and 9.1.15. Route those writes through MakeSafeAndNormalizedAttribute: the part-of-speech and lexical-relation ids, the morph-type id, and the leading-symbol, trailing-symbol, and stem-name feature-set traits. The morph-type id was written with no XML escaping either, so a morph type whose name contains an ampersand or an angle bracket produced a file no conforming parser can read; the same helper escapes it. The two writes left unnormalized in the ranges output are writing system tags, where normalizing is a no-op. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1063 +/- ##
==========================================
- Coverage 38.05% 38.04% -0.01%
==========================================
Files 1499 1499
Lines 350117 350117
Branches 40233 40233
==========================================
- Hits 133225 133218 -7
- Misses 187607 187614 +7
Partials 29285 29285
🚀 New features to boost your workflow:
|
Two cases the ranges export got wrong, neither covered before. A part of speech whose name carries a combining diacritic: the range-element id has to agree with the parent attribute naming it and with its own label, all three being the same name. A morph type whose name holds an ampersand: the ranges document has to parse at all, and the id has to read back as the name stored. Each covers one of the writes the previous commit routed through MakeSafeAndNormalizedAttribute. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 13, 2026
imnasnainaec
marked this pull request as ready for review
August 14, 2026 14:21
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes LT-22697.
Downstream analysis, byte-level evidence, and the full list of affected writes: python-sil-lift#27.
Companion to #1064. The two diffs do not overlap.
Quick Summary
FieldWorks holds strings as NFD and normalizes to NFC on LIFT export, but several writes in
the
.lift-rangesoutput bypassMakeSafeAndNormalizedAttributeand emit the NFD unchanged.Two defects fall out of that:
WritePartOfSpeechRangeElementthe range-element
@idis written withXmlUtils.MakeSafeXmlAttributewhile the@parentof that same element, two lines below, is normalized. A consumer that resolves a range
value by raw string comparison then fails to match: the
.lift's NFC<grammatical-info value="…"/>reads as undefined against the companion's NFD@id, and a@parentnaming an NFD sibling id looks dangling.WriteMorphTypeRangewrote the namestraight into the attribute, so a morph type renamed to something containing
&or<produces a
.lift-rangesno conforming parser can read.Routing the affected writes through the existing normalizing helper fixes both — the same
helper escapes as well as normalizes.
Observed in real exports from
SIL.FLEx 8.3.12.43172andSIL.FLEx 9.1.15.658. In the9.1.15 file, 12 range-element ids (7 distinct) are NFD while every label, abbrev,
description,
@parent, and.liftvalue naming them is NFC.The change
Six writes swap
XmlUtils.MakeSafeXmlAttribute— or, for the morph-type id, nothing at all —for
MakeSafeAndNormalizedAttribute:WritePartOfSpeechRangeElement— the range-element@id, in both branchesWriteLexRefType— the lexical-relation range-element@id, in both branchesWriteMorphTypeRange— the morph-type range-element@idWriteMorphTypeRange— theleading-symbolandtrailing-symboltraitsWriteStemNameRanges— the stem-namefeature-settraitScope
Deliberately left alone. Two writes in the ranges output are writing-system tags, where
normalizing is a no-op: the reversal-type id, and the feature
writing-systemtrait.The three trait hunks are not equal. Two are cosmetic, one is not:
leading-symbolandtrailing-symbolare genuine no-ops. For ordinary affix markers(
-,=,~) NFC changes nothing, and even a combining-mark marker is unchanged, since amark with no base has nothing to compose with. They are in the diff purely for consistency
and can be dropped if a tighter diff is preferred.
feature-setis a real change. Its value isregion.LiftName, a composedfeature-structure name built from user-defined feature and value abbreviations, which can
carry diacritics.
.liftside already normalizesLiftName.<trait>onname+value, so a respelled value is a delete-plus-addthere too.
not help it. The damage is smaller because a trait is a leaf that both sides regenerate.
Not addressed here. The possibility maps are NFC by convention, but nothing enforces it:
TryGetPossibilityMatchingTraitlooks up with the raw value.FindOrCreatePartOfSpeechinserts withdict.Add(val, pos).ProcessXrange-element handlers insert the LIFT id verbatim.For FLEx-produced files this is invisible, since both sides are NFC; a LIFT file written in
NFD by another exporter can create duplicate list items. An equality comparer on those
dictionaries would enforce the documented convention in one place. Separate defect, separate
review — glad to open it if that is wanted.
Effects worth knowing
FLEx's own importer needs no change; the ids move toward the convention it already uses.
AddToPossibilityMapkeys every possibility map withtss.Text.Normalize(), i.e. NFC, andthe map declarations in
LiftMergerRanges.csstate that outright.ProcessPartOfSpeechseeded an NFD key from the ranges@idalongside those NFC keys; nowthe id agrees with them.
import-side change is needed for this one.
LIFT Send/Receive sees a delete plus an add, not an attribute edit.
.lift-rangesisunder S/R and gets a 3-way XML merge. Chorus matches
range-elementacross revisions by its@id, not its@guid(
LiftRangesElementStrategiesMethod.cs),comparing ordinally, and normalizes nothing anywhere. Three cases follow:
and the new one lands as an unmatched addition, with no conflict.
range-elementisregistered order-irrelevant, so no reordering conflict either, and
LiftSortersortsrange-elements under
StringComparer.InvariantCultureIgnoreCase, which iscanonical-equivalence-aware, so the element does not move in the file. The hg diff really is
one attribute on one line.
raises a
RemovedVsEditedElementConflictand keeps their element while ours is still added,leaving two
<range-element>with the same@guid, ids differing only in thenormalization, plus a conflict note nobody earned. This degrades rather than corrupts:
FLEx's import is guid-first (
FindExistingPossibility→GetPossibilityForGuidIfExisting), so both resolve to the same POS and the second is ano-op, and the next export regenerates the file from the model.
export; it is not stored. A client that has not upgraded re-exports NFD on every sync, so
between an upgraded and an un-upgraded client this flip-flops per sync — each flip a
delete-plus-add that can land in the case above — until every client on the repo is
upgraded. Same for any non-FLEx writer of the ranges file sharing the repo.
This wants a release note telling teams to upgrade together. The change still converges
and is still the right target, but that third case is not a one-time diff.
The durable fix is in Chorus, and is now proposed.
sillsdev/chorus#394 prefers the
guidformatching
range-element, with id fallback, which makes ranges merges immune to any idrespelling — normalization, rename, recapitalization — and collapses the duplicated pair in
repos that already have one. This PR does not depend on it, but they are better together.
A third-party tool that stored the old NFD spelling as its own key will see the new
spelling as a new value once. NFC is the right target — it is what the rest of the export
already uses — so this is a one-time correction, not an ongoing incompatibility.
A residual hazard, for the record
Chorus's
RemoveAmbiguousChildrendrops all but the first sibling its finder cannot tellapart, emitting only a merge warning. NFC is injective over NFD-normalized input and FLEx
stores NFD, so this should not newly collide. But a project holding two possibilities whose
names differ only by normalization — plausible by way of a non-FLEx LIFT import — would after
this change collapse to one id, and one element would be dropped from the merge output. Not a
reason to hold this PR; worth knowing that is the failure mode.
Testing
Two regression tests in
LiftExportTests.cs, each covering one of the two defects. Neitherexisted before.
LiftExportRanges_PartOfSpeechIdIsNormalizedLikeItsParentAndLabel— a part of speech whosename carries a combining diacritic, asserting the range-element
@id, the@parentnamingit from a child, and its own
<label>are one and the same string.LiftExportRanges_MorphTypeIdWithMarkupCharacterIsEscaped— a morph type whose name holdsan
&, asserting the ranges document parses and the id reads back as the name stored.Not run. This was prepared without a local FieldWorks build environment, so it needs
.\build.ps1/.\test.ps1(or this PR's CI) before it leaves draft. Please treat both theexporter edits and the new tests as unverified.
Third-party confirmation. The
sil-liftPython library validates real FLEx exports; ithad to normalize both sides of every range comparison to stop reporting sound exports as
broken. Its issue thread, linked at the top, has the byte-level evidence, the corpus counts,
and the full list of bypassed writes with line numbers.
CI-ready checklist
.github/commit-guidelines.md(subject ≤ 72 chars, no trailing punctuation; if body present, blank line then ≤ 80-char lines).git log --checkandgit diff --checkare clean).Docs/workflows/ai-pr-workflow.md— not applicable; this is an outside contribution rather than core-developer work. AI assistance is disclosed in the commit trailers.AGENTS.mdunderSrc/LexText/describes the exporter's normalization, so none needed updating.This change is