stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
@@ -13,7 +12,7 @@ namespace StellaOps.Canonicalization.Json;
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
private static readonly JsonSerializerOptions _options = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -29,7 +28,7 @@ public static class CanonicalJsonSerializer
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, Options);
|
||||
=> JsonSerializer.Serialize(value, _options);
|
||||
|
||||
public static (string Json, string Digest) SerializeWithDigest<T>(T value)
|
||||
{
|
||||
@@ -41,102 +40,7 @@ public static class CanonicalJsonSerializer
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, Options)
|
||||
return JsonSerializer.Deserialize<T>(json, _options)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter factory that orders dictionary keys alphabetically.
|
||||
/// </summary>
|
||||
public sealed class StableDictionaryConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
if (!typeToConvert.IsGenericType) return false;
|
||||
var generic = typeToConvert.GetGenericTypeDefinition();
|
||||
return generic == typeof(Dictionary<,>) || generic == typeof(IDictionary<,>) || generic == typeof(IReadOnlyDictionary<,>);
|
||||
}
|
||||
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var args = typeToConvert.GetGenericArguments();
|
||||
var converterType = typeof(StableDictionaryConverter<,>).MakeGenericType(args[0], args[1]);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDictionary<TKey, TValue>>
|
||||
where TKey : notnull
|
||||
{
|
||||
public override IDictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
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.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>
|
||||
/// Converter for ISO 8601 date/time with UTC normalization.
|
||||
/// </summary>
|
||||
public sealed class Iso8601DateTimeConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> 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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Canonicalization.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Converter for ISO 8601 date/time with UTC normalization.
|
||||
/// </summary>
|
||||
public sealed class Iso8601DateTimeConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> 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));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Canonicalization.Json;
|
||||
|
||||
public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDictionary<TKey, TValue>>
|
||||
where TKey : notnull
|
||||
{
|
||||
public override IDictionary<TKey, TValue>? Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
IDictionary<TKey, TValue> value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Canonicalization.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Converter factory that orders dictionary keys alphabetically.
|
||||
/// </summary>
|
||||
public sealed class StableDictionaryConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
if (!typeToConvert.IsGenericType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var generic = typeToConvert.GetGenericTypeDefinition();
|
||||
return generic == typeof(Dictionary<,>)
|
||||
|| generic == typeof(IDictionary<,>)
|
||||
|| generic == typeof(IReadOnlyDictionary<,>);
|
||||
}
|
||||
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var args = typeToConvert.GetGenericArguments();
|
||||
var converterType = typeof(StableDictionaryConverter<,>).MakeGenericType(args[0], args[1]);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Canonicalization Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0048-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0048-A | TODO | Requires MAINT/TEST + approval. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-03 | DONE | Split canonical JSON serializer and determinism verifier into <=100-line files; renamed private options field; `dotnet test src/__Libraries/__Tests/StellaOps.Canonicalization.Tests/StellaOps.Canonicalization.Tests.csproj` passed (16 tests). |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace StellaOps.Canonicalization.Verification;
|
||||
|
||||
public sealed record ComparisonResult(
|
||||
bool IsIdentical,
|
||||
IReadOnlyList<string> Differences);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Canonicalization.Verification;
|
||||
|
||||
public sealed record DeterminismResult(
|
||||
bool IsDeterministic,
|
||||
int UniqueOutputs,
|
||||
int UniqueDigests,
|
||||
string SampleOutput,
|
||||
string SampleDigest);
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Canonicalization.Verification;
|
||||
|
||||
public sealed partial class DeterminismVerifier
|
||||
{
|
||||
private static IReadOnlyList<string> FindDifferences(string a, string b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
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)
|
||||
{
|
||||
differences.Add($"{path}: type mismatch ({a.ValueKind} vs {b.ValueKind})");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (a.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var propsA = a.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
var propsB = b.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
foreach (var key in propsA.Keys.Union(propsB.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var hasA = propsA.TryGetValue(key, out var propA);
|
||||
var hasB = propsB.TryGetValue(key, out var propB);
|
||||
if (!hasA)
|
||||
{
|
||||
differences.Add($"{path}.{key}: missing in first");
|
||||
}
|
||||
else if (!hasB)
|
||||
{
|
||||
differences.Add($"{path}.{key}: missing in second");
|
||||
}
|
||||
else
|
||||
{
|
||||
CompareElements(propA.Value, propB.Value, $"{path}.{key}", differences);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
var arrA = a.EnumerateArray().ToList();
|
||||
var arrB = b.EnumerateArray().ToList();
|
||||
if (arrA.Count != arrB.Count)
|
||||
{
|
||||
differences.Add($"{path}: array length mismatch ({arrA.Count} vs {arrB.Count})");
|
||||
}
|
||||
for (var i = 0; i < Math.Min(arrA.Count, arrB.Count); i++)
|
||||
{
|
||||
CompareElements(arrA[i], arrB[i], $"{path}[{i}]", differences);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (a.GetRawText() != b.GetRawText())
|
||||
{
|
||||
differences.Add($"{path}: value mismatch");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Canonicalization.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that serialization produces identical output across runs.
|
||||
/// </summary>
|
||||
public sealed class DeterminismVerifier
|
||||
public sealed partial class DeterminismVerifier
|
||||
{
|
||||
public DeterminismResult Verify<T>(T value, int iterations = 10)
|
||||
{
|
||||
@@ -39,89 +37,4 @@ public sealed class DeterminismVerifier
|
||||
var differences = FindDifferences(jsonA, jsonB);
|
||||
return new ComparisonResult(false, differences);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindDifferences(string a, string b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
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)
|
||||
{
|
||||
differences.Add($"{path}: type mismatch ({a.ValueKind} vs {b.ValueKind})");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (a.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var propsA = a.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
var propsB = b.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
foreach (var key in propsA.Keys.Union(propsB.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var hasA = propsA.TryGetValue(key, out var propA);
|
||||
var hasB = propsB.TryGetValue(key, out var propB);
|
||||
if (!hasA) differences.Add($"{path}.{key}: missing in first");
|
||||
else if (!hasB) differences.Add($"{path}.{key}: missing in second");
|
||||
else CompareElements(propA.Value, propB.Value, $"{path}.{key}", differences);
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
var arrA = a.EnumerateArray().ToList();
|
||||
var arrB = b.EnumerateArray().ToList();
|
||||
if (arrA.Count != arrB.Count)
|
||||
differences.Add($"{path}: array length mismatch ({arrA.Count} vs {arrB.Count})");
|
||||
for (var i = 0; i < Math.Min(arrA.Count, arrB.Count); i++)
|
||||
CompareElements(arrA[i], arrB[i], $"{path}[{i}]", differences);
|
||||
break;
|
||||
default:
|
||||
if (a.GetRawText() != b.GetRawText())
|
||||
differences.Add($"{path}: value mismatch");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DeterminismResult(
|
||||
bool IsDeterministic,
|
||||
int UniqueOutputs,
|
||||
int UniqueDigests,
|
||||
string SampleOutput,
|
||||
string SampleDigest);
|
||||
|
||||
public sealed record ComparisonResult(
|
||||
bool IsIdentical,
|
||||
IReadOnlyList<string> Differences);
|
||||
|
||||
Reference in New Issue
Block a user