Skip to content

fix: parse YAML numeric literals invariantly [patch] - #74

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-pi04bp
Sep 15, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-pi04bp

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #73

What was wrong

YamlSerializer writes numeric literals through YamlDotNet's scalar emitter, which is always invariant and dot-decimal (3.14). YamlDeserializer read them back with the TryParse overloads that take no culture, so they parsed against Thread.CurrentThread.CurrentCulture.

double.TryParse(string, out double) defaults to NumberStyles.Float | AllowThousands. On a culture where . is the group separator and , the decimal separator — de-DE, fr-FR, es-ES, it-IT, ru-RU, pl-PL, nl-NL and the rest of continental Europe — "3.14" parses as 314. Not an error, not a dropped node: a silently wrong value in the loaded AST, so round-tripping a .coder.yaml file changed the behaviour of the program it described.

The change

NumberStyles + CultureInfo.InvariantCulture at the two numeric parse sites in YamlDeserializer.cs:

  • DeserializeLeafNodeInt32
  • DeserializeLiteralExpressionInt32 and Double

This matches the pattern the rest of the codebase already uses for source values (LanguageGeneratorBase.cs:125,133, CSharpGenerator.cs:100,106, AstFields.AssignInteger).

bool.TryParse is deliberately left alone. The issue suggested passing a culture there too, but bool.TryParse accepts only "true"/"false" (case- and whitespace-insensitive) and has no culture-sensitive overload — it is already invariant by construction. The remaining TryParse calls in the file are Enum.TryParse, which is likewise culture-independent.

Test

New Coder.Test/Serialization/CultureInvariantDeserializationTests.cs, covering the fraction round trip the issue's acceptance criteria asks for, plus negative fractions, a fraction nested in a function body, and integer/boolean contract guards.

Two notes on how it is built:

  • The culture is constructed, not named. The test project runs in globalization-invariant mode, where new CultureInfo("de-DE") throws CultureNotFoundException. The test instead clones InvariantCulture and sets NumberDecimalSeparator = "," / NumberGroupSeparator = ".", which is the exact pairing that causes the corruption. That also keeps the test hermetic — it pins a fixed number format rather than whatever ICU data the host carries.
  • The culture never touches the test host's threads. The round trip runs on a LongRunning task, so the mutated culture dies with its dedicated thread instead of being inherited by a pool thread later.

Verification

Reverting YamlDeserializer.cs while keeping the new test file:

failed DeserializeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue
  Assert.AreEqual(3.14, roundTripped.Value)
failed DeserializeNegativeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue
  Assert.AreEqual(-0.125, roundTripped.Value)
failed DeserializeFunctionBody_UnderDotGroupingCulture_PreservesReturnedFraction
  Assert.AreEqual(0.5, value.Value)
  total: 6, failed: 3, succeeded: 3

With the fix restored: 6/6 pass, and the full suite is 867/867 green. dotnet build Coder.sln succeeds with no warnings across both target frameworks.

To be straight about coverage: only the three double cases demonstrably regress today. The Int32 and Boolean tests pass either way — int.TryParse is not currently broken by any culture .NET ships, since it special-cases the ASCII hyphen even where the culture's negative sign differs. They are kept as contract guards for the invariant round trip, not presented as reproductions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B1XQbfXNGfPRfGr4bRjsT3


Generated by Claude Code

YamlDeserializer parsed Int32 and Double literals with the TryParse
overloads that read against Thread.CurrentThread.CurrentCulture, while
YamlSerializer writes them through YamlDotNet's scalar emitter, which is
always dot-decimal. On a culture that groups with "." and separates
decimals with "," — de-DE, fr-FR, es-ES, it-IT, ru-RU, pl-PL, nl-NL and
the rest of continental Europe — double.TryParse's default NumberStyles
of Float | AllowThousands read "3.14" as 314. No error was raised: the
literal was silently corrupted, and round-tripping a .coder.yaml file
changed the behaviour of the program it described.

Pass NumberStyles and CultureInfo.InvariantCulture at both sites, matching
the pattern the generators and the inspector already use
(LanguageGeneratorBase, CSharpGenerator, AstFields.AssignInteger).

bool.TryParse is left alone: it accepts only "true"/"false" and has no
culture-sensitive overload.

Fixes #73

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1XQbfXNGfPRfGr4bRjsT3
MSTEST0046, flagged by SonarCloud on the new test file. StringAssert is
superseded in MSTest 4; Assert.Contains takes the substring first.

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 782710c into main Sep 15, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-pi04bp branch September 15, 2026 22:35
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.

Fraction/int/bool literals are silently corrupted or dropped when deserialized under a non-invariant current culture

2 participants