save progress
This commit is contained in:
@@ -11,9 +11,10 @@ public static class InvariantCulture
|
||||
public static IDisposable Scope()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
var originalUi = CultureInfo.CurrentUICulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
return new CultureScope(original);
|
||||
return new CultureScope(original, originalUi);
|
||||
}
|
||||
|
||||
public static int Compare(string? a, string? b) => string.Compare(a, b, StringComparison.Ordinal);
|
||||
@@ -25,11 +26,17 @@ public static class InvariantCulture
|
||||
private sealed class CultureScope : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _original;
|
||||
public CultureScope(CultureInfo original) => _original = original;
|
||||
private readonly CultureInfo _originalUi;
|
||||
|
||||
public CultureScope(CultureInfo original, CultureInfo originalUi)
|
||||
{
|
||||
_original = original;
|
||||
_originalUi = originalUi;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
CultureInfo.CurrentCulture = _original;
|
||||
CultureInfo.CurrentUICulture = _original;
|
||||
CultureInfo.CurrentUICulture = _originalUi;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,9 +47,14 @@ public static class InvariantCulture
|
||||
public static class Utf8Encoding
|
||||
{
|
||||
public static string Normalize(string input)
|
||||
{
|
||||
return input.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
=> Normalize(input, NormalizationForm.FormC);
|
||||
|
||||
public static byte[] GetBytes(string input) => Encoding.UTF8.GetBytes(Normalize(input));
|
||||
public static string Normalize(string input, NormalizationForm form)
|
||||
=> input.Normalize(form);
|
||||
|
||||
public static byte[] GetBytes(string input)
|
||||
=> Encoding.UTF8.GetBytes(Normalize(input));
|
||||
|
||||
public static byte[] GetBytes(string input, NormalizationForm form)
|
||||
=> Encoding.UTF8.GetBytes(Normalize(input, form));
|
||||
}
|
||||
|
||||
@@ -75,13 +75,55 @@ public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDic
|
||||
public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in value.OrderBy(x => x.Key?.ToString(), StringComparer.Ordinal))
|
||||
var ordered = value
|
||||
.Select(kvp => new
|
||||
{
|
||||
Key = kvp.Key,
|
||||
Value = kvp.Value,
|
||||
RawKeyString = ConvertKeyToString(kvp.Key)
|
||||
})
|
||||
.Select(kvp => new
|
||||
{
|
||||
kvp.Key,
|
||||
kvp.Value,
|
||||
kvp.RawKeyString,
|
||||
KeyString = ApplyKeyPolicy(kvp.RawKeyString, options)
|
||||
})
|
||||
.OrderBy(kvp => kvp.KeyString, StringComparer.Ordinal)
|
||||
.ThenBy(kvp => kvp.RawKeyString, StringComparer.Ordinal);
|
||||
|
||||
foreach (var kvp in ordered)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key?.ToString() ?? string.Empty);
|
||||
writer.WritePropertyName(kvp.KeyString);
|
||||
JsonSerializer.Serialize(writer, kvp.Value, options);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string ConvertKeyToString(TKey key)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentException("Dictionary key cannot be null.", nameof(key));
|
||||
}
|
||||
|
||||
return key switch
|
||||
{
|
||||
string s => s,
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => Convert.ToString(key, CultureInfo.InvariantCulture)
|
||||
} ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string ApplyKeyPolicy(string keyString, JsonSerializerOptions options)
|
||||
{
|
||||
if (options.DictionaryKeyPolicy is not null)
|
||||
{
|
||||
keyString = options.DictionaryKeyPolicy.ConvertName(keyString);
|
||||
}
|
||||
|
||||
return keyString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,7 +132,10 @@ public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDic
|
||||
public sealed class Iso8601DateTimeConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DateTimeOffset.Parse(reader.GetString()!, CultureInfo.InvariantCulture);
|
||||
=> DateTimeOffset.Parse(
|
||||
reader.GetString() ?? throw new JsonException("DateTimeOffset value is null."),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
|
||||
|
||||
35
src/__Libraries/StellaOps.Canonicalization/README.md
Normal file
35
src/__Libraries/StellaOps.Canonicalization/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# StellaOps.Canonicalization
|
||||
|
||||
Deterministic ordering and canonical JSON helpers used across StellaOps.
|
||||
|
||||
## Canonical JSON Defaults
|
||||
|
||||
`CanonicalJsonSerializer` uses these defaults unless you pass your own `JsonSerializerOptions`:
|
||||
|
||||
- Property naming: `JsonNamingPolicy.CamelCase`
|
||||
- Dictionary key naming: `JsonNamingPolicy.CamelCase`
|
||||
- Null handling: omit null values
|
||||
- Encoder: `JavaScriptEncoder.UnsafeRelaxedJsonEscaping`
|
||||
- Number handling: strict
|
||||
|
||||
These defaults are chosen for deterministic output. If you need stricter escaping or a different naming policy, use `JsonSerializerOptions` explicitly.
|
||||
|
||||
## Dictionary Key Handling
|
||||
|
||||
`StableDictionaryConverter` sorts keys using ordinal comparison of a stable string representation:
|
||||
|
||||
- String keys use the provided dictionary key policy (if any).
|
||||
- Non-string keys use invariant formatting when possible.
|
||||
- Null keys are rejected.
|
||||
- Duplicate keys after canonicalization are rejected to avoid ambiguous output.
|
||||
|
||||
## Date/Time Handling
|
||||
|
||||
`Iso8601DateTimeConverter` serializes `DateTimeOffset` values as UTC using the
|
||||
format `yyyy-MM-ddTHH:mm:ss.fffZ`. When parsing, offset-less values are treated
|
||||
as UTC to avoid local-time ambiguity.
|
||||
|
||||
## Determinism Verification
|
||||
|
||||
`DeterminismVerifier` can compare two JSON payloads and reports structural
|
||||
differences. Invalid JSON inputs are reported with context.
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0132-M | DONE | Maintainability audit for StellaOps.Canonicalization. |
|
||||
| AUDIT-0132-T | DONE | Test coverage audit for StellaOps.Canonicalization. |
|
||||
| AUDIT-0132-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0132-A | DONE | Applied canonicalization fixes and added tests. |
|
||||
|
||||
@@ -42,12 +42,40 @@ public sealed class DeterminismVerifier
|
||||
private static IReadOnlyList<string> FindDifferences(string a, string b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
using var docA = JsonDocument.Parse(a);
|
||||
using var docB = JsonDocument.Parse(b);
|
||||
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
|
||||
var parsedA = TryParseJson(a, "inputA", differences, out var docA);
|
||||
var parsedB = TryParseJson(b, "inputB", differences, out var docB);
|
||||
if (!parsedA || !parsedB)
|
||||
{
|
||||
return differences;
|
||||
}
|
||||
|
||||
using (docA)
|
||||
using (docB)
|
||||
{
|
||||
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
|
||||
}
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static bool TryParseJson(
|
||||
string json,
|
||||
string label,
|
||||
List<string> differences,
|
||||
out JsonDocument doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
differences.Add($"{label}: invalid JSON ({ex.Message})");
|
||||
doc = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareElements(JsonElement a, JsonElement b, string path, List<string> differences)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind)
|
||||
|
||||
Reference in New Issue
Block a user