Skip to content

Commit c35a146

Browse files
penspanicclaude
andcommitted
Bundle normalize: preserve YAML scalar style + type inference
NormalizeToJson now parses YAML via the YamlStream representation model so each scalar's ScalarStyle is visible. Plain scalars get YAML 1.2 core-schema inference (true/false → bool, integer-looking → long, float-looking → double, "null"/""/"~" → null); single/double-quoted / literal / folded scalars stay JSON strings. Fixes a chain of wasm load failures hit on real Tidemark data: - bool/float/int fields were emitted as JSON strings ("true", "6.1", "1") → STJ couldn't deserialize into bool/float/int properties - After adding inference, quoted strings like `AttachWhenEquals: "0"` got re-typed to JSON number 0 → STJ couldn't deserialize into string Tests: 4 cases in BundleNormalizeToJsonTests covering plain inference, quoted preservation, multi-dot strings (1.2.3 stays a string), and YAML null forms. Full suite 723 / 723. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 65a9b34 commit c35a146

2 files changed

Lines changed: 180 additions & 6 deletions

File tree

Datra.Tests/BundleNormalizeToJsonTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,90 @@ public void NormalizeToJson_ConvertsYamlFiles_AndPreservesJsonFiles()
3131
Assert.True(normalized.Files["Units.yaml"].TrimStart().StartsWith("["),
3232
$"Expected JSON array, got: {normalized.Files["Units.yaml"]}");
3333
Assert.Contains("\"Id\"", normalized.Files["Units.yaml"]);
34+
// Hp should be a JSON number, not a quoted string.
35+
Assert.Contains("\"Hp\":10", normalized.Files["Units.yaml"]);
36+
Assert.DoesNotContain("\"Hp\":\"10\"", normalized.Files["Units.yaml"]);
3437
// Original JSON pass-through verbatim.
3538
Assert.Equal("[{\"Id\":\"x\",\"Price\":1}]", normalized.Files["Item.json"]);
3639
// YAML path got a format override; JSON path did not.
3740
Assert.Equal(DataFormat.Json, normalized.FormatOverrides["Units.yaml"]);
3841
Assert.False(normalized.FormatOverrides.ContainsKey("Item.json"));
3942
}
4043

44+
[Fact]
45+
public void NormalizeToJson_QuotedScalars_StayAsStrings()
46+
{
47+
// Quoted YAML scalars must survive as JSON strings even when their content
48+
// looks numeric / boolean. Regression: TidemarkParticleParams.AttachWhenEquals
49+
// is `string`, and Effects.yaml writes it as `AttachWhenEquals: "0"`.
50+
var bundle = new DatraRawBundle
51+
{
52+
Files =
53+
{
54+
["Sample.yaml"] =
55+
"- Quoted: \"0\"\n" +
56+
" SingleQuoted: '5'\n" +
57+
" QuotedBool: \"true\"\n" +
58+
" Plain: 0\n" +
59+
" PlainBool: true\n",
60+
},
61+
};
62+
63+
var normalized = DatraBundleBuilder.NormalizeToJson(bundle);
64+
var s = normalized.Files["Sample.yaml"];
65+
66+
Assert.Contains("\"Quoted\":\"0\"", s);
67+
Assert.Contains("\"SingleQuoted\":\"5\"", s);
68+
Assert.Contains("\"QuotedBool\":\"true\"", s);
69+
// Plain scalars still infer normally.
70+
Assert.Contains("\"Plain\":0", s);
71+
Assert.Contains("\"PlainBool\":true", s);
72+
}
73+
74+
[Fact]
75+
public void NormalizeToJson_InfersScalarTypes_PerYamlCoreSchema()
76+
{
77+
// Cover: bool / int / float / negative / null forms / string preservation.
78+
var bundle = new DatraRawBundle
79+
{
80+
Files =
81+
{
82+
["Sample.yaml"] =
83+
"Enabled: true\n" +
84+
"Disabled: False\n" +
85+
"Count: 42\n" +
86+
"Negative: -7\n" +
87+
"Ratio: 3.5\n" +
88+
"Tiny: -0.25\n" +
89+
"Sci: 1.5e3\n" +
90+
"Empty: \n" +
91+
"Null1: null\n" +
92+
"Tilde: ~\n" +
93+
"Name: hero_a\n" +
94+
"VersionLike: 1.2.3\n",
95+
},
96+
};
97+
98+
var normalized = DatraBundleBuilder.NormalizeToJson(bundle);
99+
var s = normalized.Files["Sample.yaml"];
100+
101+
Assert.Contains("\"Enabled\":true", s);
102+
Assert.Contains("\"Disabled\":false", s);
103+
Assert.Contains("\"Count\":42", s);
104+
Assert.Contains("\"Negative\":-7", s);
105+
Assert.Contains("\"Ratio\":3.5", s);
106+
Assert.Contains("\"Tiny\":-0.25", s);
107+
Assert.Contains("\"Sci\":1500", s); // 1.5e3 normalized
108+
// YAML empty / null / ~ → JSON null
109+
Assert.Contains("\"Empty\":null", s);
110+
Assert.Contains("\"Null1\":null", s);
111+
Assert.Contains("\"Tilde\":null", s);
112+
// String-typed values stay quoted.
113+
Assert.Contains("\"Name\":\"hero_a\"", s);
114+
// Multi-dot scalar must stay a string (not a float).
115+
Assert.Contains("\"VersionLike\":\"1.2.3\"", s);
116+
}
117+
41118
[Fact]
42119
public async Task BundledRawDataProvider_DispatchesJsonSerializer_ForNormalizedYamlPath()
43120
{

Datra/Bundles/DatraBundleBuilder.cs

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
4+
using System.Globalization;
35
using System.IO;
46
using System.Linq;
57
using System.Security.Cryptography;
68
using System.Text;
79
using System.Text.Json;
10+
using System.Text.Json.Nodes;
811
using Datra.Attributes;
912
using Datra.Utilities;
13+
using YamlDotNet.Core;
14+
using YamlDotNet.RepresentationModel;
1015
using YamlDotNet.Serialization;
1116

1217
namespace Datra.Bundles
@@ -63,7 +68,6 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
6368
if (source == null) throw new ArgumentNullException(nameof(source));
6469
if (source.Files == null) throw new ArgumentException("Source bundle has no Files.", nameof(source));
6570

66-
var yamlDeserializer = new DeserializerBuilder().Build();
6771
var jsonOptions = new JsonSerializerOptions { WriteIndented = false };
6872

6973
var outFiles = new Dictionary<string, string>(source.Files.Count, StringComparer.Ordinal);
@@ -77,12 +81,19 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
7781

7882
if (fmt == DataFormat.Yaml)
7983
{
80-
// YAML → opaque object graph → JSON via STJ. The runtime serializer
81-
// re-parses this content with its proper [TableData] type info,
82-
// so we only need a structurally faithful conversion here.
84+
// YAML → JSON via the YamlStream representation model so we can
85+
// see each scalar's ScalarStyle: plain scalars are type-inferred
86+
// per the YAML 1.2 core schema, but quoted ones (`"0"`, `'true'`)
87+
// stay as JSON strings — matching the author's intent.
8388
using var reader = new StringReader(content);
84-
var graph = yamlDeserializer.Deserialize(reader);
85-
var jsonContent = JsonSerializer.Serialize(graph, jsonOptions);
89+
var yamlStream = new YamlStream();
90+
yamlStream.Load(reader);
91+
JsonNode? node = null;
92+
if (yamlStream.Documents.Count > 0)
93+
{
94+
node = ConvertYamlNodeToJsonNode(yamlStream.Documents[0].RootNode);
95+
}
96+
var jsonContent = node?.ToJsonString(jsonOptions) ?? "null";
8697
outFiles[path] = jsonContent;
8798
overrides[path] = DataFormat.Json;
8899
}
@@ -102,6 +113,92 @@ public static DatraRawBundle NormalizeToJson(DatraRawBundle source)
102113
return bundle;
103114
}
104115

116+
/// <summary>
117+
/// Convert a <see cref="YamlNode"/> tree to a <see cref="JsonNode"/> tree.
118+
/// Scalar typing follows YAML 1.2 core schema for plain (unquoted) scalars;
119+
/// single/double-quoted scalars are always emitted as JSON strings so
120+
/// authored quoting (e.g. <c>AttachWhenEquals: "0"</c>) survives round-trip.
121+
/// </summary>
122+
private static JsonNode? ConvertYamlNodeToJsonNode(YamlNode yaml)
123+
{
124+
switch (yaml)
125+
{
126+
case YamlMappingNode map:
127+
{
128+
var obj = new JsonObject();
129+
foreach (var entry in map.Children)
130+
{
131+
var key = (entry.Key as YamlScalarNode)?.Value ?? entry.Key.ToString();
132+
obj[key ?? string.Empty] = ConvertYamlNodeToJsonNode(entry.Value);
133+
}
134+
return obj;
135+
}
136+
case YamlSequenceNode seq:
137+
{
138+
var arr = new JsonArray();
139+
foreach (var child in seq.Children) arr.Add(ConvertYamlNodeToJsonNode(child));
140+
return arr;
141+
}
142+
case YamlScalarNode scalar:
143+
return ConvertYamlScalar(scalar);
144+
default:
145+
return null;
146+
}
147+
}
148+
149+
private static JsonNode? ConvertYamlScalar(YamlScalarNode scalar)
150+
{
151+
var s = scalar.Value;
152+
// Quoted scalars are always strings (preserves authored intent like `"0"`).
153+
if (scalar.Style == ScalarStyle.SingleQuoted ||
154+
scalar.Style == ScalarStyle.DoubleQuoted ||
155+
scalar.Style == ScalarStyle.Literal ||
156+
scalar.Style == ScalarStyle.Folded)
157+
{
158+
return JsonValue.Create(s ?? string.Empty);
159+
}
160+
// Plain scalars: apply YAML 1.2 core schema inference.
161+
if (s is null) return null;
162+
if (s.Length == 0 || s == "~" || s == "null" || s == "Null" || s == "NULL")
163+
return null;
164+
if (s == "true" || s == "True" || s == "TRUE") return JsonValue.Create(true);
165+
if (s == "false" || s == "False" || s == "FALSE") return JsonValue.Create(false);
166+
if (LooksLikeInteger(s) && long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
167+
return JsonValue.Create(i);
168+
if (LooksLikeFloat(s) && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
169+
return JsonValue.Create(d);
170+
return JsonValue.Create(s);
171+
}
172+
173+
private static bool LooksLikeInteger(string s)
174+
{
175+
if (s.Length == 0) return false;
176+
int start = (s[0] == '+' || s[0] == '-') ? 1 : 0;
177+
if (start == s.Length) return false;
178+
// Reject leading zeros for multi-digit integers (YAML core schema treats
179+
// "010" as a string, not an int).
180+
if (s.Length - start > 1 && s[start] == '0') return false;
181+
for (int i = start; i < s.Length; i++)
182+
if (s[i] < '0' || s[i] > '9') return false;
183+
return true;
184+
}
185+
186+
private static bool LooksLikeFloat(string s)
187+
{
188+
if (s.Length == 0) return false;
189+
bool seenDigit = false, seenDotOrExp = false;
190+
int i = (s[0] == '+' || s[0] == '-') ? 1 : 0;
191+
for (; i < s.Length; i++)
192+
{
193+
var c = s[i];
194+
if (c >= '0' && c <= '9') seenDigit = true;
195+
else if (c == '.' || c == 'e' || c == 'E') seenDotOrExp = true;
196+
else if (c == '+' || c == '-') { /* exponent sign — allow */ }
197+
else return false;
198+
}
199+
return seenDigit && seenDotOrExp;
200+
}
201+
105202
public static string ComputeContentHash(IReadOnlyDictionary<string, string> files)
106203
{
107204
if (files == null) throw new ArgumentNullException(nameof(files));

0 commit comments

Comments
 (0)