Skip to content

Fold the two NormalizePropertyName helpers, and stop decoration-only keys being silently renamed - #124

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/fold-property-name-normalizer
Sep 24, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/fold-property-name-normalizer

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Picks up the last outstanding item noted on #113 — "the two near-duplicate NormalizePropertyName helpers are untouched" — and, in doing it, turns up a data-loss bug that is already reachable on main.

Refs #113. This does not close it: the PropertyMerger fuzzy-ranking half is still open and still waiting on the judgement call recorded there. Only the helper fold is done here.

The fold

NameStandardizer and PropertyMerger each carried their own NormalizePropertyName, and the copies had drifted. The standardizer's:

  • lower-cased without trimming
  • compared its prefix and suffix culture-sensitively (StartsWith(string) / EndsWith(string))
  • replaced only the space character, so tabs and newlines survived

Measured over a 30-key corpus, the two disagreed on 15. A sample:

key NameStandardizer PropertyMerger
\ttitle \ttitle title
" meta_description " meta_description description
" user_name_value " user_name_value name
" post_headline " post_headline headline
" page_ " page (empty)

Both now call one PropertyNameNormalizer, which takes the merger's behaviour on every point of difference — it is the more robust of the two: Trim() covers all whitespace rather than the space alone, ordinal comparison is correct for fixed ASCII affix tokens, and Split/Join collapses separator runs in one pass.

The bug the fold surfaced

A key made only of decoration — page_, _value, _, page__value, meta_ — normalizes to the empty string. Every string contains the empty string, so the containment tests in both classes admit such a key against the entire candidate set, and it is then renamed to, or merged into, whichever candidate ranks first.

Verified on main, before any change here:

NameStandardizer.StandardizePropertyNames({"page_": v})   ->  {"title": v}
NameStandardizer.StandardizePropertyNames({"_value": v})  ->  {"title": v}
PropertyMerger.MergeSimilarProperties({"page_", "description"}, Maximum)
                                                          ->  merged into "description"

The value is silently attributed to an unrelated property — the one thing a round-tripping frontmatter library must not do.

Padding was masking part of it by accident: " page_ " survived only because the standardizer's missing trim blocked the prefix strip, while bare "page_" did not. Folding the helpers without fixing this would have made the failure uniform rather than removing it, so both call sites now treat an empty normalized form as "nothing to match on" and leave the key alone.

Tests

Three regression tests, each of which fails against the previous implementation (verified by reverting the two source files and re-running — 3 failed, 134 passed):

  • StandardizePropertyNames_PreservesDecorationOnlyKeys
  • MergeSimilarProperties_DoesNotMergeDecorationOnlyKeyIntoAnother
  • StandardizePropertyNames_TreatsPaddedAndUnpaddedKeysAlike

Plus five characterization tests pinning the shared normalizer's semantics.

The padded/bare test asserts over the decision rather than the literal spelling, because an unmatched key is preserved verbatim — padding included — which is correct for a round-tripping library.

Full suite: 137 passed, 0 failed (129 pre-existing, unchanged).

Scope notes

  • No public API change; both classes and the new one are internal.
  • One pre-existing looseness is deliberately not touched: a key normalizing to a single character still containment-matches any standard property containing it, so meta_x_field → x → next. That is main's behaviour for the unpadded form already; this PR only makes the padded form agree with it. Narrowing that heuristic is a separate behaviour judgement.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UJa1Uvm6PDR74cLBoE4PMY


Generated by Claude Code

…n-only keys being renamed

NameStandardizer and PropertyMerger each carried their own copy of
NormalizePropertyName, and the copies had drifted. The standardizer
lower-cased without trimming, compared its prefix and suffix
culture-sensitively, and replaced only the space character, so a key
carrying leading or trailing whitespace - a tab especially - kept the
padding and missed the affix strip entirely. Measured over a corpus of
30 keys, the two implementations disagreed on 15.

Both now call a single PropertyNameNormalizer, which takes the merger's
behaviour: trim first so a padded key normalizes like its bare form,
ordinal affix comparisons, and one separator collapse.

Folding them surfaced a latent bug that was already reachable on main
without any padding. A key made only of decoration - "page_", "_value",
"_", "page__value" - normalizes to the empty string, and every string
contains the empty string, so the containment tests in both classes
admitted it against the whole candidate set. The standardizer renamed
such a key to "title" and the merger folded it into an unrelated
property, losing the value either way, which is the one thing a
round-tripping frontmatter library must not do. Both call sites now
treat an empty normalized form as "nothing to match on" and leave the
key alone.

Padding previously masked some of this by accident: " page_ " survived
only because the missing trim blocked the prefix strip, while "page_"
did not. That inconsistency is gone.

Three regression tests cover the two guards and the padded/bare
invariant; all three fail against the previous implementation. The
existing 129 tests are unchanged and still pass.

Refs #113

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJa1Uvm6PDR74cLBoE4PMY
All five land in code this PR introduces.

csharpsquid:S3267 x2 on the affix loops. Sonar suggests Where, but the
loop body reassigns the captured key between the prefix and suffix
steps, so a deferred LINQ filter over it would be a hazard rather than a
simplification. Array.Find says what the loop actually does - take the
first match, then act - and removes the finding on its own terms.

MSTEST0068 x2: CollectionAssert.AreEqual -> Assert.AreSequenceEqual.
MSTEST0037: Assert.AreEqual(1, x.Count) -> Assert.HasCount(1, x).

No behaviour change; Array.Find returns the first match exactly as the
foreach/break did. Suite still 137 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJa1Uvm6PDR74cLBoE4PMY
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants