From 76be74c4e1485dc2f9ebc6f1c18f349f154557c4 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Thu, 28 May 2026 14:28:28 -0700 Subject: [PATCH] add SHA-based artifact-freshness check to VerbCatalogSanityTests (#527) Fails CI when artifacts/verb-output-schemas.json drifts from what the current verb annotations would generate. Closes a silent-staleness gap flagged in the 2026-05-28 squad fan-out. Note: this test will be RED until Mozart's [VerbResult] backfill PR (squad/527-verbresult-attrs) lands and regenerates the artifact. That is expected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Annotations/VerbCatalogSanityTests.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs b/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs index 441aabdd..c4b8f60d 100644 --- a/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs +++ b/tests/Polyphony.Tests/Annotations/VerbCatalogSanityTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Shouldly; @@ -113,4 +115,97 @@ public void Catalog_EveryVerbResultType_HasMatchingTypesMapEntry() } dangling.ShouldBeEmpty(string.Join(Environment.NewLine, dangling)); } + + /// + /// SHA-based freshness gate: fails CI when + /// artifacts/verb-output-schemas.json drifts from what the + /// current verb annotations would generate. + /// + /// + /// Both sides are canonicalized (object keys sorted, consistent + /// indentation) before hashing so that whitespace-only reformats + /// don't trigger false positives. + /// + /// + /// + /// NOTE: this test is RED until Mozart's [VerbResult] backfill + /// PR (squad/527-verbresult-attrs) lands and regenerates the + /// artifact. That is expected and intentional. + /// + /// + /// + /// If this test fails, run: + /// dotnet build src/Polyphony.SchemaExporter -c Release + /// + /// + [Fact] + public void VerbOutputSchemas_ArtifactIsFresh() + { + var repoRoot = FindRepoRoot(); + var artifactPath = Path.Combine(repoRoot, "artifacts", "verb-output-schemas.json"); + + File.Exists(artifactPath).ShouldBeTrue( + $"artifacts/verb-output-schemas.json is missing. " + + $"Run: dotnet build src/Polyphony.SchemaExporter -c Release" + + $"{Environment.NewLine}Looked at: {artifactPath}"); + + var embeddedCanon = CanonicalizeJson(VerbOutputSchemaCatalog.Json); + var diskCanon = CanonicalizeJson(File.ReadAllText(artifactPath)); + + var embeddedSha = Sha256Hex(embeddedCanon); + var diskSha = Sha256Hex(diskCanon); + + diskSha.ShouldBe(embeddedSha, + $"artifacts/verb-output-schemas.json is stale (SHA mismatch).{Environment.NewLine}" + + $" On-disk SHA : {diskSha}{Environment.NewLine}" + + $" Expected SHA: {embeddedSha}{Environment.NewLine}" + + $"Run: dotnet build src/Polyphony.SchemaExporter -c Release"); + } + + // ───────────────────────────────────────────────────────────────────── + // Helpers for freshness check + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parse , sort all object keys recursively, + /// and re-serialize with consistent indentation. Two JSON documents + /// that are semantically equivalent will produce the same canonical + /// string regardless of original key order or whitespace. + /// + private static string CanonicalizeJson(string json) + { + var node = JsonNode.Parse(json)!; + return SortNode(node).ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private static JsonNode SortNode(JsonNode node) + { + if (node is JsonObject obj) + { + var sorted = new JsonObject(); + foreach (var kv in obj.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + sorted.Add(kv.Key, kv.Value is not null ? SortNode(kv.Value.DeepClone()) : null); + } + return sorted; + } + + if (node is JsonArray arr) + { + var sortedArr = new JsonArray(); + foreach (var item in arr) + { + sortedArr.Add(item is not null ? SortNode(item.DeepClone()) : null); + } + return sortedArr; + } + + return node.DeepClone(); + } + + private static string Sha256Hex(string input) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } }