From f147fe8cc8a8d3889183921845520b59de4b12c7 Mon Sep 17 00:00:00 2001 From: amis92 Date: Sun, 12 Jul 2026 19:32:30 +0200 Subject: [PATCH] deps: bump JsonSchema.Net 7.3.4 -> 9.2.2 and ReverseMarkdown 5.4.0 -> 6.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are major bumps with real breaking changes; supersedes dependabot #15, which only moved the version pins and does not compile. JsonSchema.Net 9: - Evaluate() takes a JsonElement, not a JsonNode. - EvaluationResults.HasErrors is gone; check Errors directly. - FromText() now registers each schema by $id in a process-wide registry that refuses to re-register an $id, so a second LoadFrom() in one process throws. Build into a registry owned by the validator instead. ReverseMarkdown 6 reorganizes Config into grouped options; the flat properties still work but are [Obsolete]. Moved to Tags/Formatting/Links. Kept GithubFlavored: it is NOT an alias for Flavor = MarkdownFlavor.GitHub, which selects the new round-trip-faithful writer that preserves raw HTML (it emits x rather than **x**). Adds SchemaValidatorTests covering the schema-rejection path, which had no coverage — the publish fixture only exercised documents that validate. Verified: build -warnaserror is clean, 826/826 tests pass, and the publisher runs end-to-end over the real data/ (12,799 products, 7,349 paints, 76 files schema-validated). Diffing v5 vs v6 markdown through the full HtmlCleaner pipeline, the only output change is that link URLs no longer leak & — v5 double-escaped ampersands in hrefs, so a future scrape may show & -> & in descriptions whose links carry query params. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 4 +- .../SchemaValidatorTests.cs | 67 +++++++++++++++++++ .../WarHub.Catalog.Publish/SchemaValidator.cs | 17 +++-- .../Scraping/HtmlCleaner.cs | 9 ++- 4 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 47ee98e..6d0cfaf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,11 @@ - + - + diff --git a/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs b/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs new file mode 100644 index 0000000..27e7815 --- /dev/null +++ b/tools/WarHub.Catalog.Publish.Tests/SchemaValidatorTests.cs @@ -0,0 +1,67 @@ +namespace WarHub.Catalog.Publish.Tests; + +/// +/// The happy path (every generated document validating) is covered by +/// via the fixture's full publish run. These cover the rejection path: a schema violation must +/// fail the build with a message that actually names the offending location. +/// +public sealed class SchemaValidatorTests +{ + private static SchemaValidator Validator() + => SchemaValidator.LoadFrom(Path.Combine(AppContext.BaseDirectory, "schema")); + + [Fact] + public void Valid_document_passes() + { + Validator().Validate("manifest", ValidManifest, "manifest.json"); + } + + [Fact] + public void Missing_required_property_is_reported() + { + // "files" is required by the manifest schema. + const string json = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{}} + """; + + var ex = Assert.Throws( + () => Validator().Validate("manifest", json, "manifest.json")); + + Assert.Contains("manifest.json", ex.Message, StringComparison.Ordinal); + Assert.Contains("files", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Nested_constraint_violation_reports_instance_location() + { + // sha256 must match ^[0-9a-f]{64}$ — this one is far too short. + const string json = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{}, + "files":[{"path":"products.json","kind":"products","bytes":10,"sha256":"abc"}]} + """; + + var ex = Assert.Throws( + () => Validator().Validate("manifest", json, "manifest.json")); + + // The error detail must pinpoint where it failed, not just say "invalid". + Assert.Contains("/files/0/sha256", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Unknown_schema_name_throws() + { + var ex = Assert.Throws( + () => Validator().Validate("no-such-schema", ValidManifest, "whatever.json")); + + Assert.Contains("no-such-schema", ex.Message, StringComparison.Ordinal); + } + + private const string ValidManifest = """ + {"schemaVersion":"1","kind":"manifest","version":"1.0.0","generatedAt":"2026-07-04T00:00:00Z", + "source":{"repo":"WarHub/warhub-catalog"},"counts":{"products":2}, + "files":[{"path":"products.json","kind":"products","bytes":10, + "sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]} + """; +} diff --git a/tools/WarHub.Catalog.Publish/SchemaValidator.cs b/tools/WarHub.Catalog.Publish/SchemaValidator.cs index c484c44..6feea5e 100644 --- a/tools/WarHub.Catalog.Publish/SchemaValidator.cs +++ b/tools/WarHub.Catalog.Publish/SchemaValidator.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Nodes; +using System.Text.Json; using Json.Schema; namespace WarHub.Catalog.Publish; @@ -15,10 +15,15 @@ private SchemaValidator() { } public static SchemaValidator LoadFrom(string schemaDir) { + // Build into a registry owned by this instance. The default is a process-wide global + // registry that refuses to re-register an $id, which would make a second LoadFrom in + // the same process throw. + var options = new BuildOptions { SchemaRegistry = new SchemaRegistry() }; + var v = new SchemaValidator(); foreach (string file in Directory.EnumerateFiles(schemaDir, "*.json")) { - v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file)); + v._schemas[Path.GetFileNameWithoutExtension(file)] = JsonSchema.FromText(File.ReadAllText(file), options); } return v; @@ -31,12 +36,12 @@ public void Validate(string schemaName, string json, string relPath) throw new InvalidOperationException($"No schema '{schemaName}' loaded (validating {relPath})."); } - JsonNode? node = JsonNode.Parse(json); - EvaluationResults results = schema.Evaluate(node, new EvaluationOptions { OutputFormat = OutputFormat.List }); + using JsonDocument doc = JsonDocument.Parse(json); + EvaluationResults results = schema.Evaluate(doc.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List }); if (!results.IsValid) { - IEnumerable errors = results.Details - .Where(d => d.HasErrors) + IEnumerable errors = (results.Details ?? []) + .Where(d => d.Errors is { Count: > 0 }) .SelectMany(d => d.Errors!.Select(e => $"{d.InstanceLocation}: {e.Value}")) .Distinct() .Take(10); diff --git a/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs b/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs index 9a4803c..1ecc6ec 100644 --- a/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs +++ b/tools/WarHub.ProductCatalog.Tool/Scraping/HtmlCleaner.cs @@ -10,12 +10,15 @@ internal static partial class HtmlCleaner { private const int DefaultMaxLength = 10000; + // GithubFlavored enables GFM extras (pipe tables, ~~strikethrough~~). It is NOT the same + // as Flavor = MarkdownFlavor.GitHub, which selects the round-trip-faithful GitHub writer + // that preserves raw HTML — that would emit x instead of **x**. private static readonly Converter MarkdownConverter = new(new Config { - UnknownTags = Config.UnknownTagsOption.Drop, GithubFlavored = true, - RemoveComments = true, - SmartHrefHandling = true, + Tags = { Unknown = Config.UnknownTagsOption.Drop }, + Formatting = { RemoveComments = true }, + Links = { SmartHref = true }, }); ///