From 9553eab5e9e6101fab20666d3be9fa933c3eb092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 13:44:39 +0200 Subject: [PATCH 1/5] docs: KB-040, a converter's value is refused at the depth limit where the serializer's own is written Measured with System.Text.Json of the pinned SDK: under MaxDepth = 3, a list element at the writer's depth 3 is written by the serializer's own converter for int and refused with a JsonException at the list's path when a converter of one's own writes it; one level less deep it is written, and reading is not charged. Its fixture is the probes' side (1) for a converter-written value under a depth limit, which the stj-maxdepth note told by the refusal's message until now. Co-Authored-By: Claude Opus 5.5 --- ...nverter-is-charged-a-level-of-max-depth.md | 28 +++++++ design/knowledge/README.md | 1 + test/CodoMetis.TypeKit.Facts/KB040.cs | 73 +++++++++++++++++++ .../KB040.fixture.json | 44 +++++++++++ 4 files changed, 146 insertions(+) create mode 100644 design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB040.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB040.fixture.json diff --git a/design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md b/design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md new file mode 100644 index 00000000..df4e7873 --- /dev/null +++ b/design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md @@ -0,0 +1,28 @@ +--- +verified: 2026-10-17 +pins: + - sdk 10.0.4xx +source: measured with System.Text.Json of the pinned SDK, a list of int nested in objects, written once by the serializer's own converter and once by a converter of the application's; the API reference of JsonSerializerOptions.MaxDepth says only that it is "the maximum depth allowed when serializing or deserializing JSON", nothing of converters +turned-on: the rows of BaseProbe, C10-preserve-guard, L01-json-bytes and L04-round-trip under json.MaxDepth=3 where a value object, which its converter writes, was refused at a depth its wrapped twin was written at; until WS2's three-sided differential these were told by the refusal's message (note stj-maxdepth), now by side (1), a converter of the application's for the wrapped type without TypeKit +test: test/CodoMetis.TypeKit.Facts/KB040.cs +fixture: test/CodoMetis.TypeKit.Facts/KB040.fixture.json +--- + +# KB-040. Under `MaxDepth`, System.Text.Json refuses a value a converter of one's own writes at the limit, where its own converter writes it + +With `JsonSerializerOptions.MaxDepth = 3`, a list of `int` inside two objects, `{"P":{"P":[1]}}`, is written: the +writer is at depth 3 when it writes the element. Register a converter of your own for `int` that writes the same +number, and the same document is refused with a `JsonException` whose `Path` is the list's, `$.P.P`, before the +converter is asked to write anything. One level less deep, `{"P":[1]}`, the converter's value is written. Reading +is not charged: the converter reads the element of `{"P":{"P":[1]}}` under the same limit. + +| The element of a `List` at the writer's depth | the serializer's own converter | a converter of one's own | +|---|---|---| +| 2 (`{"P":[1]}`), `MaxDepth = 3` | written | written | +| 3 (`{"P":{"P":[1]}}`), `MaxDepth = 3` | written | `JsonException` at `$.P.P` | +| 3, read back | read | read | + +A value object is written by its generated converter, so under a depth limit it is refused one level earlier than +its wrapped type, which the serializer's own converter writes, and the same holds for any converter an application +registers. The probes' side (1) for such a row is a converter of the application's for the wrapped type, without +TypeKit, or this fixture where it covers the row's limit and the depth the value is written at. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index 052b094b..d7e745f8 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -68,3 +68,4 @@ test and later for the harness's `Baseline.Fact`. | [KB-037](KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md) | Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default | SDK 10.0.4xx | `KB037` | | [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` | | [KB-039](KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md) | EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`, and does both for a `decimal` | EF Core 10.0.12, its SQLite provider 10.0.12 | `KB039` | +| [KB-040](KB-040-a-converter-is-charged-a-level-of-max-depth.md) | Under `MaxDepth`, System.Text.Json refuses a value a converter of one's own writes at the limit, where its own converter writes it | SDK 10.0.4xx | `KB040` | diff --git a/test/CodoMetis.TypeKit.Facts/KB040.cs b/test/CodoMetis.TypeKit.Facts/KB040.cs new file mode 100644 index 00000000..0743f633 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB040.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-040: under JsonSerializerOptions.MaxDepth, System.Text.Json refuses a value that a converter of one's own +/// writes at the writer's depth equal to the limit, with a JsonException at that value, where its own converter for the same type +/// writes it; one level less deep, the converter's value is written. Reading is not charged: the converter reads its value at the limit. +/// +public sealed class KB040 +{ + public sealed class A + { + public T P { get; set; } = default!; + } + + /// A converter of the application's for int, which reads and writes the number as the serializer does. + public sealed class IntConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private static readonly FactFixture Fixture = FactFixture.Beside("KB-040"); + + public static TheoryData Answers() => [.. Fixture.Answers.Select(answer => answer.Id)]; + + private static JsonSerializerOptions Options(IReadOnlyDictionary when) + { + var options = new JsonSerializerOptions { MaxDepth = int.Parse(when["json.MaxDepth"], CultureInfo.InvariantCulture) }; + if (when["written-by"] == "a-converter") options.Converters.Add(new IntConverter()); + return options; + } + + /// A list of one int whose element the writer is at the given depth for: inside the root object, then one more object per level. + private static object At(string depth) => depth switch + { + "2" => new A> { P = [1] }, + "3" => new A>> { P = new() { P = [1] } }, + _ => throw new ArgumentOutOfRangeException(nameof(depth), depth, "the fixture's depths are 2 and 3"), + }; + + /// Each answer of KB040.fixture.json: the element written by the converter or the serializer, at the depth the answer names. + [Theory, MemberData(nameof(Answers))] + public void The_serializer_writes_or_refuses_as_the_fixture_holds(string id) + { + var answer = Fixture[id]; + var value = At(answer.When["writer-depth"]); + + if (answer.Kind == FactAnswer.Exception) + { + var thrown = Should.Throw(() => JsonSerializer.Serialize(value, value.GetType(), Options(answer.When))); + answer.Mismatch(thrown).ShouldBeNull(); + ((JsonException)thrown).Path.ShouldBe("$.P.P"); + } + else + { + answer.Mismatch(JsonNode.Parse(JsonSerializer.Serialize(value, value.GetType(), Options(answer.When)))!).ShouldBeNull(); + } + } + + /// Reading is not charged: the converter reads the element at the limit, as the serializer's own does. + [Fact] + public void A_converter_reads_its_value_at_the_limit() + { + var options = Options(new Dictionary { ["json.MaxDepth"] = "3", ["written-by"] = "a-converter" }); + + JsonSerializer.Deserialize>>>("""{"P":{"P":[1]}}""", options)!.P.P.ShouldBe([1]); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB040.fixture.json b/test/CodoMetis.TypeKit.Facts/KB040.fixture.json new file mode 100644 index 00000000..2bb6b935 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB040.fixture.json @@ -0,0 +1,44 @@ +{ + "fact": "KB-040", + "answers": [ + { + "id": "a-converter-at-the-limit", + "kind": "exception", + "when": { + "json.MaxDepth": "3", + "written-by": "a-converter", + "writer-depth": "3" + }, + "value": { + "type": "System.Text.Json.JsonException", + "operation": "write", + "stage": "the value the converter is handed, before it writes anything" + }, + "note": "a list element at the writer's depth 3, written by a converter of the application's: refused, with the path of the element" + }, + { + "id": "the-serializer-at-the-limit", + "kind": "document", + "when": { + "json.MaxDepth": "3", + "written-by": "the-serializer", + "writer-depth": "3" + }, + "at": "/P/P", + "value": [1], + "note": "the same element, written by the serializer's own converter for int: written" + }, + { + "id": "a-converter-below-the-limit", + "kind": "document", + "when": { + "json.MaxDepth": "3", + "written-by": "a-converter", + "writer-depth": "2" + }, + "at": "/P", + "value": [1], + "note": "a list element at the writer's depth 2, written by a converter of the application's: written" + } + ] +} From 3000d79ff2a4298d334b68222af6f7d6a99089bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 13:44:39 +0200 Subject: [PATCH 2/5] test: compile the facts' fixture reader into the Samples for the probe harness The three-sided differential reads side (1) from a fact's fixture where one covers a row (FactFixture, design/adr/0107). FactFixture.cs is BCL only, so it is compiled in as it is, as the analyzer tests' variant catalogues are, rather than the Facts project referenced. Co-Authored-By: Claude Opus 5.5 --- .../CodoMetis.TypeKit.Samples.csproj | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj b/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj index b727a509..8116a63d 100644 --- a/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj +++ b/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj @@ -49,4 +49,13 @@ + + + + + From bf6db0b5340f839845a1f124c5b00b0c745e5590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 14:01:40 +0200 Subject: [PATCH 3/5] docs: KB-041, MVC cannot write its automatic 400 under a source-generated context without ValidationProblemDetails Measured in a host without TypeKit: an [ApiController] action whose int parameter is sent abc answers 400 under the serializer's default resolver, and fails with System.Text.Json's NotSupportedException when MVC's JSON options resolve through a source-generated context that lists int alone. Its fixture is the probes' side (1) for a refused binding under such a context, which the note mvc-400-unwritable-under-source-gen told by the exception's words until now. Co-Authored-By: Claude Opus 5.5 --- ...text-without-validation-problem-details.md | 26 ++++++ design/knowledge/README.md | 1 + test/CodoMetis.TypeKit.Facts/KB041.cs | 81 +++++++++++++++++++ .../KB041.fixture.json | 33 ++++++++ 4 files changed, 141 insertions(+) create mode 100644 design/knowledge/KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB041.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB041.fixture.json diff --git a/design/knowledge/KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md b/design/knowledge/KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md new file mode 100644 index 00000000..a83170bd --- /dev/null +++ b/design/knowledge/KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md @@ -0,0 +1,26 @@ +--- +verified: 2026-10-17 +pins: + - sdk 10.0.4xx +source: measured in a host without TypeKit (Microsoft.AspNetCore.App 10.0 of the pinned SDK, an [ApiController] with an int route parameter, MVC's JSON options given a source-generated context listing int alone); ASP.NET Core's web API documentation, "Automatic HTTP 400 responses", names ValidationProblemDetails as the default type of that response +turned-on: rows 286 and 309 of BaseProbe (claim C07-fault-responses-blanket) under json.TypeInfoResolver=source-generated-context, where a value Create refuses answered 500 instead of MVC's 400; until WS2's three-sided differential told by the exception's words (note mvc-400-unwritable-under-source-gen), now by side (1), the wrapped twin sent a text it cannot read on a host without TypeKit +test: test/CodoMetis.TypeKit.Facts/KB041.cs +fixture: test/CodoMetis.TypeKit.Facts/KB041.fixture.json +--- + +# KB-041. MVC cannot write its automatic 400 under a source-generated context that does not list `ValidationProblemDetails` + +An `[ApiController]` action whose parameter MVC cannot bind answers 400 with a `ValidationProblemDetails`, which +MVC writes with its own JSON options. Give those options a source-generated context that lists the action's types +but not `ValidationProblemDetails`, and the 400 is never written: the request fails with System.Text.Json's +`NotSupportedException`, which a test server passes to its client, for a parameter of any type. Under the +serializer's default resolver the same request is a 400. + +| `GET /kb041/abc` to an action's `int` route parameter | MVC's JSON options' resolver | +|---|---| +| `NotSupportedException` | a source-generated context listing `int` alone | +| 400 | the serializer's default | + +So under such a context a refused binding has no 400 to compare: a value object whose text its `Create` refuses +fails the request as the wrapped type does with a text it cannot read. The HTTP probes show it in the same row with +the twin on a host without TypeKit, or read this fixture where it covers the row. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index d7e745f8..c14f9f18 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -69,3 +69,4 @@ test and later for the harness's `Baseline.Fact`. | [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` | | [KB-039](KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md) | EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`, and does both for a `decimal` | EF Core 10.0.12, its SQLite provider 10.0.12 | `KB039` | | [KB-040](KB-040-a-converter-is-charged-a-level-of-max-depth.md) | Under `MaxDepth`, System.Text.Json refuses a value a converter of one's own writes at the limit, where its own converter writes it | SDK 10.0.4xx | `KB040` | +| [KB-041](KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md) | MVC cannot write its automatic 400 under a source-generated context that does not list `ValidationProblemDetails` | SDK 10.0.4xx | `KB041` | diff --git a/test/CodoMetis.TypeKit.Facts/KB041.cs b/test/CodoMetis.TypeKit.Facts/KB041.cs new file mode 100644 index 00000000..91bbbcae --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB041.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Reflection; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.Facts; + +/// A source-generated context that lists int and nothing of MVC's: no ValidationProblemDetails. +[JsonSerializable(typeof(int))] +internal sealed partial class KB041Context : JsonSerializerContext; + +/// +/// design/knowledge/KB-041: an [ApiController] action whose parameter MVC cannot bind answers its automatic 400 as a +/// ValidationProblemDetails, which MVC writes with its JSON options; where their resolver is a source-generated context that does +/// not list that type, the 400 is not written and the request fails with the serializer's exception, for any parameter type. +/// +public sealed class KB041 +{ + [ApiController] + public sealed class BindController : ControllerBase + { + [HttpGet("kb041/{v}")] + public IActionResult Route([FromRoute(Name = "v")] int v) => Ok(v); + } + + private static readonly FactFixture Fixture = FactFixture.Beside("KB-041"); + + public static TheoryData Answers() => [.. Fixture.Answers.Select(answer => answer.Id)]; + + private static async Task SendAsync(bool sourceGenerated) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddControllers() + .AddJsonOptions(options => { if (sourceGenerated) options.JsonSerializerOptions.TypeInfoResolver = KB041Context.Default; }) + .ConfigureApplicationPartManager(parts => + { + parts.ApplicationParts.Clear(); + parts.FeatureProviders.Add(new Only()); + }); + + await using var app = builder.Build(); + app.MapControllers(); + await app.StartAsync(TestContext.Current.CancellationToken); + + return await app.GetTestClient().GetAsync(new Uri("/kb041/abc", UriKind.Relative), TestContext.Current.CancellationToken); + } + + /// Each answer of KB041.fixture.json: abc sent to the action's int, under the resolver the answer names. + [Theory, MemberData(nameof(Answers))] + public async Task MVC_answers_a_refused_binding_as_the_fixture_holds(string id) + { + var answer = Fixture[id]; + var sourceGenerated = answer.When["json.TypeInfoResolver"] == "source-generated-context"; + + if (answer.Kind == FactAnswer.Exception) + { + var thrown = await Should.ThrowAsync(() => SendAsync(sourceGenerated)); + answer.Mismatch(thrown).ShouldBeNull(); + } + else + { + using var response = await SendAsync(sourceGenerated); + answer.Mismatch((int)response.StatusCode, "").ShouldBeNull(); + } + } + + private sealed class Only : IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) => + feature.Controllers.Add(typeof(BindController).GetTypeInfo()); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB041.fixture.json b/test/CodoMetis.TypeKit.Facts/KB041.fixture.json new file mode 100644 index 00000000..5c4129de --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB041.fixture.json @@ -0,0 +1,33 @@ +{ + "fact": "KB-041", + "answers": [ + { + "id": "source-generated-context-refused-binding", + "kind": "exception", + "when": { + "json.TypeInfoResolver": "source-generated-context", + "endpoint": "mvc-api-controller", + "binding": "fails" + }, + "value": { + "type": "System.NotSupportedException", + "operation": "bind", + "stage": "writing the automatic 400, a ValidationProblemDetails the context does not list" + }, + "note": "an [ApiController] action whose int parameter is sent abc, under MVC JSON options whose resolver is a source-generated context listing int alone: the 400 is not written, the request fails" + }, + { + "id": "default-resolver-refused-binding", + "kind": "status", + "when": { + "json.TypeInfoResolver": "default", + "endpoint": "mvc-api-controller", + "binding": "fails" + }, + "value": { + "status": 400 + }, + "note": "the same request under the serializer's default resolver: MVC's automatic 400" + } + ] +} From 9974f46f3dfabd3a32c596ad231acbff2ca5d64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 14:04:24 +0200 Subject: [PATCH 4/5] test: the three-sided differential in the http, json and text harness Every comparison of a value object with its wrapped twin now has three sides (harness/Sides.cs, the rules in one place): (1) the wrapped twin without TypeKit, a host without AddTypeKit() and TypeKit's JSON converters (HostCfg.WithoutTypeKit, the twin's endpoints alone) or, in process, the application's options less TypeKit's converters; (2) the twin with TypeKit, as before; (3) the value object. Where (1) and (3) refuse alike (the same operation, stage and exception type, and status; no message compared) the check is the framework's refusal, expected-refusal against Baseline.Framework, or Baseline.Fact where a fact's fixture answered for side (1) (KB-040, KB-041, KB-037); (2) differing from (1) is "TypeKit changed the wrapped type's behaviour". Side (1) runs on every row of C21, L01 and C10, whose obligations promise the underlying type's schema or JSON or that it is unaffected, and elsewhere only where (2) and (3) disagree or refuse. The text-matching sites of #53 and #57 are gone: HttpBaseline's stj-maxdepth (both sides) and MVC's 500 under a source-generated context, the three OpenAPI 2.0 path and shape checks (now side (1)'s document at the same path, and the structure of the difference), Positions.cs's and TextRow.cs's depth checks and JsonFamily.cs's Jx.Depth, TextRow.cs's Path cut by its words (now JsonException.Path, with the twin's Path beside it), OffTheWire.cs's "not a wire type" (now the refusal's TargetSite, by type), and the message comparisons that made a read refused alike a pass (json:read-refused-by-stj-alone, json-half:wrapped- alone-refuses). FaultClaim, SchemaClaim, C06 and C18 read a message only to fail a row now. The note keys those sites called stay in NoteKeys; framework-refuses is new. JsonParity.ReadMayDiffer, unused, is gone. Triage 1, harness, found by side (1): C21 row 104 compared the twin's and the value object's shapes where the row's CreateSchemaReferenceId gave both one component (an array of Nullable), so the document held one schema for the two; such a pair is now inconclusive. C10 rows 24 and 190 (a sealed record reading null) and 156 (the application's converter for the value object beside a twin refused by depth) were judged against the wrong baseline once the depth note no longer hid them. The probes reading a fact's fixture watch it and the reader (registry.json). Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/harness/DecimalSpelling.cs | 11 +- tools/probe/corpus/harness/FaultClaim.cs | 5 +- tools/probe/corpus/harness/HttpBaseline.cs | 365 ++++++++++++------ tools/probe/corpus/harness/HttpHost.cs | 32 +- tools/probe/corpus/harness/JsonFamily.cs | 4 - tools/probe/corpus/harness/OffTheWire.cs | 67 ++-- tools/probe/corpus/harness/Positions.cs | 100 +++-- tools/probe/corpus/harness/PreserveGuard.cs | 157 ++++---- tools/probe/corpus/harness/Row.cs | 13 +- tools/probe/corpus/harness/SchemaClaim.cs | 17 +- tools/probe/corpus/harness/Sides.cs | 298 ++++++++++++++ tools/probe/corpus/harness/TextRow.cs | 196 +++++----- .../probe/corpus/probes/C06FaultKindsProbe.cs | 6 +- tools/probe/corpus/probes/C18Cmtk1023Probe.cs | 4 +- tools/probe/corpus/registry.json | 29 ++ 15 files changed, 888 insertions(+), 416 deletions(-) create mode 100644 tools/probe/corpus/harness/Sides.cs diff --git a/tools/probe/corpus/harness/DecimalSpelling.cs b/tools/probe/corpus/harness/DecimalSpelling.cs index d8147c2d..eea828d7 100644 --- a/tools/probe/corpus/harness/DecimalSpelling.cs +++ b/tools/probe/corpus/harness/DecimalSpelling.cs @@ -82,7 +82,16 @@ sealed record Form(string Name, string Json, Exception? Ep, Exception? Ev, List< var (jp, xp) = Jx.Ser(hp, o); var (jv, xv) = Jx.Ser(hv, o); var (jb, xb) = Jx.Ser(hp, ob); if (xb is not null && xp is not null && (xv is not null || ownVo) && xp.GetType() == xb.GetType()) { - // the options refuse the shape for the serializer's own decimal too: nothing is written or read to compare + // the options refuse the shape for the serializer's own decimal too: nothing is written or read to compare. Where the value + // object is refused alike, at the same stage with the same exception type, the decimal without TypeKit's converter is side (1) + // of the three-sided differential (Sides), and each check is the framework's refusal; otherwise none can tell. + var one = new Sides.Answer("write", null, xb); var three = new Sides.Answer("write", null, xv); + if (Sides.Alike(one, three)) + { + var framework = Sides.Measured(one, "the serializer's own decimal, without TypeKit's converter"); + foreach (var o2 in new[] { Smallest, kindOf, Covers, Reads, Keeps }) r.Check(o2, framework.Baseline, c => Sides.Refuses(c, framework, three)); + return null; + } var why = "the options refuse the shape for the serializer's own decimal too: " + Jx.Ex(xb); string? None(Check c) => c.Inconclusive(why); foreach (var ob2 in new[] { Smallest, kindOf }) r.Check(ob2, docs, None); diff --git a/tools/probe/corpus/harness/FaultClaim.cs b/tools/probe/corpus/harness/FaultClaim.cs index 73e2bf47..19ba7a67 100644 --- a/tools/probe/corpus/harness/FaultClaim.cs +++ b/tools/probe/corpus/harness/FaultClaim.cs @@ -320,8 +320,9 @@ static Row StrictContext(Row r) var names = s.Refused!; var ioe = ex as InvalidOperationException ?? ex.InnerException as InvalidOperationException; if (ioe is null) return $"host refused to start, but with {ex.GetType().Name}, not InvalidOperationException: {Cut(ex.Message)}"; - var missing = names.Where(n => !ioe.Message.Contains(n, StringComparison.Ordinal)).ToList(); - return missing.Count == 0 ? null : $"host refused to start, but not naming {string.Join(", ", missing)}: {Cut(ioe.Message)}"; + foreach (var name in names) + if (!ioe.Message.Contains(name, StringComparison.Ordinal)) return $"host refused to start, but not naming {name}: {Cut(ioe.Message)}"; + return null; } /// diff --git a/tools/probe/corpus/harness/HttpBaseline.cs b/tools/probe/corpus/harness/HttpBaseline.cs index 28b89e49..95d1cb78 100644 --- a/tools/probe/corpus/harness/HttpBaseline.cs +++ b/tools/probe/corpus/harness/HttpBaseline.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using System.Text.Json; using System.Text.Json.Nodes; +using CodoMetis.TypeKit.Facts; namespace Probes.Shapes; public static class HttpBaseline @@ -25,26 +26,24 @@ static bool RefusesItsLimit(Entry e) } /// - /// One wire comparison with the twin, inside its check : the wrapped type's answer and the - /// value object's . A difference a document makes intended ends the check as that document's citation; BaseProbe - /// calibrates the host on C07's rows, whose claim promises nothing of the wire, so these cite the sentence itself, not a not-promised - /// entry of C07. + /// One wire comparison with the twin, inside its check : the wrapped type's answer (side (2)) and the + /// value object's (side (3)); side (1) refusing alike was decided before the check (). A difference a + /// document makes intended ends the check as that document's citation; BaseProbe calibrates the host on C07's rows, whose claim promises + /// nothing of the wire, so these cite the sentence itself, not a not-promised entry of C07. /// static string? Same(Check c, Row r, Entry e, string what, Hx.Wire p, Hx.Wire v, string input = "valid") { r.Log.AppendLine($"{what}: plain {(int)p.Status} {Cut(p.Body)} {p.Thrown} | vo {(int)v.Status} {Cut(v.Body)} {v.Thrown}"); bool ok(Hx.Wire w) => (int)w.Status is >= 200 and < 300; - if (!ok(p) && !ok(v)) { c.Note(NoteKeys.Compared, what + ": both refused"); return null; } + // both refused, and not as the twin without TypeKit refuses (Sides.Alike): evidence about refusing, and nothing to compare + if (!ok(p) && !ok(v)) return c.Inconclusive($"{what}: both refuse, not alike: wrapped {p.Side(what)}, value object {v.Side(what)}"); // "Your own converter wins": the application's converter writes the value object instead of the generated one. - if (r.Is("json.Converters", "own-for-value-object") && ok(v) && ok(p)) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), what); + if (r.Is("json.Converters", "own-for-value-object") && ok(v)) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), what); if (ok(p) && !ok(v)) { if (ReadRefusedAsDocumented(c, r, e, input, what)) return null; // the same: the value object's array reads a JSON array of numbers, never the base64 string the twin's byte[] wrote and reads if (ByteArray(r, e)) return c.Inapplicable(Cite.Docs("docs/json.md#the-wrapped-types-json"), what); - // Text-matching site, kept in this step: the depth refusal is told by the message's words. Baseline gap: no twin in the row - // shows System.Text.Json charging a converter a level (the three-sided differential, next step), and no record cites it. - if (r.Is("json.MaxDepth", "3") && (v.Thrown ?? v.Body).Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "value-object side"); return null; } // A closed set travels as its member's name; the wrapped type under the application's converter is no reference. if (e.Kind == "closed-set" && r.Is("json.Converters", "own-for-wrapped")) return c.Inapplicable(Cite.Docs("README.md#what-you-get"), what); // The validated shapes' Create refuses the wrapped type's limit on purpose, so the limit input is a refusal. @@ -56,10 +55,8 @@ static bool RefusesItsLimit(Entry e) // docs/json.md "Null and missing": a struct value object rejects a JSON null; a sealed partial record reads it as null, // like any class, where the struct it wraps refuses it. if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return c.Refused(Cite.Docs("docs/json.md#null-and-missing"), what); - // Text-matching site, kept in this step: as the value-object side's above. Baseline gap: the same. - if (r.Is("json.MaxDepth", "3") && (p.Thrown ?? p.Body).Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "wrapped side"); return null; } + return $"{what}: value object answered {(int)v.Status} {Cut(v.Body)} where wrapped answered {(int)p.Status} {Cut(p.Body)} {p.Thrown}"; } - if (!ok(p) && ok(v)) return $"{what}: value object answered {(int)v.Status} {Cut(v.Body)} where wrapped answered {(int)p.Status} {Cut(p.Body)} {p.Thrown}"; if (p.Body != v.Body) { // A closed set travels as its member's name, never as the string or number standing in for it. @@ -73,6 +70,34 @@ static bool RefusesItsLimit(Entry e) return null; } + /// + /// One check of the wire, three-sided (Sides): side (2) and side (3) answered on the host with + /// TypeKit; side (1) is asked of the host without it (), lazily, only where they disagree or refuse. Where (1) + /// refuses alike with (3), the check is the framework's refusal against Baseline.Framework; where (1) answers and the value object's + /// write was refused under a depth limit, side (1) is a converter's value there (KB-040, in process); otherwise + /// compares, once (2) is held to (1). + /// + static async Task Wire(Row r, Entry e, string obligation, Baseline against, string what, Hx.Wire p, Hx.Wire v, Func> one, Func> body) + { + var two = p.Side(what); var three = v.Side(what); + Sides.Answer? first = null; Sides.Side? framework = null; + if (two.Refused || three.Refused || p.Body != v.Body) + { + if (await one() is { } w) first = w.Side(what); + if (three.Refused && first is { Refused: true } && Sides.Alike(first, three)) framework = Sides.Measured(first, "the twin on a host without TypeKit"); + else if (three.Refused && v.Ex is { } thrown && first is { Refused: false } && (Sides.Why = null) is null + && Sides.WrittenByAConverter(r, e, Twin(r, e), Sides.WithoutTypeKit(JsonRow.Apply(r, e).Options!), new("write", null, thrown)) is { } converter + && Sides.Alike(converter.Answer, new("write", null, thrown))) + return Sides.Check(r, obligation, against, converter, new("write", null, thrown), null, null, _ => (string?)null); + } + var failed = await Sides.Check(r, obligation, against, framework, three, first, first is null ? null : two, body); + // the failure names side (1), for the triage of the row + return failed is null || first is null ? failed : failed + $" [side (1), the twin without TypeKit: {first}{(Sides.Why is { } why ? "; " + why : "")}]"; + } + + /// The wrapped twin's payload, as the echo writes it: the row's position and containing type around the wrapped value. + static Held Twin(Row r, Entry e) => Pos.Build(r["shape.position"], r.Get("shape.containing") ?? "class", e, false, e.Valid()); + /// An array of value objects over byte, whose twin is a byte[] (KB-038). static bool ByteArray(Row r, Entry e) => e.Wrapped == "byte" && r["shape.position"] == "array-element"; @@ -94,11 +119,12 @@ static bool HoldsDefault(Hx.Wire w, string? body) } /// A text the wrapped type does not read, for a twin that shows what MVC does with a binding failure; none where any text binds. - static string? Unreadable(Entry e) => e.Wrapped is "string" or "Uri" or "own-record-class" ? null : "not-a-value"; + static string? Unreadable(Entry e) => e.Wrapped is "string" or "Uri" or "own-record-class" ? null : "abc"; /// - /// The value object refused a read the wrapped type answered, as a document says it does (JsonParity.ReadMayDiffer's reasons, the JSON - /// family's, each cited by its sentence): the check ends as that refusal and true is returned. + /// The value object refused a read the wrapped type answered, as a document says it does (the JSON family's documented differences, each + /// cited by its sentence; JsonParity.ReadMayDiffer listed them uncited): side (1) answered and (3) refused, which the citation makes + /// intended (Sides). The check ends as that refusal and true is returned. /// static bool ReadRefusedAsDocumented(Check c, Row r, Entry e, string input, string what) { @@ -111,30 +137,66 @@ static bool ReadRefusedAsDocumented(Check c, Row r, Entry e, string input, strin /// Where two schemas first differ, and what each side has there: the comparison's 220 characters rarely reach it. static string FirstDifference(JsonNode? p, JsonNode? v, string path) => - Difference(p, v, path) is var (at, wp, wv) ? $"{at}: wrapped {Cut(wp?.ToJsonString() ?? "(absent)")} | value object {Cut(wv?.ToJsonString() ?? "(absent)")}" : ""; + Difference(p, v, path) is var d ? $"{d.Path}: wrapped {Cut(d.P?.ToJsonString() ?? "(absent)")} | value object {Cut(d.V?.ToJsonString() ?? "(absent)")}" : ""; + + /// + /// Where two schemas first differ: the path as it is shown, the keys that lead there (a property's name, an element's index), each + /// side's node there (null where it is absent) and the node holding it, so a difference is told by its structure, never by its path's text. + /// + sealed record Diff(string Path, IReadOnlyList Keys, JsonNode? P, JsonNode? V, JsonNode? ParentP, JsonNode? ParentV); + + static Diff Difference(JsonNode? p, JsonNode? v, string path) => Difference(p, v, path, [], null, null); - /// The path where two schemas first differ, and each side's node there (null where it is absent). - static (string, JsonNode?, JsonNode?) Difference(JsonNode? p, JsonNode? v, string path) + static Diff Difference(JsonNode? p, JsonNode? v, string path, List keys, JsonNode? parentP, JsonNode? parentV) { if (p is JsonObject po && v is JsonObject vo) { foreach (var k in po.Select(kv => kv.Key).Union(vo.Select(kv => kv.Key)).Order(StringComparer.Ordinal)) - if (!JsonNode.DeepEquals(po[k], vo[k])) return Difference(po[k], vo[k], path + "." + k); + if (!JsonNode.DeepEquals(po[k], vo[k])) return Difference(po[k], vo[k], path + "." + k, [.. keys, k], p, v); } else if (p is JsonArray pa && v is JsonArray va && pa.Count == va.Count) { for (var i = 0; i < pa.Count; i++) - if (!JsonNode.DeepEquals(pa[i], va[i])) return Difference(pa[i], va[i], $"{path}[{i}]"); + if (!JsonNode.DeepEquals(pa[i], va[i])) return Difference(pa[i], va[i], $"{path}[{i}]", [.. keys, i.ToString(System.Globalization.CultureInfo.InvariantCulture)], p, v); } - return (path, p, v); + return new(path, keys, p, v, parentP, parentV); + } + + /// The node the keys of a lead to in another schema, or null where it has none. + static JsonNode? At(JsonNode? node, IEnumerable keys) + { + foreach (var k in keys) + node = node switch { JsonObject o => o[k], JsonArray a when int.TryParse(k, out var i) && i < a.Count => a[i], _ => null }; + return node; + } + + /// + /// Side (1) of a row's wire and schema comparisons: the same host started without TypeKit (HostCfg.WithoutTypeKit), once, on the first + /// comparison that asks for it; null where it does not start, which no comparison then excuses anything by. The twin's side (1) is a + /// host with the twin's endpoints alone (plain): ASP.NET alone describing the value object's types beside them names components + /// after them and writes the twin's own schemas otherwise. A host with both (both) answers for the value object's position. + /// + sealed class SideOne(Row r, Entry e, string only) : IAsyncDisposable + { + Task? started; + public string? Down { get; private set; } + public Task Host() => started ??= Start(); + async Task Start() + { + try { return await Hx.Start(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false, WithoutTypeKit = true, PayloadOnly = only }); } + catch (Exception x) { Down = x.GetType().Name + ": " + Cut(x.Message); r.Log.AppendLine("side (1) host did not start: " + Down); return null; } + } + public async Task Ask(Func> ask) => await Host() is { } app ? await ask(app) : null; + public async ValueTask DisposeAsync() { if (started is not null && await started is { } app) await app.DisposeAsync(); } } /// /// BaseProbe's calibration of the shared host: every wire comparison a check of its own, named (BaseProbe /// runs on rows of C07, which promises nothing of the wire, so its checks are the implicit claim). Per side: the echo against the /// twin; the request read with the row's input.json, against the twin, or a closed set against its own response read back; the binding of - /// the row's http.binding with a valid value against the twin, and with a value Create refuses against the documented 400. With - /// , the schema parity too (C21's check). Every check runs; the first failure is returned. + /// the row's http.binding with a valid value against the twin, and with a value Create refuses against the documented 400. Each is + /// three-sided (Sides): side (1), the twin on the same host without TypeKit, is asked where the twin and the value object disagree or + /// refuse. With , the schema parity too (C21's check). Every check runs; the first failure is returned. /// public static async Task Run(Row r, Entry e, bool schema = true, string obligation = "claim") { @@ -142,6 +204,8 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => try { app = await Hx.Start(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }); } catch (Exception ex) { return "baseline host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } await using var _ = app; + await using var one = new SideOne(r, e, "plain"); + await using var alone = new SideOne(r, e, "both"); var jopts = JsonRow.Apply(r, e).Options!; var position = r["shape.position"]; var plainSample = e.Valid(); @@ -156,7 +220,9 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => { // 1. response side var ep = await Hx.Get(app, path(echo + "plain")); var ev = await Hx.Get(app, path(echo + "vo")); - var f1 = r.Check(obligation, Baseline.Twin, c => Same(c, r, e, name + " echo", ep, ev)); failed ??= f1; + var f1 = await Wire(r, e, obligation, Baseline.Twin, name + " echo", ep, ev, () => one.Ask(h => Hx.Get(h, path(echo + "plain"))), + c => Task.FromResult(Same(c, r, e, name + " echo", ep, ev))); + failed ??= f1; // 2. request side, with the row's input.json, where the wrapped type's echo gave a body to change if ((int)ep.Status == 200) { @@ -185,12 +251,13 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => return null; }); else - f2 = await r.Check(obligation, Baseline.Twin, async c => - { - var text = JsonParity.InputFor(input, e, jopts, ep.Body, position); - var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), text); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), text); - return Same(c, r, e, name + " in(" + input + ")", ip, iv, input); - }); + { + var text = JsonParity.InputFor(input, e, jopts, ep.Body, position); + var ip = await Hx.Send(app, HttpMethod.Post, path(inn + "plain"), text); var iv = await Hx.Send(app, HttpMethod.Post, path(inn + "vo"), text); + var what = name + " in(" + input + ")"; + f2 = await Wire(r, e, obligation, Baseline.Twin, what, ip, iv, () => one.Ask(h => Hx.Send(h, HttpMethod.Post, path(inn + "plain"), text)), + c => Task.FromResult(Same(c, r, e, what, ip, iv, input))); + } failed ??= f2; } // 3. binding, the row's http.binding @@ -210,18 +277,18 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => } var valid = Hx.BindText(e, plainSample); var refused = e.Refused() is { } rf ? Hx.BindText(e, rf) : null; - async Task B(string which, string txt, string w) + async Task B(WebApplication host, string which, string txt, string w) { var enc = Uri.EscapeDataString(txt); return bind switch { - "route" => await Hx.Get(app, path(bindPre + "route/" + w + "/" + enc)), - "query" => await Hx.Get(app, path(bindPre + "query/" + w + "?v=" + enc)), - "as-parameters" => await Hx.Get(app, path(bindPre + "params/" + w + "?v=" + enc)), - "header" => await Hx.Send(app, HttpMethod.Get, path(bindPre + "header/" + w), header: ("v", txt)), - "form" => await Hx.Send(app, HttpMethod.Post, path(bindPre + "form/" + w), "v=" + enc, "application/x-www-form-urlencoded"), - _ => await Hx.Send(app, HttpMethod.Post, path(bindPre + "body/" + w), - which == "valid" ? JsonSerializer.Serialize(plainSample, e.Plain, jopts) : JsonSerializer.Serialize(e.Refused(), e.Plain, jopts)), + "route" => await Hx.Get(host, path(bindPre + "route/" + w + "/" + enc)), + "query" => await Hx.Get(host, path(bindPre + "query/" + w + "?v=" + enc)), + "as-parameters" => await Hx.Get(host, path(bindPre + "params/" + w + "?v=" + enc)), + "header" => await Hx.Send(host, HttpMethod.Get, path(bindPre + "header/" + w), header: ("v", txt)), + "form" => await Hx.Send(host, HttpMethod.Post, path(bindPre + "form/" + w), "v=" + enc, "application/x-www-form-urlencoded"), + _ => await Hx.Send(host, HttpMethod.Post, path(bindPre + "body/" + w), + which == "valid" ? JsonSerializer.Serialize(plainSample, e.Plain, jopts) : which == "refused" ? JsonSerializer.Serialize(e.Refused(), e.Plain, jopts) : JsonSerializer.Serialize(txt)), }; } // Both sides are mapped for every binding they reach here: a 404 is the harness's routing, never a binding result. Counted as @@ -233,80 +300,85 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => // bound is not the one sent. A limit of the harness, which no sentence cites: the check cannot tell. bool Unsafe(string txt) => bind == "route" && (txt.Contains('/') || txt.Contains(':') || txt.Contains('?') || txt.Contains('#')); string? Unplaceable(Check c, string txt) => c.Inconclusive($"{name} bind route: the text {txt} cannot be carried by a route segment as it is"); - var f3 = await r.Check(obligation, Baseline.Twin, async c => + string? f3; + if (Unsafe(valid)) f3 = r.Check(obligation, Baseline.Twin, c => Unplaceable(c, valid)); + else { - if (Unsafe(valid)) return Unplaceable(c, valid); - var bp = await B("valid", valid, "plain"); var bv = await B("valid", valid, "vo"); - if (Routed(name, bind, bp, bv) is { } unrouted) return unrouted; + var bp = await B(app, "valid", valid, "plain"); var bv = await B(app, "valid", valid, "vo"); r.Log.AppendLine($"{name} bind {bind} valid text={valid}: plain {(int)bp.Status} {Cut(bp.Body)} {bp.Thrown} | vo {(int)bv.Status} {Cut(bv.Body)} {bv.Thrown}"); - // The twin refuses the valid text too: no binding to compare, and no sentence cites it. - if ((int)bp.Status is not (>= 200 and < 300) && (int)bv.Status is not (>= 200 and < 300)) - return c.Inconclusive($"{name} bind {bind}: the wrapped type refuses the valid text too ({(int)bp.Status}), the value object answered {(int)bv.Status}"); - return Same(c, r, e, name + " bind " + bind, bp, bv); - }); + f3 = Routed(name, bind, bp, bv) is { } unrouted + ? r.Check(obligation, Baseline.Twin, _ => unrouted) + : await Wire(r, e, obligation, Baseline.Twin, name + " bind " + bind, bp, bv, () => one.Ask(h => B(h, "valid", valid, "plain")), + c => Task.FromResult(Same(c, r, e, name + " bind " + bind, bp, bv))); + } failed ??= f3; if (refused is null) continue; // README "Binding" (AspNetCore): a value Create rejects gets a 400, the one set of rules. - var f4 = await r.Check(obligation, Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#binding"), async c => + var against = Baseline.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#binding"); + if (Unsafe(refused)) { failed ??= r.Check(obligation, against, c => Unplaceable(c, refused)); continue; } + var rp = await B(app, "refused", refused, "plain"); var rv = await B(app, "refused", refused, "vo"); + r.Log.AppendLine($"{name} bind {bind} refused text={refused}: plain {(int)rp.Status} {Cut(rp.Body)} {rp.Thrown} | vo {(int)rv.Status} {Cut(rv.Body)} {rv.Thrown}"); + if (Routed(name, bind, rp, rv) is { } unrouted2) { failed ??= r.Check(obligation, against, _ => unrouted2); continue; } + // Side (1) of a refused binding: the twin on the host without TypeKit, refused a binding too, as MVC refuses a text the wrapped + // type cannot read. Where the value object's refusal could not be answered (a 500, which MVC's 400 under a source-generated + // context that does not list ValidationProblemDetails is, KB-041), the twin's refused binding is side (1): read from KB-041's + // fixture where it covers the row, else asked of the host without TypeKit. + Sides.Side? framework = null; + if (rv.Ex is { } refusal && Unreadable(e) is { } unreadable) + { + var settings = Sides.Settings(r, ("endpoint", name == "mvc" ? "mvc-api-controller" : "minimal"), ("binding", "fails")); + if (Sides.Covered(r, "KB-041", settings) is { Kind: FactAnswer.Exception } answer) + framework = new(new(name + " bind " + bind + " refused", null, Status: 500, FixtureRefusal: (string?)answer.Value["type"]), Baseline.Fact("KB-041"), $"KB-041 {answer.Id}: a binding MVC refuses, under a source-generated context"); + else if (await one.Ask(h => B(h, "unreadable", unreadable, "plain")) is { } twin) + framework = Sides.Measured(twin.Side(name + " bind " + bind + " refused"), $"the twin on a host without TypeKit, sent {unreadable}"); + } + var f4 = await Sides.Check(r, obligation, against, framework, rv.Side(name + " bind " + bind + " refused"), null, null, async c => { - if (Unsafe(refused)) return Unplaceable(c, refused); - var bp = await B("refused", refused, "plain"); var bv = await B("refused", refused, "vo"); - if (Routed(name, bind, bp, bv) is { } unrouted) return unrouted; - r.Log.AppendLine($"{name} bind {bind} refused text={refused}: plain {(int)bp.Status} {Cut(bp.Body)} {bp.Thrown} | vo {(int)bv.Status} {Cut(bv.Body)} {bv.Thrown}"); - if ((int)bp.Status is not (>= 200 and < 300)) return c.Inconclusive($"{name} bind {bind}: the wrapped type refuses the value Create refuses too ({(int)bp.Status})"); + if ((int)rp.Status is not (>= 200 and < 300)) return c.Inconclusive($"{name} bind {bind}: the wrapped type refuses the value Create refuses too ({(int)rp.Status})"); if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class") return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "bind"); // README "What TypeKit guarantees": StoredJsonConverterFactory reads JSON the application stored itself, without validation. if (bind == "body" && r.Is("json.Converters", "StoredJsonConverterFactory")) return c.Inapplicable(Cite.Docs("README.md#what-typekit-guarantees"), "bind body"); // KB-037: under MaxValidationDepth=1 MVC runs an action whose parameter it could not bind, with the parameter at its default. The - // value object must hold its default then, never the refused value; the twin, sent a text it cannot read in the same host, shows - // the framework doing the same. - if (name == "mvc" && r.Is("mvc.MaxValidationDepth", "1") && (int)bv.Status is >= 200 and < 300) + // value object must hold its default then, never the refused value; side (1), the twin sent a text it cannot read, shows the + // framework doing the same: read from KB-037's fixture where it covers the row, else asked of the host without TypeKit. + if (name == "mvc" && r.Is("mvc.MaxValidationDepth", "1") && (int)rv.Status is >= 200 and < 300) { - if (DefaultBody(app, e.Vo) is var voDefault && !HoldsDefault(bv, voDefault)) - return $"{name} bind {bind}: validated value object bound the refused value (status {(int)bv.Status}) {Cut(bv.Body)}, not its default ({(voDefault is null ? "204" : "200 " + Cut(voDefault))})"; + if (DefaultBody(app, e.Vo) is var voDefault && !HoldsDefault(rv, voDefault)) + return $"{name} bind {bind}: validated value object bound the refused value (status {(int)rv.Status}) {Cut(rv.Body)}, not its default ({(voDefault is null ? "204" : "200 " + Cut(voDefault))})"; if (Unreadable(e) is not { } text) return c.Inapplicable(Cite.Fact("KB-037"), $"{name} bind {bind}: the value object holds its default; any text binds the wrapped type, so no twin shows it"); - var enc = Uri.EscapeDataString(text); - var bt = bind switch - { - "route" => await Hx.Get(app, path(bindPre + "route/plain/" + enc)), - "query" => await Hx.Get(app, path(bindPre + "query/plain?v=" + enc)), - "as-parameters" => await Hx.Get(app, path(bindPre + "params/plain?v=" + enc)), - "header" => await Hx.Send(app, HttpMethod.Get, path(bindPre + "header/plain"), header: ("v", text)), - "form" => await Hx.Send(app, HttpMethod.Post, path(bindPre + "form/plain"), "v=" + enc, "application/x-www-form-urlencoded"), - _ => await Hx.Send(app, HttpMethod.Post, path(bindPre + "body/plain"), JsonSerializer.Serialize(text)), - }; + Hx.Wire? bt; + if (Sides.Covered(r, "KB-037", Sides.Settings(r, ("text", text))) is { Kind: FactAnswer.Status } answer) + bt = new((HttpStatusCode)(int)answer.Value["status"]!, (string?)answer.Value["body"] ?? ""); + else bt = await one.Ask(h => B(h, "unreadable", text, "plain")); + if (bt is null) return c.Inconclusive($"{name} bind {bind}: the value object holds its default (KB-037); no host without TypeKit to show the wrapped type's ({one.Down})"); r.Log.AppendLine($"{name} bind {bind} unreadable text={text}: plain {(int)bt.Status} {Cut(bt.Body)} {bt.Thrown}"); if (HoldsDefault(bt, DefaultBody(app, e.Plain))) return c.Inapplicable(Cite.Fact("KB-037"), $"{name} bind {bind}: both hold their default, the wrapped type for a text it cannot read"); if ((int)bt.Status is >= 400 and < 500) - return $"{name} bind {bind}: the value object answered {(int)bv.Status} holding its default, where the wrapped type rejects a text it cannot read ({(int)bt.Status})"; + return $"{name} bind {bind}: the value object answered {(int)rv.Status} holding its default, where the wrapped type rejects a text it cannot read ({(int)bt.Status})"; // KB-037's class from the query: the validator throws at the twin's property, and the value object, written by a // converter of its own, is no class it walks return c.Inconclusive($"{name} bind {bind}: the value object holds its default (KB-037); the wrapped type answered {(int)bt.Status} {Cut(bt.Body)} {bt.Thrown}"); } // a refused value must not bind (the one set of rules), except where the application skips validation on purpose - if ((int)bv.Status is >= 200 and < 300) return $"{name} bind {bind}: validated value object bound the refused value (status {(int)bv.Status}) {Cut(bv.Body)}"; - // Text-matching site, kept in this step: MVC writes its 400 as ValidationProblemDetails, which a source-generated context alone - // does not list, so ASP.NET answers 500 for any refused binding under that configuration, the wrapped type's included. Baseline - // gap: the message's words tell it, where a wrapped twin refused in the same row should (the three-sided differential). - if ((int)bv.Status == 500 && name == "mvc" && r.Is("json.TypeInfoResolver", "source-generated-context") - && (bv.Thrown ?? bv.Body ?? "").Contains("ValidationProblemDetails")) { r.Note(NoteKeys.Mvc400UnwritableUnderSourceGen); return null; } - if ((int)bv.Status != 400) return $"{name} bind {bind}: refused value answered {(int)bv.Status}, expected 400: {Cut(bv.Body)} {bv.Thrown}"; + if ((int)rv.Status is >= 200 and < 300) return $"{name} bind {bind}: validated value object bound the refused value (status {(int)rv.Status}) {Cut(rv.Body)}"; + if ((int)rv.Status != 400) return $"{name} bind {bind}: refused value answered {(int)rv.Status}, expected 400: {Cut(rv.Body)} {rv.Thrown}"; c.Note(NoteKeys.Compared, $"{name} bind {bind}: the refused value answered 400"); return null; }); failed ??= f4; } // 4. schema parity - if (schema) { var f5 = await r.Check(obligation, Baseline.Twin, c => SchemaParity(app, r, e, c)); failed ??= f5; } + if (schema) { var f5 = await r.Check(obligation, Baseline.Twin, async c => await SchemaParity(app, one, alone, r, e, c)); failed ??= f5; } return failed; } /// /// The schema parity alone, on the same host as : claim C21's check (C21OpenapiSchemaParityProbe), which BaseProbe leaves - /// out of its calibration, run once for each of the row's against the twin. Every check runs; the first - /// failure is returned. + /// out of its calibration, run once for each of the row's against the twin. C21 promises the schema ASP.NET + /// Core publishes for the underlying type, so side (1), the same host without TypeKit, is started on every row (Sides.Unconditional). + /// Every check runs; the first failure is returned. /// public static async Task Schema(Row r, Entry e, IEnumerable obligations) { @@ -314,10 +386,14 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => try { app = await Hx.Start(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }); } catch (Exception ex) { down = "schema host did not start: " + ex.GetType().Name + ": " + Cut(ex.Message); } await using var _ = app; + await using var one = new SideOne(r, e, "plain"); + await using var alone = new SideOne(r, e, "both"); + var list = obligations.ToList(); + if (down is null && list.Any(o => Sides.Unconditional(r, o))) await one.Host(); string? failed = null; - foreach (var obligation in obligations) + foreach (var obligation in list) { - var f = await r.Check(obligation, Baseline.Twin, c => down is not null ? Task.FromResult(down) : SchemaParity(app!, r, e, c)); + var f = await r.Check(obligation, Baseline.Twin, c => down is not null ? Task.FromResult(down) : SchemaParity(app!, one, alone, r, e, c)); failed ??= f; } return failed; @@ -334,14 +410,6 @@ static string FirstDifference(JsonNode? p, JsonNode? v, string path) => /// admits null is kept; everywhere else, inlined schemas included, the comparison stays byte for byte (rows 170, /// 196 and 215 of C21). Returns both sides without the marks put on resolved references. /// - /// The node at a path names ($.parameters[0].schema), or none. - static JsonNode? At(JsonNode? node, string path) - { - foreach (System.Text.RegularExpressions.Match step in System.Text.RegularExpressions.Regex.Matches(path, @"\.([^.\[]+)|\[(\d+)\]")) - node = step.Groups[1].Success ? (node as JsonObject)?[step.Groups[1].Value] : (node as JsonArray) is { } a && int.Parse(step.Groups[2].Value) is var i && i < a.Count ? a[i] : null; - return node; - } - static (string, string, bool) NullAroundReferences(string sp, string sv) { var around = false; @@ -413,13 +481,47 @@ static bool NullOrReference(JsonNode? n) => return (np?.ToJsonString() ?? "null", nv?.ToJsonString() ?? "null", around); } - static async Task SchemaParity(WebApplication app, Row r, Entry e, Check c) + /// + /// The component two operations of one document both refer to at the same place (a parameter's or the body's schema), where one + /// substitutes the value object for the twin: two types under one name, told by the references' structure. + /// + static string? SharedComponent(JsonNode twin, JsonNode valueObject) + { + static IEnumerable<(string, string)> References(JsonNode op) + { + if (op["parameters"] is JsonArray ps) + for (var i = 0; i < ps.Count; i++) + if (ps[i]?["schema"]?["$ref"]?.GetValue() is { } p) yield return ($"parameter {i}", p); + if (op["requestBody"]?["content"] is JsonObject content) + foreach (var (media, body) in content) + if (body?["schema"]?["$ref"]?.GetValue() is { } b) yield return ($"body {media}", b); + } + var twins = References(twin).ToDictionary(x => x.Item1, x => x.Item2); + return References(valueObject).FirstOrDefault(x => twins.TryGetValue(x.Item1, out var t) && t == x.Item2).Item2; + } + + /// A body whose schema is its null alone, {"schema": {"allOf": [{"x-nullable": true}]}}, anywhere in an operation: told by its structure. + static bool OnlyItsNull(JsonNode? n) => n switch + { + JsonObject o => o["schema"] is JsonObject { Count: 1 } schema && schema["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag + && flag["x-nullable"]?.GetValueKind() == JsonValueKind.True + || o.Any(kv => OnlyItsNull(kv.Value)), + JsonArray a => a.Any(OnlyItsNull), + _ => false, + }; + + /// + /// The value object's schema (3) against the wrapped twin's (2) at each endpoint pair, three-sided (Sides): the twin's schema is held to + /// the one ASP.NET publishes on the same host without TypeKit (1), where that host writes a document; and where the value object's schema + /// differs from the twin's, side (1)'s document at the same path tells whether ASP.NET alone writes that difference. + /// + static async Task SchemaParity(WebApplication app, SideOne one, SideOne alone, Row r, Entry e, Check c) { JsonNode doc; try { doc = (await Hx.Doc(app))!; } catch (Exception ex) { - // The baseline is ASP.NET alone on the same host: a document it refuses too has no schema to compare. + // The baseline is ASP.NET alone on the same host: a document it refuses too has no schema to compare (decision 99). return await Hx.DocRefusedByAspNetAlone(r, e, new HostCfg { FaultControllers = false, MapBlanketMapping = false }, ex); } // docs/json.md "Describing what a converter writes": ASP.NET drops items/additionalProperties of a converter it cannot see into, the value @@ -428,10 +530,16 @@ static bool NullOrReference(JsonNode? n) => // C21 is generated with them; these lines hold for the schema half run under another claim (ShrinkProbe's baseline). if (r.Is("json.Converters", "own-for-wrapped")) return c.Inapplicable(Cite.NotPromised("own-converter-for-the-wrapped-type")); if (r.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.NotPromised("own-converter-for-the-value-object")); - string? Op(string path, string method, out JsonNode? node) + // side (1): the document ASP.NET writes for the twin's endpoints without TypeKit, where it writes one; and, asked only where the value + // object's schema differs from the twin's, the one it writes for both sides' endpoints, for the value object's position + async Task Document(SideOne host) { - node = doc["paths"]?[path]?[method]; return node is null ? "missing " + method + " " + path : null; + if (await host.Host() is not { } h) return null; + try { return await Hx.Doc(h); } + catch (Exception x) { r.Log.AppendLine("side (1) document refused: " + x.GetType().Name + ": " + Cut(x.Message)); return null; } } + var doc1 = await Document(one); + JsonNode? Op(JsonNode d, string path, string method) => d["paths"]?[path]?[method]; // only the schemas are compared (the claim is about the schema); whether a parameter or body is required follows the .NET type's nullability JsonNode? Schemas(JsonNode? n) { @@ -451,12 +559,12 @@ static bool NullOrReference(JsonNode? n) => default: return n?.DeepClone(); } } - string Sig(JsonNode op) + string Sig(JsonNode d, JsonNode op) { var o = new JsonObject(); - o["parameters"] = Schemas(Hx.Deep(doc, op["parameters"], mark: true)); - o["requestBody"] = Schemas(Hx.Deep(doc, op["requestBody"], mark: true)); - o["ok"] = Schemas(Hx.Deep(doc, op["responses"]?["200"], mark: true)); + o["parameters"] = Schemas(Hx.Deep(d, op["parameters"], mark: true)); + o["requestBody"] = Schemas(Hx.Deep(d, op["requestBody"], mark: true)); + o["ok"] = Schemas(Hx.Deep(d, op["responses"]?["200"], mark: true)); return o.ToJsonString(); } var checks = new List<(string, string)>(); @@ -470,7 +578,6 @@ string Sig(JsonNode op) if (Hx.HasMinimal(r)) { var bind = r["http.binding"] switch { "route" => "route", "query" => "query", "header" => "header", "form" => "form", "as-parameters" => "params", _ => "body" }; - var sfx = bind == "route" ? "/{v}" : ""; // route: /bind/route/{which}/{v} checks.Add((bind == "route" ? Hx.M(r, "/bind/route/plain/{v}") : Hx.M(r, $"/bind/{bind}/plain"), bind == "route" ? Hx.M(r, "/bind/route/vo/{v}") : Hx.M(r, $"/bind/{bind}/vo"))); } @@ -494,14 +601,25 @@ string Inputs(JsonNode op) var twinned = pp0.Contains("/bind/") || pp0.Contains("/schema/") && r["shape.position"] == "root"; var pp = e.Vo.IsValueType != e.Plain.IsValueType && Hx.PlainNull(r, e) is not null && twinned ? pp0.Replace("/plain", "/plainnull") : pp0; if (pp != pp0) c.Note(NoteKeys.SchemaComparedWithNullableWrapped); - if (Op(pp, method, out var po) is { } m1) + var po = Op(doc, pp, method); var vo = Op(doc, vp, method); + if (po is null) { // Mapped for bodies only (a record class, Instant in a minimal API): neither side is there. - if (Op(vp, method, out _) is null) return $"harness: the value object's {method} {vp} is documented, its plain twin is not ({m1})"; - c.Note(NoteKeys.SchemaNeitherSideDocumented, m1); continue; + if (vo is not null) return $"harness: the value object's {method} {vp} is documented, its plain twin is not (missing {method} {pp})"; + c.Note(NoteKeys.SchemaNeitherSideDocumented, $"missing {method} {pp}"); continue; } - if (Op(vp, method, out var vo) is { } m2) return m2; - if (Inputs(po!) is var ip && Inputs(vo!) is var iv && ip != iv) + if (vo is null) return $"missing {method} {vp}"; + // The twin's and the value object's shapes are two .NET types; one component name for both (an id the row's CreateSchemaReferenceId + // makes alike for both, such as an array of a generic type) holds one schema for the two, so the document cannot tell them apart. + if (SharedComponent(po, vo) is { } shared) + { c.Inconclusive($"{pp} vs {vp}: the twin's and the value object's shapes are one component, {shared}, in the document: nothing to compare"); continue; } + // (2) held to (1): the twin's schema is the one ASP.NET publishes on the same host without TypeKit, but where the twin itself + // holds one of TypeKit's types (a Patch), which no host without TypeKit describes + var po1 = doc1 is null ? null : Op(doc1, pp, method); + var twinHoldsTypeKit = pp.Contains("/schema/") && Sides.HoldsTypeKit(Twin(r, e).Declared); + if (po1 is not null && !twinHoldsTypeKit && Sig(doc1!, po1) is var s1 && Sig(doc, po) is var s2 && s1 != s2) + return $"schema of the wrapped type at {pp}: TypeKit changed the wrapped type's schema, at {FirstDifference(JsonNode.Parse(s1), JsonNode.Parse(s2), "$")} (without TypeKit, then with it)"; + if (Inputs(po) is var ip && Inputs(vo) is var iv && ip != iv) { // No twin of the wrapped type at the value object's nullability here (a struct value object over a class, or under a // source-generated context an enum or Instant, whose Nullable the probes' context does not list): the two sides are not @@ -511,7 +629,7 @@ string Inputs(JsonNode op) else r.Outcome("harness-error", $"inputs not equivalent for {pp} vs {vp}: wrapped [{ip}] vs value object [{iv}]"); continue; } - var sp = Sig(po!); var sv = Sig(vo!); var written = sp; + var sp = Sig(doc, po); var sv = Sig(doc, vo); if (NullAroundReferences(sp, sv) is var (np, nv, around)) { sp = np; sv = nv; if (around) c.Note(NoteKeys.SchemaNullAroundReferenceNormalised); } if (sp != sv && pp != pp0) { @@ -529,34 +647,31 @@ string Inputs(JsonNode op) if (sp != sv) { if (e.Kind == "closed-set") { c.Inapplicable(Cite.NotPromised("a-closed-set-is-a-string-enum"), "schema"); continue; } - var (at, wp, wv) = Difference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$"); + var d = Difference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$"); var openApi2 = r.Is("openapi.OpenApiVersion", "OpenApi2_0"); + // Side (1) at the same path: the twin's and the value object's operations in the document ASP.NET writes without TypeKit. + var twin1 = po1 is null ? null : JsonNode.Parse(Sig(doc1!, po1)); + var both1 = openApi2 && d.P is null && d.V is not null ? await Document(alone) : null; + var vo1 = both1 is null ? null : Op(both1, vp, method) is { } o1 ? JsonNode.Parse(Sig(both1, o1)) : null; + var vo3 = JsonNode.Parse(Sig(doc, vo)); // KB-026: OpenAPI 2.0 writes a nullable property of any component as allOf [{x-nullable: true}], without the reference, and a - // value object keeps a component of its own (not-promised openapi-2-nullable-component-loses-its-type; row 38 of C21). - // Text-matching site, kept in this step: the difference is told by its path's suffix and the value object's side spelled out. - // Baseline gap: no plain nullable component in the same document shows ASP.NET losing its reference too (the three-sided - // differential, next step). - if (openApi2 && !r.Is("openapi.CreateSchemaReferenceId", "null-inlines-all") && at.EndsWith(".allOf", StringComparison.Ordinal) - && wp is null && wv?.ToJsonString() == """[{"x-nullable":true}]""") - { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), at); continue; } + // value object keeps a component of its own (not-promised openapi-2-nullable-component-loses-its-type; row 38 of C21). Side (1) + // shows it: without TypeKit, ASP.NET writes the value object's position at the same path the same, where the twin has nothing. + if (openApi2 && d.P is null && d.V is not null && At(vo1, d.Keys) is { } same1 && JsonNode.DeepEquals(same1, At(vo3, d.Keys))) + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), d.Path); continue; } // KB-001: OpenAPI 2.0 writes a nullable body of the wrapped value type as allOf [{x-nullable: true}] alone, where a class value - // object's body in a minimal API is not nullable and keeps its schema (not-promised openapi-2-nullable-body-keeps-only-its-null; row 257). - // Text-matching site, kept in this step: the wrapped side's document is searched for the spelling. Baseline gap: the twin's - // nullable body is not compared with a plain nullable body ASP.NET writes alone (the three-sided differential). - if (openApi2 && !e.Vo.IsValueType && Nullable.GetUnderlyingType(e.Plain) is not null - && written.Contains(""" - "schema":{"allOf":[{"x-nullable":true}]} - """, StringComparison.Ordinal)) + // object's body in a minimal API is not nullable and keeps its schema (not-promised openapi-2-nullable-body-keeps-only-its-null; + // row 257). Side (1) shows it: without TypeKit, the twin's schema there is that null alone. + if (openApi2 && !e.Vo.IsValueType && Nullable.GetUnderlyingType(e.Plain) is not null && OnlyItsNull(twin1) && OnlyItsNull(JsonNode.Parse(Sig(doc, po)))) { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-body-keeps-only-its-null"), pp); continue; } // KB-027: the component ASP.NET makes for an enum written by name is the schema of the first use it meets, so a wrapped E? // parameter lists null through E's component where E was first met nullable; the value object's parameter is the enum's - // content without it wherever the enum is a component (not-promised enum-component-is-its-first-use; row 285 of C21). - // Text-matching site, kept in this step: told by the path's prefix and suffix. Baseline gap: the enum's first use is not - // replayed in a document of its own to show the component it makes (the three-sided differential). - if (at.StartsWith("$.parameters", StringComparison.Ordinal) && at.EndsWith(".enum", StringComparison.Ordinal) - && wp is JsonArray we && wv is JsonArray ve && we.Count == ve.Count + 1 && we[^1] is null + // content without it wherever the enum is a component (not-promised enum-component-is-its-first-use; row 285 of C21). Side (1) + // shows it: without TypeKit, the twin's parameter lists the same null, through the same resolved component. + if (d.Keys is ["parameters", .., "enum"] && d.P is JsonArray we && d.V is JsonArray ve && we.Count == ve.Count + 1 && we[^1] is null && JsonNode.DeepEquals(new JsonArray(we.Take(ve.Count).Select(x => x?.DeepClone()).ToArray()), ve) - && At(JsonNode.Parse(written), at[..^".enum".Length]) is JsonObject held && held.ContainsKey(Hx.ResolvedReference)) + && At(JsonNode.Parse(Sig(doc, po)), d.Keys.SkipLast(1)) is JsonObject held && held.ContainsKey(Hx.ResolvedReference) + && At(twin1, d.Keys) is { } listed1 && JsonNode.DeepEquals(listed1, d.P)) { c.Inapplicable(Cite.NotPromised("enum-component-is-its-first-use"), vp); continue; } return $"schema differs for {pp} vs {vp} at {FirstDifference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$")}:\n wrapped {Cut(sp)}\n value object {Cut(sv)}"; } diff --git a/tools/probe/corpus/harness/HttpHost.cs b/tools/probe/corpus/harness/HttpHost.cs index d0b7d8ec..84ec38c4 100644 --- a/tools/probe/corpus/harness/HttpHost.cs +++ b/tools/probe/corpus/harness/HttpHost.cs @@ -128,9 +128,14 @@ public sealed class HostCfg public Type[]? Controllers; // null: the fault controllers of the host public bool FaultControllers = true; public bool Payload = true; // payload echo / in / binding / schema endpoints - public string PayloadOnly = "both"; // "both", "vo" or "plain" (to isolate a document refusal) + public string PayloadOnly = "both"; // "both", "vo" or "plain": the endpoints of one side only, minimal and MVC (side (1)'s twin alone, a document refusal isolated) public bool MapBlanketMapping = true; public bool TypeKitOpenApi = true; // false: the document as ASP.NET alone writes it, the baseline of a refused one + /// + /// The three-sided differential's side (1) (Sides): no AddTypeKit(), none of TypeKit's JSON converters on either set of options, and no + /// fault responses; the wrapped twin's endpoints answer as ASP.NET and System.Text.Json have them without TypeKit. + /// + public bool WithoutTypeKit; public HostCfg Copy() => (HostCfg)MemberwiseClone(); } @@ -197,11 +202,11 @@ public static async Task Start(Row r, Entry e, HostCfg c) Pos.Build(r["shape.position"], (r.Get("shape.containing") ?? "class"), e, true, vo), Pos.Build(r["shape.position"], (r.Get("shape.containing") ?? "class"), e, false, plain)); builder.Services.AddSingleton(payload); - builder.Services.ConfigureHttpJsonOptions(o => JsonRow.Apply(r, e, o.SerializerOptions)); - builder.Services.AddOpenApi(x => { ApplyOpenApi(x, r); if (c.TypeKitOpenApi) x.AddTypeKit(); }); - builder.Services.Configure(x => x.QualifiedFaultNames = c.Qualified); + builder.Services.ConfigureHttpJsonOptions(o => { JsonRow.Apply(r, e, o.SerializerOptions); if (c.WithoutTypeKit) Sides.DropTypeKit(o.SerializerOptions); }); + builder.Services.AddOpenApi(x => { ApplyOpenApi(x, r); if (c.TypeKitOpenApi && !c.WithoutTypeKit) x.AddTypeKit(); }); + if (!c.WithoutTypeKit) builder.Services.Configure(x => x.QualifiedFaultNames = c.Qualified); c.Services?.Invoke(builder.Services); - if (c.MapBlanketMapping) + if (c.MapBlanketMapping && !c.WithoutTypeKit) { builder.Services.AddFaultResponses(f => f switch { SFault.First => 409, SFault.Second => 410, _ => throw new ArgumentOutOfRangeException(nameof(f)) }); if (c.Mapping == "by-kind") builder.Services.AddFaultResponses(); @@ -216,9 +221,9 @@ public static async Task Start(Row r, Entry e, HostCfg c) if (c.Payload) { controllers.Add(typeof(MvcPayloadController)); - controllers.Add(typeof(MvcBind<>).MakeGenericType(e.Vo)); - controllers.Add(typeof(MvcBind<>).MakeGenericType(e.Plain)); - if (Hx.PlainNull(r, e) is { } pn) controllers.Add(typeof(MvcBind<>).MakeGenericType(pn)); + if (c.PayloadOnly != "plain") controllers.Add(typeof(MvcBind<>).MakeGenericType(e.Vo)); + if (c.PayloadOnly != "vo") controllers.Add(typeof(MvcBind<>).MakeGenericType(e.Plain)); + if (c.PayloadOnly != "vo" && Hx.PlainNull(r, e) is { } pn) controllers.Add(typeof(MvcBind<>).MakeGenericType(pn)); } var pfx = ""; builder.Services.AddControllers(o => @@ -227,7 +232,7 @@ public static async Task Start(Row r, Entry e, HostCfg c) if (r["http.host"] == "controller-without-ApiController-visible") o.Conventions.Add(new VisibleToApiExplorer()); ApplyMvc(o, r); }) - .AddJsonOptions(o => JsonRow.Apply(r, e, o.JsonSerializerOptions)) + .AddJsonOptions(o => { JsonRow.Apply(r, e, o.JsonSerializerOptions); if (c.WithoutTypeKit) Sides.DropTypeKit(o.JsonSerializerOptions); }) .ConfigureApplicationPartManager(p => { p.ApplicationParts.Clear(); p.FeatureProviders.Add(new OnlyControllers([.. controllers])); }); } var app = builder.Build(); @@ -303,7 +308,12 @@ static void MapSchema(IEndpointRouteBuilder b, string which) => b.MapPost($"/schema/{which}", ([FromBody] T body) => Results.Ok(body)); // ---- client side - public sealed record Wire(HttpStatusCode Status, string Body, string? Thrown = null); + /// An answer on the wire: its status and body, or the exception the server threw (, a 500), named in . + public sealed record Wire(HttpStatusCode Status, string Body, string? Thrown = null, Exception? Ex = null) + { + /// The answer as the three-sided differential compares it (Sides): the status, the body, and the exception's type and stage. + public Sides.Answer Side(string operation) => new(operation, Ex is null ? Body : null, Ex, (int)Status); + } public static async Task Send(WebApplication app, HttpMethod m, string path, string? body = null, string? contentType = null, (string, string)? header = null) { var req = new HttpRequestMessage(m, path); @@ -315,7 +325,7 @@ public static async Task Send(WebApplication app, HttpMethod m, string pat var resp = await app.GetTestClient().SendAsync(req); return new(resp.StatusCode, await resp.Content.ReadAsStringAsync()); } - catch (Exception ex) { return new(HttpStatusCode.InternalServerError, "", ex.GetType().Name + ": " + ex.Message); } + catch (Exception ex) { return new(HttpStatusCode.InternalServerError, "", ex.GetType().Name + ": " + ex.Message, ex); } } public static Task Get(WebApplication app, string path) => Send(app, HttpMethod.Get, path); public static async Task Doc(WebApplication app) => JsonNode.Parse(await app.GetTestClient().GetStringAsync("/openapi/v1.json")); diff --git a/tools/probe/corpus/harness/JsonFamily.cs b/tools/probe/corpus/harness/JsonFamily.cs index 515d8b11..bda456e5 100644 --- a/tools/probe/corpus/harness/JsonFamily.cs +++ b/tools/probe/corpus/harness/JsonFamily.cs @@ -56,10 +56,6 @@ public static string Rewrite(string json, IEnumerable at, Func s is null ? "(null)" : s.Length > 220 ? s[..220] + "..." : s; public static string Name(Type t) => t.IsGenericType ? t.Name[..t.Name.IndexOf('`')] + "<" + string.Join(", ", t.GetGenericArguments().Select(Name)) + ">" : t.Name; - // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in the - // row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). - /// The serializer's depth limit under json.MaxDepth=3, which any converter is charged a level against (design note D1). - public static bool Depth(Row r, Exception? x) => x is JsonException && x.Message.Contains("maximum allowed depth") && r.Is("json.MaxDepth", "3"); /// A refusal's message with the type the other side holds named instead: the serializer names the shape it refused. public static string Swap(string message, Type from, Type to) => message.Replace(from.ToString(), to.ToString()); /// What a containing type holds: its property P or its field F. diff --git a/tools/probe/corpus/harness/OffTheWire.cs b/tools/probe/corpus/harness/OffTheWire.cs index 75d25c6b..a65c3299 100644 --- a/tools/probe/corpus/harness/OffTheWire.cs +++ b/tools/probe/corpus/harness/OffTheWire.cs @@ -62,9 +62,9 @@ public static class OffTheWire if (nullableUnderContext) return x is InvalidOperationException ? null : $"{dir}: a source-generated context refuses an {name}? property with the serializer's InvalidOperationException; got {(x is null ? "no exception" : Jx.Ex(x))}"; if (x is not NotSupportedException) return $"{dir}: expected NotSupportedException naming {name}, got {(x is null ? "no exception" : Jx.Ex(x))}"; - // Text-matching site, kept in this step (WS2 step 4): the refusal is told TypeKit's by its words ("not a wire type"). Baseline - // gap: a NotSupportedException of the serializer's own is the same type, and no twin in the row refuses as the serializer alone does. - if (!x.Message.Contains(name) || !x.Message.Contains("not a wire type")) return $"{dir}: the NotSupportedException does not name {name} as no wire type: {Jx.Cut(x.Message)}"; + // TypeKit's refusal, told by its type and never by its words: the serializer passes a converter's NotSupportedException on as the + // inner exception of its own, so one in the chain is thrown by NotWireTypeJsonConverterFactory's converter, by nameof + if (!ThrownBy(x, typeof(NotWireTypeJsonConverterFactory))) return $"{dir}: the NotSupportedException is not {nameof(NotWireTypeJsonConverterFactory)}'s: {Jx.Ex(x)}"; return null; } @@ -92,29 +92,37 @@ public static class OffTheWire c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: write refused"); return null; }); - var fr = await r.Check("never-read", refused, async c => + // reading: the value object's document with the row's input, where the Option or Result stands; measured before the check, so that + // a document the serializer refuses before the member (side (1), Sides) decides the check's baseline + var full = Jx.Full(o); + var (jf, xf) = Jx.Ser(hv, full); + // does the application's converter get called there? Its own document, read under the same options, says so + var (jOwnFull, _) = Jx.Ser(ho, Jx.Full(oOwn)); + string input = r["input.json"], text = ""; + if (xf is null) text = JsonParity.InputFor(input, e, full, jf!, position); + else if (jOwnFull is not null) { input = "valid"; text = jOwnFull; } + var reads = xOwn is null && (xf is null || jOwnFull is not null); + var (_, xOwnRead) = reads ? Jx.De(jOwnFull ?? "null", ho.Declared, oOwn) : (null, null); + var memberRead = reads && OwnReads() > 0; + var (vr, xr) = reads ? Jx.De(text, ho.Declared, o) : (null, null); + // side (1): the same document with the value object where the Option or Result stands, read under the same options; refusing alike + // (the same exception type at the same path, no message compared), the serializer refused the document before the member + var three = new Sides.Answer("read", null, xr); + var one = new Sides.Answer("read", null, reads && xr is not null && !ThrownBy(xr, typeof(NotWireTypeJsonConverterFactory)) ? Jx.De(text, hv.Declared, o).Ex : null); + var framework = Sides.Alike(one, three) ? Sides.Measured(one, "the document with the value object where the Option or Result stands") : null; + var fr = await Sides.Check(r, "never-read", refused, framework, three, null, null, async c => { await Task.CompletedTask; if (xOwn is not null) return c.Inconclusive("read: the options refuse the shape itself, so no document has the member: " + Jx.Ex(xOwn)); - // reading: the value object's document with the row's input, where the Option or Result stands - var full = Jx.Full(o); - var (jf, xf) = Jx.Ser(hv, full); - // does the application's converter get called there? Its own document, read under the same options, says so - var (jOwnFull, _) = Jx.Ser(ho, Jx.Full(oOwn)); - string input, text; - if (xf is null) { input = c["input.json"]; text = JsonParity.InputFor(input, e, full, jf!, position); } - else + _ = c["input.json"]; + if (xf is not null) { // the value object's own shape cannot be written here (a value object over a class under Preserve, a class as a key): the // document with the application's converter has a token where the Option or Result stands, and is read as it is if (jOwnFull is null) return c.Inconclusive("read: no document has the member: " + Jx.Ex(xf)); - _ = c["input.json"]; c.Substituted("input.json", "valid", "the value object's document cannot be written: " + xf.GetType().Name); - input = "valid"; text = jOwnFull; + c.Substituted("input.json", "valid", "the value object's document cannot be written: " + xf.GetType().Name); } - var (_, xOwnRead) = Jx.De(jOwnFull ?? "null", ho.Declared, oOwn); if (xOwnRead is not null) return $"read: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwnRead)}"; - var memberRead = OwnReads() > 0; - var (vr, xr) = Jx.De(text, ho.Declared, o); if (xr is null && input == "null" && position == "in-patch" && Jx.Member(ho.Declared, vr) is { } patch && patch.GetType().GetProperty("State")!.GetValue(patch)!.ToString() == "Null") // a JSON null is the patch's own null (docs/merge-patches.md): it holds no Option or Result, and none was read return c.Inapplicable(Cite.Docs("docs/merge-patches.md#tell-absent-null-and-a-value-apart"), $"read null: the Patch<{name}> is null, nothing of {name} read"); @@ -124,24 +132,25 @@ public static class OffTheWire c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); return c.Inapplicable(Cite.NotPromised("a-missing-property-reaches-no-converter"), "read " + input); } - if (Refused(xr, "read " + input) is { } f) - { - // the serializer may refuse the document before the member: the value object's shape refuses it with the very same exception - var (_, xvr) = Jx.De(text, hv.Declared, o); - if (xvr is not null && xvr.GetType() == xr.GetType() && xvr.Message == Jx.Swap(Jx.Swap(xr.Message, ho.Declared, hv.Declared), orType, v)) - { - // the framework's refusal, shown by the value object's own shape in the same row: a pass, as decision 99's document is - c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(xr)); - return null; - } - return f + $"\n text={Jx.Cut(text)}"; - } + if (Refused(xr, "read " + input) is { } f) return f + $"\n text={Jx.Cut(text)}"; c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: read {input} refused"); return null; }); return fw ?? fr; } + /// + /// Whether a refusal, or one in its chain of inner exceptions, was thrown by a converter that declares: the + /// method that threw it (its TargetSite) belongs to the factory's type or to a type nested in it. Told by types, never by a message. + /// + static bool ThrownBy(Exception? x, Type factory) + { + for (; x is not null; x = x.InnerException) + for (var t = x.TargetSite?.DeclaringType; t is not null; t = t.DeclaringType) + if (t == factory) return true; + return false; + } + static async Task<(Type, object)> Produce(string shape, string op, V v) where V : notnull { switch (shape) diff --git a/tools/probe/corpus/harness/Positions.cs b/tools/probe/corpus/harness/Positions.cs index d3a94d63..118560fb 100644 --- a/tools/probe/corpus/harness/Positions.cs +++ b/tools/probe/corpus/harness/Positions.cs @@ -176,24 +176,15 @@ static string Input(string input, Entry e, JsonSerializerOptions o, string jp, s } } - /// True when the value object may legitimately refuse (or differ on) an input the wrapped type reads: the documented differences. - public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) - { - if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && xv is NotSupportedException) return true; - if (Validated(e) && input == "refused-by-create" && r.Get("json.Converters") != "StoredJsonConverterFactory") return true; - if (input == "null" && e.Vo.IsValueType) return true; - if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return true; - if (e.Kind == "closed-set" && input != "valid") return true; - return false; - } static bool Validated(Entry e) => e.Kind.StartsWith("validated"); /// - /// The oracle "JSON parity" (differential) as L01's checks: the value object's write against the wrapped twin in the same shape - /// under the same options (the-underlying-types-json, or as-a-dictionary-key-too where the value object is a dictionary key), and - /// the read of the twin's document with the row's input.json against the twin's read (read-as-the-underlying-type-reads). Under an - /// application's converter for the value object, which writes it instead of the generated one, the read is that converter's own - /// document read back. Every check runs; returns the first failure. + /// The oracle "JSON parity" (differential) as L01's checks, three-sided (Sides): the value object's write (3) against the wrapped twin in + /// the same shape under the same options (2), held to the twin under the application's options less TypeKit's converters (1), as + /// the-underlying-types-json, or as-a-dictionary-key-too where the value object is a dictionary key; and the read of the twin's document + /// with the row's input.json, read-as-the-underlying-type-reads, alike. L01 promises the underlying type's JSON, so side (1) runs on every + /// row. Under an application's converter for the value object, which writes it instead of the generated one, the read is that + /// converter's own document read back. Every check runs; returns the first failure. /// public static string? Check(Row r, Entry e, JsonSerializerOptions o, string position, string containing, string input, JsonSerializerOptions? oPlain = null) { @@ -205,7 +196,8 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) // Input equivalence: the two sides may differ only by the substituted type, not in shape or options. if (Equivalence.Shapes(hp.Declared, hv.Declared, e) is { } whyShape) { r.Outcome("harness-error", "inputs not equivalent: " + whyShape); return null; } if (Equivalence.Options(oPlain, o, allowConverters: e.Kind == "closed-set") is { } whyOptions) { r.Outcome("harness-error", "inputs not equivalent: " + whyOptions); return null; } - var (jp, ep) = Ser(hp, oPlain); var (jv, ev) = Ser(hv, o); + var one = Sides.WithoutTypeKit(oPlain); + var w1 = Sides.Write(hp, one); var w2 = Sides.Write(hp, oPlain); var w3 = Sides.Write(hv, o); var write = position == "dictionary-key" ? "as-a-dictionary-key-too" : "the-underlying-types-json"; const string read = "read-as-the-underlying-type-reads"; string? fw, fr; @@ -214,58 +206,58 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) // "Your own converter wins" (AspNetCore README#what-the-document-says): the application's converter writes the value object // instead of the generated one, so the twin's bytes are none of its. claims.json declares the write inapplicable there. fw = r.Check(write, Baseline.Twin, c => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "write")); - fr = r.Check(read, Baseline.RoundTrip, c => OwnVoRead(c, e, hp, hv, oPlain, o, jv, ev, ep)); + fr = r.Check(read, Baseline.RoundTrip, c => Sides.Interferes(w1, w2) ?? OwnVoRead(c, e, hp, hv, oPlain, o, w3.Value, w3.Thrown, w2.Thrown)); return fw ?? fr; } - fw = r.Check(write, Baseline.Twin, c => Write(c, e, position, jp, ep, jv, ev)); - fr = r.Check(read, Baseline.Twin, c => Read(c, e, position, input, hp, hv, oPlain, o, jp)); + fw = Sides.Check(r, write, Baseline.Twin, Sides.Framework(r, e, hp, one, w1, w3), w3, w1, w2, c => Write(c, e, position, w2, w3)); + if (e.Wrapped == "byte" && position == "array-element") + fr = r.Check(read, Baseline.Twin, c => c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "read")); + else if (e.Kind == "closed-set" && position == "dictionary-key" && r.Get("json.DictionaryKeyPolicy") is not (null or "default")) + fr = r.Check(read, Baseline.Twin, c => c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "read")); + else if (w2.Value is not { } jp) + fr = r.Check(read, Baseline.Twin, c => c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read")); + else + { + var text = Input(input, e, oPlain, jp, position); + var r1 = Sides.Read(text, hp, one); var r2 = Sides.Read(text, hp, oPlain); var r3 = Sides.Read(text, hv, o); + fr = Sides.Check(r, read, Baseline.Twin, Sides.Framework(r, e, hp, one, r1, r3), r3, r1, r2, c => Read(c, e, position, input, text, jp, r2, r3)); + } return fw ?? fr; } - /// The value object's document is the wrapped twin's, byte for byte, in the same shape under the same options. - static string? Write(Check c, Entry e, string position, string? jp, Exception? ep, string? jv, Exception? ev) + /// The value object's document (3) is the wrapped twin's (2), byte for byte, in the same shape under the same options; (1) and (3) refusing alike is decided before (Sides). + static string? Write(Check c, Entry e, string position, Sides.Answer p, Sides.Answer v) { // System.Text.Json writes byte[] as base64 and an array of value objects over byte as a JSON array (KB-038); claims.json declares it - if (e.Wrapped == "byte" && position == "array-element" && jp != jv) return c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "write"); - if (ep is not null && ev is not null) - // Both refused: evidence about refusing, not about serializing, and nothing to compare. - return c.Inconclusive(ep.GetType() != ev.GetType() - ? $"write: both refuse, differently: the wrapped type {ep.GetType().Name}, the value object {ev.GetType().Name}" - : $"write: the options refuse the shape for the wrapped type too: {ep.GetType().Name}: {Cut(ep.Message)}"); - if (ep is null && ev is not null) + if (e.Wrapped == "byte" && position == "array-element" && p.Value != v.Value) return c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "write"); + if (p.Refused && v.Refused) + // Both refused, not alike (Sides.Alike): evidence about refusing, not about serializing, and nothing to compare. + return c.Inconclusive($"write: both refuse, not alike: the wrapped type {p}, the value object {v}"); + if (!p.Refused && v.Refused) { - // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in - // the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). - if (c.Is("json.MaxDepth", "3") && ev is JsonException && ev.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "charges custom converters"); return null; } // inherent to STJ: any custom converter costs a level (design note D1) - if (c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && ev is NotSupportedException) return c.Refused(Cite.NotPromised("preserved-references-over-a-walked-class"), "write"); - return $"wrapped serialized ({jp}) but the value object threw {ev.GetType().Name}: {ev.Message}"; + if (c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && v.Thrown is NotSupportedException) return c.Refused(Cite.NotPromised("preserved-references-over-a-walked-class"), "write"); + return $"wrapped serialized ({p.Value}) but the value object threw {v.Thrown!.GetType().Name}: {v.Thrown.Message}"; } - if (ep is not null && ev is null) return $"value object serialized ({jv}) where the wrapped type threw {ep.GetType().Name}: {ep.Message}"; + if (p.Refused) return $"value object serialized ({v.Value}) where the wrapped type threw {p.Thrown!.GetType().Name}: {p.Thrown.Message}"; // a closed set travels as its name, which no key policy renames; claims.json declares it - if (jp != jv && e.Kind == "closed-set" && position == "dictionary-key" && c.Get("json.DictionaryKeyPolicy") is not (null or "default")) return c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "write"); - if (jp != jv) return $"JSON differs: wrapped {Cut(jp)} vs value object {Cut(jv)}"; + if (p.Value != v.Value && e.Kind == "closed-set" && position == "dictionary-key" && c.Get("json.DictionaryKeyPolicy") is not (null or "default")) return c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "write"); + if (p.Value != v.Value) return $"JSON differs: wrapped {Cut(p.Value)} vs value object {Cut(v.Value)}"; c.Note(NoteKeys.Compared, "write"); return null; } - /// The wrapped twin's document, with the row's input.json, read into both shapes: each reads it as the other does, or refuses as a document says. - static string? Read(Check c, Entry e, string position, string input, Held hp, Held hv, JsonSerializerOptions oPlain, JsonSerializerOptions o, string? jp) + /// + /// The wrapped twin's document, with the row's input.json, read into both shapes (and what each read written again): each reads it as the + /// other does, or refuses as a document says; (1) and (3) refusing alike is decided before (Sides). + /// + static string? Read(Check c, Entry e, string position, string input, string text, string jp, Sides.Answer p, Sides.Answer v) { - if (e.Wrapped == "byte" && position == "array-element") return c.Inapplicable(Cite.NotPromised("a-byte-array-is-base64"), "read"); - if (e.Kind == "closed-set" && position == "dictionary-key" && c.Get("json.DictionaryKeyPolicy") is not (null or "default")) return c.Inapplicable(Cite.NotPromised("a-closed-set-key-is-its-name"), "read"); - if (jp is null) return c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read"); - var text = Input(input, e, oPlain, jp, position); - var (dp, xp) = De(text, hp, oPlain); var (dv, xv) = De(text, hv, o); var stored = c.Get("json.Converters") == "StoredJsonConverterFactory"; - if (xp is not null && xv is not null) - return c.Inconclusive(xp.GetType() != xv.GetType() && !(xp is JsonException && xv is JsonException) - ? $"read {input}: both refuse, differently: the wrapped type {xp.GetType().Name}, the value object {xv.GetType().Name}" - : $"read {input}: both refuse: {xp.GetType().Name}"); - if (xp is null && xv is not null) + if (p.Refused && v.Refused) return c.Inconclusive($"read {input}: both refuse, not alike: the wrapped type {p}, the value object {v}"); + if (!p.Refused && v.Refused) { + var xv = v.Thrown!; if (c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && xv is NotSupportedException) return c.Refused(Cite.NotPromised("preserved-references-over-a-walked-class"), "read"); - // The text-matching site of the write, at the read: the depth refusal told by its words, no twin in the row showing it. - if (c.Is("json.MaxDepth", "3") && xv is JsonException && xv.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "read: charges custom converters"); return null; } // The documented refusals, each the JsonException the generated converter throws. if (!(xv is JsonException) && ((Validated(e) && input == "refused-by-create" && !stored) || (input == "null" && e.Vo.IsValueType) || (e.Kind == "closed-set" && input != "valid"))) return $"input {input}: the refusal is {xv.GetType().Name}, not JsonException: {Cut(xv.Message)}"; @@ -275,11 +267,11 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) return $"input {input}: wrapped read it, value object threw {xv.GetType().Name}: {Cut(xv.Message)}\n text={Cut(text)}"; } // a reference-type value object takes JSON null as null, as any class does - if (xp is not null && xv is null && input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return c.Inapplicable(Cite.Docs("docs/json.md#null-and-missing"), "read null"); - if (xp is not null && xv is null) return $"input {input}: value object read what the wrapped type refused ({xp.GetType().Name}: {Cut(xp.Message)})\n text={Cut(text)}"; + if (p.Refused && input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return c.Inapplicable(Cite.Docs("docs/json.md#null-and-missing"), "read null"); + if (p.Refused) return $"input {input}: value object read what the wrapped type refused ({p.Thrown?.GetType().Name}: {Cut(p.Thrown?.Message)})\n text={Cut(text)}"; if (Validated(e) && input == "refused-by-create" && text != jp && !stored) return $"validated value object accepted the refused value{(position == "dictionary-key" ? " as a dictionary key" : "")}: {Cut(text)}"; - if (dp != dv) return $"input {input}: read-back differs: wrapped {Cut(dp)} vs value object {Cut(dv)}"; + if (p.Value != v.Value) return $"input {input}: read-back differs: wrapped {Cut(p.Value)} vs value object {Cut(v.Value)}"; c.Note(NoteKeys.Compared, "read " + input); return null; } @@ -296,7 +288,7 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) var (jo, eo) = Ser(hp, oOwn); if (ev is not null) { - // System.Text.Json refuses any custom converter there (MaxDepth counts its value one level deeper, design note D1), the wrapped + // System.Text.Json refuses any custom converter there (MaxDepth counts its value one level deeper, KB-040), the wrapped // type's under a converter of the application's alike if (ep is not null || (eo is not null && eo.GetType() == ev.GetType())) return c.Inconclusive($"the application's converter cannot write here, for the wrapped type too: {ev.GetType().Name}"); return $"own VO converter row: VO serialization threw {ev.GetType().Name}: {ev.Message}"; diff --git a/tools/probe/corpus/harness/PreserveGuard.cs b/tools/probe/corpus/harness/PreserveGuard.cs index 43ec6af6..9002e1b2 100644 --- a/tools/probe/corpus/harness/PreserveGuard.cs +++ b/tools/probe/corpus/harness/PreserveGuard.cs @@ -46,7 +46,11 @@ public static class PreserveGuard return guarded ? Guarded(r, obligation, e, hp, hv, o) : Unaffected(r, obligation, e, hp, hv, op, o, ownVo); } - /// The guarded rows: NotSupportedException naming the wrapped type and Preserve, in both directions, never a document. + /// + /// The guarded rows: NotSupportedException naming the wrapped type and Preserve, in both directions, never a document. The read of the + /// wrapped type's document is three-sided (Sides): where the twin without TypeKit refuses it alike, the serializer refused the document + /// before any converter ran. + /// static string? Guarded(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions o) { var wrapped = Make.Held(e.Plain); @@ -71,14 +75,18 @@ public static class PreserveGuard c.Note(NoteKeys.Compared, "write refused"); return null; }); - var fr = r.Check(obligation, Baseline.Refusal, c => + if (xp is not null) + return fw ?? r.Check(obligation, Baseline.Refusal, c => c.Inconclusive("read: the wrapped type's document is refused under these options, so there is none to read: " + Jx.Ex(xp))); + // reading: the wrapped type's own document, with the row's input applied + var input = r["input.json"]; + var text = JsonParity.InputFor(input, e, Jx.Full(o), Jx.Ser(hp, Jx.Full(o)).Json ?? jp!, r["shape.position"]); + var (vp, ep) = Jx.De(text, hp.Declared, o); + var (vv, ev) = Jx.De(text, hv.Declared, o); + var one = Sides.WithoutTypeKit(o); + var r1 = new Sides.Answer("read", null, Jx.De(text, hp.Declared, one).Ex); + var r3 = new Sides.Answer("read", null, ev); + var fr = Sides.Check(r, obligation, Baseline.Refusal, r1.Refused && Sides.Alike(r1, r3) ? Sides.Measured(r1, "the twin's read under the application's options, less TypeKit's converters") : null, r3, null, null, c => { - if (xp is not null) return c.Inconclusive("read: the wrapped type's document is refused under these options, so there is none to read: " + Jx.Ex(xp)); - // reading: the wrapped type's own document, with the row's input applied - var input = c["input.json"]; - var text = JsonParity.InputFor(input, e, Jx.Full(o), Jx.Ser(hp, Jx.Full(o)).Json ?? jp!, c["shape.position"]); - var (vp, ep) = Jx.De(text, hp.Declared, o); - var (vv, ev) = Jx.De(text, hv.Declared, o); if (ev is null) { // no converter ran: the read holds none of the wrapped value (a JSON null for a class, a member the options do not read) @@ -88,38 +96,37 @@ public static class PreserveGuard return c.Inconclusive($"read {input}: no converter ran, the read holds none of the wrapped value"); } if (ev is NotSupportedException) { if (Refusal(ev, "read " + input) is { } f) return f; c.Note(NoteKeys.Compared, "read " + input + " refused"); return null; } - // the serializer refused the document before any converter ran, the wrapped type's alike - if (ep is not null && ev.GetType() == ep.GetType() && ev.Message == ep.Message) - { c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } // the framework's refusal, the twin's alike: a pass, as decision 99's document is - if (ev is JsonException && ev.Message.Contains(Make.Held(e.Vo).Name) && ev.InnerException is null) { c.Note(NoteKeys.Compared, "read " + input + ": refused naming the value object before the guard"); return null; } - return Refusal(ev, "read " + input) + $"\n text={Jx.Cut(text)}"; + // refused before the guard: a JsonException naming the value object, with no inner exception (Generators README#json) + if (ev is not JsonException || ev.InnerException is not null) return Refusal(ev, "read " + input) + $"\n text={Jx.Cut(text)}"; + if (!ev.Message.Contains(Make.Held(e.Vo).Name)) return Refusal(ev, "read " + input) + $"\n text={Jx.Cut(text)}"; + c.Note(NoteKeys.Compared, "read " + input + ": refused naming the value object before the guard"); + return null; }); return fw ?? fr; } /// - /// The unaffected rows: the value object's document is the wrapped type's under the same options, never an $id twice, and its read - /// agrees with the wrapped type's (ReadAlike); under a converter of the application's for the value object, that converter's document - /// reads back and reads the row's input alike with and without the reference handler. + /// The unaffected rows, three-sided (Sides; C10 promises these are unaffected, so side (1) runs on every row): the value object's + /// document (3) is the wrapped type's under the same options (2), which is the twin's without TypeKit's converters (1), never an $id + /// twice, and its read agrees with the wrapped type's (ReadAlike); under a converter of the application's for the value object, that + /// converter's document reads back and reads the row's input alike with and without the reference handler. /// static string? Unaffected(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions o, bool ownVo) { - var (jp, xp) = Jx.Ser(hp, op); var (jv, xv) = Jx.Ser(hv, o); + var one = Sides.WithoutTypeKit(op); + var w1 = Sides.Write(hp, one); var w2 = Sides.Write(hp, op); var w3 = Sides.Write(hv, o); + var (jp, xp) = (w2.Value, w2.Thrown); var (jv, xv) = (w3.Value, w3.Thrown); // byte[] is base64 by the serializer's own rule (KB-038), an array of value objects a JSON array: unaffected means the value // object's document reads back as written var byteArray = e.Wrapped == "byte" && r.Is("shape.position", "array-element") && jv is not null && jp is not null && jv != jp; - var fw = r.Check(obligation, ownVo || byteArray ? Baseline.RoundTrip : Baseline.Twin, c => + var written = Sides.Framework(r, e, hp, one, w1, w3); + var fw = Sides.Check(r, obligation, ownVo || byteArray ? Baseline.RoundTrip : Baseline.Twin, written, w3, w1, w2, c => { - // MaxDepth=3: a converter costs a level of depth, the serializer's or the generated one (design note D1, the catalogue's stj-maxdepth); - // Jx.Depth is a text-matching site (JsonFamily.cs) - if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { c.Note(NoteKeys.StjMaxdepth, "write: " + Jx.Ex((xv ?? xp)!)); return null; } - if (xp is not null && xv is not null) - { - if (xp.GetType() == xv.GetType() && xp.Message == xv.Message) return c.Inconclusive("write: the options refuse the shape, for the wrapped type alike: " + Jx.Ex(xp)); - return $"write: both threw, differently: the wrapped type {Jx.Ex(xp)}, the value object {Jx.Ex(xv)}"; - } + if (xp is not null && xv is not null) return $"write: both threw, not alike: the wrapped type {w2}, the value object {w3}"; if (xv is not null) return $"write: the value object threw {Jx.Ex(xv)} where the wrapped type wrote {Jx.Cut(jp)}"; - if (xp is not null) return $"write: the value object wrote {Jx.Cut(jv)} where the wrapped type threw {Jx.Ex(xp)}"; + // the application's converter for the value object writes its own document: its baseline is that document read back, whatever + // the twin's writes ("anything a converter of your own writes" is unaffected) + if (xp is not null && !ownVo) return $"write: the value object wrote {Jx.Cut(jv)} where the wrapped type threw {Jx.Ex(xp)}"; if (DuplicateId(jv!) is { } dup) return $"write: $id {dup} twice in {Jx.Cut(jv)}"; if (ownVo) { @@ -142,19 +149,17 @@ public static class PreserveGuard c.Note(NoteKeys.Compared, "write"); return null; }); - var fr = r.Check(obligation, ownVo ? Baseline.RoundTrip : Baseline.Twin, c => - { - if (ownVo) return OwnReadsInput(r, c, e, hv, o); - // the wrapped type's base64 document is no array of value objects (KB-038): the round trip above holds the value object to its own - if (byteArray) return c.Inapplicable(Cite.Fact("KB-038"), "read: the wrapped type's document is a base64 string"); - // the document is refused by depth (Jx.Depth, the text-matching site), so its read is too: the write's refusal, as it is - if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { c.Note(NoteKeys.StjMaxdepth, "read: " + Jx.Ex((xv ?? xp)!)); return null; } - var full = Jx.Ser(hp, Jx.Full(op)).Json ?? jp; - if (full is null) return c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read"); - var input = c["input.json"]; - var text = JsonParity.InputFor(input, e, Jx.Full(op), full, c["shape.position"]); - return ReadAlike.Check(r, c, e, input, text, hp, hv, op, o); - }); + if (ownVo) return fw ?? OwnReadsInput(r, obligation, e, hp, hv, o); + // the wrapped type's base64 document is no array of value objects (KB-038): the round trip above holds the value object to its own + if (byteArray) return fw ?? r.Check(obligation, Baseline.Twin, c => c.Inapplicable(Cite.Fact("KB-038"), "read: the wrapped type's document is a base64 string")); + // the value object's document is refused as the framework refuses it, so is its read: the write's refusal, as it is + if (written is not null && Sides.Alike(written.Answer, w3)) return fw ?? Sides.Check(r, obligation, Baseline.Twin, written, w3, null, null, _ => (string?)null); + var full = Jx.Ser(hp, Jx.Full(op)).Json ?? jp; + if (full is null) return fw ?? r.Check(obligation, Baseline.Twin, c => c.Inconclusive("read: the wrapped type is not written under these options, so there is no document to read")); + var input = r["input.json"]; + var text = JsonParity.InputFor(input, e, Jx.Full(op), full, r["shape.position"]); + var r1 = Sides.Read(text, hp, one); var r2 = Sides.Read(text, hp, op); var r3 = Sides.Read(text, hv, o); + var fr = Sides.Check(r, obligation, Baseline.Twin, Sides.Framework(r, e, hp, one, r1, r3), r3, r1, r2, c => ReadAlike.Check(c, e, input, text, r2, r3, hp, hv, op, o)); return fw ?? fr; } @@ -162,32 +167,37 @@ public static class PreserveGuard /// The application's converter for the value object reads the row's input inside its own spelling ("vo:" and the wrapped /// type's JSON) alike under the row's reference handler and under none: "anything a converter of your own writes" is unaffected /// (Generators README#json). Each side writes its own document, so Preserve's metadata is its own, and only the value is rewritten. + /// Where the handler's document is refused as the framework refuses a converter's value (KB-040), the check is that refusal. /// - static string? OwnReadsInput(Row r, Check c, Entry e, Held hv, JsonSerializerOptions o) + static string? OwnReadsInput(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions o) { - var input = c["input.json"]; + var input = r["input.json"]; var inner = JsonRow.Inner(e); var wrapped = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, e.Valid())), Make.Held(e.Plain), inner); var value = "vo:" + JsonParity.InputFor(input, e, inner, wrapped, "root"); var bare = new JsonSerializerOptions(o) { ReferenceHandler = null }; - (string? Read, string? Refused, Exception? Depth) Through(JsonSerializerOptions x) + (string? Read, string? Refused, Sides.Answer? Write) Through(JsonSerializerOptions x) { - var (doc, wx) = Jx.Ser(hv, Jx.Full(x)); - if (wx is not null) return (null, "write: " + wx.GetType().Name, Jx.Depth(r, wx) ? wx : null); - var own = Jx.Tokens(doc!).Where(t => t.Type is JsonTokenType.String or JsonTokenType.PropertyName && t.Text is { } s && s.StartsWith("vo:", StringComparison.Ordinal)).ToList(); + var w = Sides.Write(hv, Jx.Full(x)); + if (w.Refused) return (null, "write: " + w.Thrown!.GetType().Name, w); + var own = Jx.Tokens(w.Value!).Where(t => t.Type is JsonTokenType.String or JsonTokenType.PropertyName && t.Text is { } s && s.StartsWith("vo:", StringComparison.Ordinal)).ToList(); if (own.Count == 0) return (null, null, null); - var (v, rx) = Jx.De(Jx.Rewrite(doc!, own, _ => JsonSerializer.Serialize(value)), hv.Declared, x); - if (rx is not null) return (null, "read: " + rx.GetType().Name, Jx.Depth(r, rx) ? rx : null); + var (v, rx) = Jx.De(Jx.Rewrite(w.Value!, own, _ => JsonSerializer.Serialize(value)), hv.Declared, x); + if (rx is not null) return (null, "read: " + rx.GetType().Name, null); return (JsonSerializer.Serialize(v, hv.Declared, bare), null, null); } var handled = Through(o); var unhandled = Through(bare); - if ((handled.Depth ?? unhandled.Depth) is { } depth) { c.Note(NoteKeys.StjMaxdepth, "own converter, read " + input + ": " + Jx.Ex(depth)); return null; } // Jx.Depth: a text-matching site - if (handled.Read is null && handled.Refused is null && unhandled.Read is null && unhandled.Refused is null) - { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "own converter: the options write no member"); return c.Inconclusive("own converter: the options write no member to read"); } - if (handled.Read != unhandled.Read || handled.Refused != unhandled.Refused) - return $"read {input} through the application's converter: {handled.Read ?? handled.Refused ?? "no member"} under ReferenceHandler {c["handler"]}, {unhandled.Read ?? unhandled.Refused ?? "no member"} without one"; - c.Note(NoteKeys.Compared, $"read {input} through the application's converter, alike with and without the reference handler: {Jx.Cut(handled.Read ?? handled.Refused)}"); - return null; + // the handler's document, written by the application's converter, refused as the framework refuses any converter's value there + var framework = (handled.Write ?? unhandled.Write) is { } refused ? Sides.WrittenByAConverter(r, e, hp, Sides.WithoutTypeKit(Jx.Full(handled.Write is null ? bare : o)), refused) : null; + return Sides.Check(r, obligation, Baseline.RoundTrip, framework, (handled.Write ?? unhandled.Write) ?? new Sides.Answer("write", null), null, null, c => + { + if (handled.Read is null && handled.Refused is null && unhandled.Read is null && unhandled.Refused is null) + { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "own converter: the options write no member"); return c.Inconclusive("own converter: the options write no member to read"); } + if (handled.Read != unhandled.Read || handled.Refused != unhandled.Refused) + return $"read {input} through the application's converter: {handled.Read ?? handled.Refused ?? "no member"} under ReferenceHandler {c["handler"]}, {unhandled.Read ?? unhandled.Refused ?? "no member"} without one"; + c.Note(NoteKeys.Compared, $"read {input} through the application's converter, alike with and without the reference handler: {Jx.Cut(handled.Read ?? handled.Refused)}"); + return null; + }); } /// The first $id value a document holds twice, or null. @@ -201,46 +211,43 @@ public static class PreserveGuard } /// -/// One read of the same text into the wrapped type's shape and the value object's: they agree, or differ only as the documents say -/// (a struct value object refuses a JSON null, a sealed record reads it as null, a validated one refuses what Create refuses, a closed set -/// has only its members); a refusal of the value object's is a JsonException naming it, with no inner exception (Generators README#json). +/// One read of the same text into the wrapped type's shape (2) and the value object's (3), each written again to be compared: they agree, or +/// differ only as the documents say (a struct value object refuses a JSON null, a sealed record reads it as null, a validated one refuses what +/// Create refuses, a closed set has only its members); a refusal of the value object's is a JsonException naming it, with no inner exception +/// (Generators README#json). Side (1) refusing alike with (3) is decided before (Sides). /// public static class ReadAlike { - public static string? Check(Row r, Check c, Entry e, string input, string text, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions ov) + public static string? Check(Check c, Entry e, string input, string text, Sides.Answer p, Sides.Answer v, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions ov) { - var (vp, ep) = Jx.De(text, hp.Declared, op); - var (vv, ev) = Jx.De(text, hv.Declared, ov); var voName = Make.Held(e.Vo).Name; string? Named(Exception x) => x is JsonException && x.Message.Contains(voName) && x.InnerException is null ? null : $"read {input}: the value object's refusal is {Jx.Ex(x)}{(x.InnerException is null ? "" : " with an inner exception")}, not a JsonException naming {voName}\n text={Jx.Cut(text)}"; var stored = c.Is("json.Converters", "StoredJsonConverterFactory"); - if (Jx.Depth(r, ev) || Jx.Depth(r, ep)) { c.Note(NoteKeys.StjMaxdepth, "read " + input + ": " + Jx.Ex((ev ?? ep)!)); return null; } // Jx.Depth: a text-matching site - if (ep is null && ev is null) + if (!p.Refused && !v.Refused) { if (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored && e.Refused() is not null && text != Jx.Ser(hp, op).Json && !c.Is("shape.position", "dictionary-key")) return $"read {input}: the validated value object read what Create refuses\n text={Jx.Cut(text)}"; - var sp = JsonSerializer.Serialize(vp, hp.Declared, op); var sv = JsonSerializer.Serialize(vv, hv.Declared, ov); - if (sp != sv) return $"read {input}: read back differs: wrapped {Jx.Cut(sp)} vs value object {Jx.Cut(sv)}\n text={Jx.Cut(text)}"; + if (p.Value != v.Value) return $"read {input}: read back differs: wrapped {Jx.Cut(p.Value)} vs value object {Jx.Cut(v.Value)}\n text={Jx.Cut(text)}"; c.Note(NoteKeys.Compared, "read " + input); return null; } - if (ep is not null && ev is not null) + if (p.Refused && v.Refused) { - // refused alike, message for message: the value object is unaffected, as the twin - if (ep.GetType() == ev.GetType() && Jx.Swap(ep.Message, Make.Held(e.Plain), Make.Held(e.Vo)) == ev.Message) { c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } - if (Named(ev) is { } f) return f + $"; the wrapped type: {Jx.Ex(ep)}"; + if (v.Operation != "read") return $"read {input}: both refuse, not alike: the wrapped type {p}, the value object {v}"; + if (Named(v.Thrown!) is { } f) return f + $"; the wrapped type: {p}"; c.Note(NoteKeys.Compared, "read " + input + ": both refuse, the value object naming itself"); return null; } - if (ev is not null) + if (v.Refused) { - var intended = (input == "null" && e.Vo.IsValueType) || (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored) || (e.Kind == "closed-set" && input != "valid"); - if (!intended) return $"read {input}: the wrapped type read it, the value object threw {Jx.Ex(ev)}\n text={Jx.Cut(text)}"; - if (Named(ev) is { } f) return f; + var intended = v.Operation == "read" && ((input == "null" && e.Vo.IsValueType) || (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored) || (e.Kind == "closed-set" && input != "valid")); + if (!intended) return $"read {input}: the wrapped type read it, the value object refused: {v} {Jx.Ex(v.Thrown!)}\n text={Jx.Cut(text)}"; + if (Named(v.Thrown!) is { } f) return f; c.Note(NoteKeys.Compared, "read " + input + ": refused as documented"); return null; } // the wrapped type refused and the value object read: only a JSON null for a sealed record, which reads it as any class does - if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Type == JsonTokenType.Null)) + if (input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType && Jx.De(text, hv.Declared, ov).Value is { } read + && Jx.Tokens(JsonSerializer.Serialize(read, hv.Declared, Jx.Plain)).Any(t => t.Type == JsonTokenType.Null)) { c.Note(NoteKeys.Compared, "read null: the sealed record reads null, as any class does"); return null; } - return $"read {input}: the value object read what the wrapped type refused ({Jx.Ex(ep!)})\n text={Jx.Cut(text)}"; + return $"read {input}: the value object read what the wrapped type refused ({p})\n text={Jx.Cut(text)}"; } } diff --git a/tools/probe/corpus/harness/Row.cs b/tools/probe/corpus/harness/Row.cs index 6975a4bc..a5ba7f2d 100644 --- a/tools/probe/corpus/harness/Row.cs +++ b/tools/probe/corpus/harness/Row.cs @@ -66,6 +66,8 @@ string Mark(string axis, string v) } /// The value the row holds for an axis, without marking it read: a check marks its own. internal string? Raw(string axis) => d.TryGetValue(axis, out var v) ? v : null; + /// Every axis and its value, none marked read: what a fact's fixture is matched against (Sides.Settings), whose covering answer then reads its own. + internal IReadOnlyDictionary Values => d; /// The setting ran as , not as the row asked: only the actual value earns credit. public void Substituted(string axis, string actual, string reason) { @@ -380,14 +382,14 @@ public sealed class NoteKeys public static readonly NoteKeys DocRefusedByAspnetAlone = new("doc:refused-by-aspnet-alone", Pass); public static readonly NoteKeys DocControllerNotInApiExplorer = new("doc:controller-not-in-api-explorer", Pass); // L03's JSON half has nothing to compare: the wrapped type alone refuses under the row's options, the value object alike, in the same row - public static readonly NoteKeys JsonHalfWrappedAloneRefuses = new("json-half:wrapped-alone-refuses", Pass); + public static readonly NoteKeys JsonHalfWrappedAloneRefuses = new("json-half:wrapped-alone-refuses", Pass); // not called since the three-sided differential (Sides): framework-refuses // JSON family (C01, C10, L06): the options leave the member out of one direction for the plain type without TypeKit too, shown by a // baseline in the same row; that direction has nothing to check, and the row is judged by the other one (both left out: inapplicable) public static readonly NoteKeys JsonMemberLeftOutByOptions = new("json:member-left-out-by-options", Pass); public static readonly NoteKeys JsonBothDirectionsLeftOutByOptions = new("json:both-directions-left-out-by-options", Inapplicable); // a read the wrapped type's shape refuses with the very same exception and message: the serializer refused the document before any // value object ran (the baseline in the same row), so the read has nothing to check; the row is judged by its write and other reads - public static readonly NoteKeys JsonReadRefusedByStjAlone = new("json:read-refused-by-stj-alone", Pass); + public static readonly NoteKeys JsonReadRefusedByStjAlone = new("json:read-refused-by-stj-alone", Pass); // not called since the three-sided differential (Sides): framework-refuses // ASP.NET's OpenAPI 2.0 writer drops the reference of a nullable struct component; a plain struct beside it in the same // schema, without a converter, is written the same, so that position has nothing to check (C12) public static readonly NoteKeys SchemaNullableReferenceLostForPlainToo = new("schema:nullable-reference-lost-for-plain-too", Pass); @@ -419,12 +421,12 @@ public sealed class NoteKeys public static readonly NoteKeys RefusedInputNotPlaceable = new("refused-input-not-placeable", Inapplicable); // a refusal, intended or the framework's own public static readonly NoteKeys DocumentedDiff = new("documented-diff", ExpectedRefusal); - public static readonly NoteKeys StjMaxdepth = new("stj-maxdepth", ExpectedRefusal); + public static readonly NoteKeys StjMaxdepth = new("stj-maxdepth", ExpectedRefusal); // not called since the three-sided differential (Sides): KB-040's side (1) public static readonly NoteKeys ValidatedRefusesTypeLimit = new("validated-refuses-type-limit", ExpectedRefusal); public static readonly NoteKeys ClassVoTakesNull = new("class-vo-takes-null", ExpectedRefusal); public static readonly NoteKeys BindPlainUnbindable = new("bind:plain-unbindable", ExpectedRefusal); public static readonly NoteKeys PreserveDocumented = new("preserve-documented", ExpectedRefusal); - public static readonly NoteKeys Mvc400UnwritableUnderSourceGen = new("mvc-400-unwritable-under-source-gen", ExpectedRefusal); + public static readonly NoteKeys Mvc400UnwritableUnderSourceGen = new("mvc-400-unwritable-under-source-gen", ExpectedRefusal); // not called since the three-sided differential (Sides): KB-041's side (1) public static readonly NoteKeys OwnVoReadBackRefused = new("own-vo:read-back-refused", ExpectedRefusal); public static readonly NoteKeys BothThrew = new("both-threw", ExpectedRefusal); public static readonly NoteKeys ReadBothThrew = new("read-both-threw", ExpectedRefusal); @@ -436,6 +438,9 @@ public sealed class NoteKeys public static readonly NoteKeys OwnConverterRefusedForWrappedToo = new("own-converter-refused-for-wrapped-too", ExpectedRefusal); // MVC's validator throws for a nested struct of a body (MaxValidationDepth) for a plain twin on the same side too (C13) public static readonly NoteKeys MvcPlainNestedBodyThrowsToo = new("mvc:plain-nested-body-throws-too", ExpectedRefusal); + // the three-sided differential (Sides): the wrapped twin without TypeKit and the value object refuse alike, at the same operation and + // stage with the same exception type and status, so the framework refuses; no message is compared + public static readonly NoteKeys FrameworkRefuses = new("framework-refuses", ExpectedRefusal); // both sides refused, but not alike public static readonly NoteKeys BothThrewDifferently = new("both-threw-differently", Inconclusive); } diff --git a/tools/probe/corpus/harness/SchemaClaim.cs b/tools/probe/corpus/harness/SchemaClaim.cs index 927750a0..02d7e4c3 100644 --- a/tools/probe/corpus/harness/SchemaClaim.cs +++ b/tools/probe/corpus/harness/SchemaClaim.cs @@ -668,16 +668,15 @@ sealed record AgreeRun(bool Patch, Type Type, HostCfg Cfg, List Sides, s { if (Down is not null) return Down; if (Failed is null) return $"{(Patch ? "a type held only inside a patch, " : "")}{Type.Name}: the host has controllers, its minimal-API options alone describe it ({Want!.ToJsonString()}), and the document was built"; - var named = Failed is InvalidOperationException && Failed.Message.Contains($"'{Type}'", StringComparison.Ordinal); - if (named && (!names || Failed.Message.Contains(Said, StringComparison.Ordinal))) - { - c.Note(NoteKeys.Compared, names ? "refused naming the type and the minimal-API options' description" : "refused naming the type"); - return null; - } // not the refusal the claim names: judged against ASP.NET alone - return await Hx.DocRefusedByAspNetAlone(r, en, Cfg, Failed) is null ? null - : named ? $"{(Patch ? "patch-held " : "")}{Type.Name}: the refusal names the type but not what the options say ({Said}): {Cut(Failed.Message)}" - : $"{(Patch ? "patch-held " : "")}{Type.Name}: the document was refused otherwise than naming the type and what the options say: {Failed.GetType().Name}: {Cut(Failed.Message)}"; + if (Failed is not InvalidOperationException || !Failed.Message.Contains($"'{Type}'", StringComparison.Ordinal)) + return await Hx.DocRefusedByAspNetAlone(r, en, Cfg, Failed) is null ? null + : $"{(Patch ? "patch-held " : "")}{Type.Name}: the document was refused otherwise than naming the type and what the options say: {Failed.GetType().Name}: {Cut(Failed.Message)}"; + if (names && !Failed.Message.Contains(Said, StringComparison.Ordinal)) + return await Hx.DocRefusedByAspNetAlone(r, en, Cfg, Failed) is null ? null + : $"{(Patch ? "patch-held " : "")}{Type.Name}: the refusal names the type but not what the options say ({Said}): {Cut(Failed.Message)}"; + c.Note(NoteKeys.Compared, names ? "refused naming the type and the minimal-API options' description" : "refused naming the type"); + return null; } /// The document built, describing the type as the converter on both option sets, or the minimal-API options alone, says. diff --git a/tools/probe/corpus/harness/Sides.cs b/tools/probe/corpus/harness/Sides.cs new file mode 100644 index 00000000..6de70533 --- /dev/null +++ b/tools/probe/corpus/harness/Sides.cs @@ -0,0 +1,298 @@ +// The three-sided differential: the rules every comparison of a value object with its wrapped twin follows, in one place. A comparison +// has three sides: +// (1) the wrapped twin without TypeKit: for http a host without AddTypeKit() and without TypeKit's JSON converters +// (HostCfg.WithoutTypeKit); in process, the twin under the application's options less TypeKit's converters (WithoutTypeKit); +// (2) the wrapped twin with TypeKit, as the row configures it; +// (3) the value object. +// Side (1) is read from a fact's fixture where one covers the row's configuration (Fact, FactFixture), and the check's baseline is then +// Baseline.Fact("KB-NNN"); otherwise it is measured, and the baseline is Baseline.Framework. A configuration measured here that no fact +// covers and that decides rows is pinned as a fact with its test and fixture (design/knowledge), never as a note key. +// The rules: +// - (1) and (3) refuse alike, at the same operation and stage with the same exception type and the same HTTP status: the framework +// refuses, and the check ends expected-refusal against side (1)'s baseline (NoteKeys.FrameworkRefuses). No message is compared: the +// stage is what an exception's properties say (a JsonException's Path), never its text. +// - (1) answers and (3) refuses, or (1) refuses and (3) answers: a violation, unless a sentence makes the difference intended, cited by +// the check (a not-promised entry of the claim, or the docs section a calibration cites). +// - (2) differs from (1): a violation, "TypeKit changed the wrapped type's behaviour" (Interferes). (2) and (3) agreeing on a wrong answer +// is why side (1) runs on every row of a claim whose obligations promise non-interference (NonInterference). +// - Elsewhere side (1) runs lazily, only where (2) and (3) disagree or refuse. +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using CodoMetis.TypeKit.Facts; +namespace Probes.Shapes; + +public static class Sides +{ + /// + /// One side's answer to one operation: the text it gave, or the exception it threw or the status it answered with. A side read from a + /// fixture has no exception object, only the type the fixture names (). + /// + public sealed record Answer(string Operation, string? Value, Exception? Thrown = null, int? Status = null, string? FixtureRefusal = null) + { + public bool Refused => Thrown is not null || FixtureRefusal is not null || Status is { } s && s is not (>= 200 and < 300); + /// The refusal's exception type, by its full name; null for an answer or a refusal by status alone. + public string? Refusal => Thrown?.GetType().FullName ?? FixtureRefusal; + /// + /// Where the refusal was met, from the exception's properties and never from its message: the JSON path a JsonException names. A side + /// read from a fixture has none: the settings its answer covers (the depth a value is written at) say where. + /// + public string? Stage => FixtureRefusal is not null ? null : Thrown is JsonException j ? j.Path ?? "" : ""; + public override string ToString() => + Refused ? $"{Operation} refused ({string.Join(" ", new[] { Thrown?.GetType().Name ?? FixtureRefusal, Status?.ToString() }.Where(x => x is not null))}{(Stage is { Length: > 0 } at ? " at " + at : "")})" + : $"{Operation} {Cut(Value)}"; + } + + /// (1) and (3) refuse alike: at the same operation and stage, with the same exception type and the same status. No message is compared. + public static bool Alike(Answer one, Answer three) => + one.Refused && three.Refused && one.Operation == three.Operation && one.Refusal == three.Refusal && one.Status == three.Status + && (one.Stage is null || SameStage(one.Stage, three.Stage!)); + + /// + /// Two refusals' stages are the same: the same JSON path, or, where one side writes or reads its value through a nested call of the + /// serializer, whose path starts at that value (a value object over a class the serializer walks), the other's path ending in it. + /// + static bool SameStage(string a, string b) => + a == b || (b.Length > 1 && b[0] == '$' && a.EndsWith(b[1..], StringComparison.Ordinal)) || (a.Length > 1 && a[0] == '$' && b.EndsWith(a[1..], StringComparison.Ordinal)); + + /// + /// (2) is not (1): TypeKit changed what the wrapped type does. The failure, or null where the two are the same answer: the same text, or + /// a refusal alike (a refusal's body, such as a problem's trace id, is not compared). + /// + public static string? Interferes(Answer one, Answer two) => + one.Refused == two.Refused && one.Refusal == two.Refusal && one.Status == two.Status && one.Stage == two.Stage && (one.Refused || one.Value == two.Value) ? null + : $"{two.Operation}: TypeKit changed the wrapped type's behaviour: without TypeKit {one}, with it {two}"; + + /// + /// Ends a check whose sides (1) and (3) refuse alike: the framework's refusal, an expected refusal that earns nothing. The check runs + /// against side (1)'s baseline, which its caller chose (). + /// + public static string? Refuses(Check c, Side one, Answer three) + { + c.Note(NoteKeys.FrameworkRefuses, $"{three}, {one.Source}"); + return null; + } + + /// + /// One check of the differential. Where side (1) refuses alike with (3) (), the check is the framework's + /// refusal, against side (1)'s baseline; otherwise compares, against , once (2) is held + /// to (1) where side (1) ran (, ). + /// + public static string? Check(Row r, string obligation, Baseline against, Side? framework, Answer three, Answer? one, Answer? two, Func body) => + framework is not null && Alike(framework.Answer, three) + ? r.Check(obligation, framework.Baseline, c => Refuses(c, framework, three)) + : r.Check(obligation, against, c => (one is not null && two is not null ? Interferes(one, two) : null) ?? body(c)); + + /// The asynchronous form of . + public static Task Check(Row r, string obligation, Baseline against, Side? framework, Answer three, Answer? one, Answer? two, Func> body) => + framework is not null && Alike(framework.Answer, three) + ? Task.FromResult(r.Check(obligation, framework.Baseline, c => Refuses(c, framework, three))) + : r.Check(obligation, against, async c => (one is not null && two is not null ? Interferes(one, two) : null) ?? await body(c)); + + /// + /// Side (1) where the value object refused (), as the framework's: the twin without TypeKit refusing alike + /// (), or, where the twin answered, a converter's value under the row's depth limit (). + /// Null where the framework does not refuse alike, and the check compares. + /// + public static Side? Framework(Row r, Entry e, Held twin, JsonSerializerOptions oneOptions, Answer one, Answer three) + { + if (!three.Refused) return null; + if (one.Refused) return Alike(one, three) ? Measured(one, "the twin under the application's options, less TypeKit's converters") : null; + return WrittenByAConverter(r, e, twin, oneOptions, three) is { } s && Alike(s.Answer, three) ? s : null; + } + + /// Side (1) as a check uses it: its answer, the baseline it stands for, and where it came from. + public sealed record Side(Answer Answer, Baseline Baseline, string Source); + + /// Side (1) measured: the framework without TypeKit, run in the same row. + public static Side Measured(Answer a, string how) => new(a, Baseline.Framework, "measured: " + how); + + // ---- which obligations promise non-interference + + /// + /// The obligations whose sentence promises that the value object leaves the wrapped type as ASP.NET or System.Text.Json have it ("as the + /// underlying type's", "the one ASP.NET Core publishes for the underlying type", "unaffected"), read from each claim's obligations in + /// claims.json. Side (1) runs on every row that checks one of them. C12's parameter-keeps-aspnet and C01's covers-every-decimal already + /// compare with the framework without TypeKit's converters on every row (SchemaClaim's Baseline.Framework, DecimalSpelling's serializer's + /// own decimal); the text claims compare with the wrapped type's own text, which TypeKit adds nothing to. + /// + static readonly Dictionary NonInterference = new() + { + ["C21-openapi-schema-parity"] = ["the-underlying-types-schema", "nullable-admits-null-as-the-wrapped-does", "container-elements", "dictionary-key", "every-document-version"], + ["L01-json-bytes"] = ["the-underlying-types-json", "as-a-dictionary-key-too", "read-as-the-underlying-type-reads"], + ["C10-preserve-guard"] = ["value-converters-unaffected", "own-converters-unaffected", "ignore-cycles-unaffected"], + }; + + /// Whether side (1) runs on every row for this obligation of the row's claim, not only where (2) and (3) disagree or refuse. + public static bool Unconditional(Row r, string obligation) => + r.Claim is { } claim && NonInterference.TryGetValue(claim, out var obligations) && obligations.Contains(obligation); + + /// + /// Whether a type is or holds one of TypeKit's own (a Patch, an Option), through its type arguments and element type: a twin that holds + /// one has no side without TypeKit. + /// + public static bool HoldsTypeKit(Type t) => + t.Assembly == typeof(CodoMetis.TypeKit.ValueObjects.StoredJsonConverterFactory).Assembly + || (t.IsArray && HoldsTypeKit(t.GetElementType()!)) + || (t.IsGenericType && t.GetGenericArguments().Any(HoldsTypeKit)); + + // ---- side (1) in process: the twin under the application's options, less TypeKit's converters + + /// The application's options less what TypeKit adds to them: its converters (StoredJsonConverterFactory, the decimal converters), told by their assembly. + public static JsonSerializerOptions WithoutTypeKit(JsonSerializerOptions o) + { + var one = new JsonSerializerOptions(o); + DropTypeKit(one); + return one; + } + + /// Removes TypeKit's converters from options in place: a host's side (1) (HostCfg.WithoutTypeKit). + public static void DropTypeKit(JsonSerializerOptions o) + { + var typeKit = typeof(CodoMetis.TypeKit.ValueObjects.StoredJsonConverterFactory).Assembly; + foreach (var x in o.Converters.Where(x => x.GetType().Assembly == typeKit).ToList()) o.Converters.Remove(x); + } + + /// A side's write of a held shape. + public static Answer Write(Held h, JsonSerializerOptions o, string operation = "write") + { + try { return new(operation, JsonSerializer.Serialize(h.Value, h.Declared, o)); } + catch (Exception x) { return new(operation, null, x); } + } + + /// A side's read of a text into a held shape, and what it read written again: a refusal of the write back is the operation "write back". + public static Answer Read(string text, Held h, JsonSerializerOptions o, string operation = "read") + { + object? v; + try { v = JsonSerializer.Deserialize(text, h.Declared, o); } + catch (Exception x) { return new(operation, null, x); } + try { return new(operation, v is null ? "null" : JsonSerializer.Serialize(v, h.Declared, o)); } + catch (Exception x) { return new("write back", null, x); } + } + + // ---- side (1) of a value a converter writes, under a depth limit (KB-040) + + /// + /// Side (1) where the value object, which its converter writes, refused a write that the twin, which the serializer's own converter writes, + /// answered, under the row's json.MaxDepth: System.Text.Json charges a converter's value a level against the limit (KB-040), so the + /// framework's side for a converter-written value is a converter of the application's for the wrapped type, without TypeKit. Read from + /// KB-040's fixture where an answer covers the row's depth limit and the depth the value is written at, else measured with that + /// converter in the same row. Null where the row sets no depth limit, (3) refused no write, or the depth cannot be read. + /// + public static Side? WrittenByAConverter(Row r, Entry e, Held twin, JsonSerializerOptions one, Answer three) + { + if (r.Get("json.MaxDepth") is null or "default" || three.Thrown is null || three.Operation is not ("write" or "write back")) return null; + if (ConverterDepth(e, twin, one) is not { } depth) { r.Log.AppendLine(Why = $"side (1) of a converter's value: no depth read for {Make.Held(e.Plain).Name} ({Unread})"); return null; } + var settings = Settings(r, ("written-by", "a-converter"), ("writer-depth", depth.ToString(System.Globalization.CultureInfo.InvariantCulture))); + if (Covered(r, "KB-040", settings) is { Kind: FactAnswer.Exception } answer) + return new(new(three.Operation, null, FixtureRefusal: (string?)answer.Value["type"]), Baseline.Fact("KB-040"), + $"KB-040 {answer.Id}: a converter's value at depth {depth} under MaxDepth {r["json.MaxDepth"]}"); + var measured = ByAConverter(e, twin, one, three.Operation); + Why = $"side (1) of a converter's value at depth {depth}: no KB-040 answer covers it, measured {measured?.ToString() ?? "nothing"}"; + return measured is null ? null : Measured(measured, $"the twin written by a converter of the application's at depth {depth}"); + } + + /// The writer's depth where the serializer hands the wrapped value to a converter, read by one that records it, without a limit. + static int? ConverterDepth(Entry e, Held twin, JsonSerializerOptions one) + { + try + { + var open = new JsonSerializerOptions(one) { MaxDepth = 64 }; + var serializers = new JsonSerializerOptions(open); + var converter = (IRecordsDepth)Activator.CreateInstance(typeof(PassThrough<>).MakeGenericType(Make.Held(e.Plain)), serializers)!; + open.Converters.Insert(0, (JsonConverter)converter); + JsonSerializer.Serialize(twin.Value, twin.Declared, open); + Unread = converter.Depth >= 0 ? null : "no value handed to the converter"; + return converter.Depth >= 0 ? converter.Depth : null; + } + catch (Exception x) { Unread = x.GetType().Name + ": " + Cut(x.Message); return null; } // the twin is not written with a converter of the application's here: no depth to read + } + [ThreadStatic] static string? Unread; + /// Why the last on this thread gave no fixture's side, for a failure to name. + [ThreadStatic] public static string? Why; + + /// The twin written (and, for a write back, read and written again) by a converter of the application's that writes as the serializer does. + static Answer? ByAConverter(Entry e, Held twin, JsonSerializerOptions one, string operation) + { + try + { + var with = new JsonSerializerOptions(one); + with.Converters.Insert(0, (JsonConverter)Activator.CreateInstance(typeof(PassThrough<>).MakeGenericType(Make.Held(e.Plain)), new JsonSerializerOptions(one))!); + return Write(twin, with, operation); + } + catch (Exception) { return null; } + } + + interface IRecordsDepth { int Depth { get; } } + + /// A converter of the application's for the wrapped type, without TypeKit: it writes and reads through the serializer's own contract for it, and records the writer's depth. + sealed class PassThrough(JsonSerializerOptions serializers) : JsonConverter, IRecordsDepth where T : notnull + { + // options a host or a row built without a resolver get the serializer's default one, as JsonSerializer gives them on first use + readonly JsonTypeInfo info = (JsonTypeInfo)Resolving(serializers).GetTypeInfo(typeof(T)); + static JsonSerializerOptions Resolving(JsonSerializerOptions o) { o.TypeInfoResolver ??= new DefaultJsonTypeInfoResolver(); return o; } + public int Depth { get; private set; } = -1; + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => JsonSerializer.Deserialize(ref reader, info); + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { Depth = Math.Max(Depth, writer.CurrentDepth); JsonSerializer.Serialize(writer, value, info); } + public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ((JsonConverter)info.Converter).ReadAsPropertyName(ref reader, typeToConvert, serializers); + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { Depth = Math.Max(Depth, writer.CurrentDepth); ((JsonConverter)info.Converter).WriteAsPropertyName(writer, value, serializers); } + } + + // ---- facts' fixtures + + static readonly Dictionary Fixtures = new(); + + /// + /// A fact's fixture as side (1), as FactFixture's rule says: only a fixture the fact's entry names on its fixture: line, read from the + /// repository (the Facts project's test/CodoMetis.TypeKit.Facts, found by walking up to CodoMetis.TypeKit.slnx), never written. Null where + /// the fact names none. Whether `docs-query check` is clean is CI's to hold: the harness does not run it. + /// + public static FactFixture? Fact(string id) + { + lock (Fixtures) + { + if (Fixtures.TryGetValue(id, out var known)) return known; + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "CodoMetis.TypeKit.slnx"))) root = root.Parent; + FactFixture? fixture = null; + if (root is not null) + { + var file = FactFixture.FileName(id); + var entry = Directory.EnumerateFiles(Path.Combine(root.FullName, "design", "knowledge"), id + "-*.md").FirstOrDefault(); + // the entry's frontmatter, between its first two "---" lines, names the fixture + var named = entry is not null && File.ReadLines(entry).Skip(1).TakeWhile(l => l != "---") + .Any(l => l.Trim() == $"fixture: test/CodoMetis.TypeKit.Facts/{file}"); + var path = Path.Combine(root.FullName, "test", "CodoMetis.TypeKit.Facts", file); + if (named && File.Exists(path)) fixture = FactFixture.Load(path); + } + return Fixtures[id] = fixture; + } + } + + /// + /// The row's settings as a fixture's answers name them: every axis at the value that applied (a substitution's, else the row's), unread, + /// and the settings the harness measured (), such as the depth a value is written at. The caller marks read + /// the axes of the answer that covers them (). + /// + public static Dictionary Settings(Row r, params (string Key, string Value)[] measured) + { + var s = new Dictionary(StringComparer.Ordinal); + Dictionary applied; + lock (r.Ev) applied = new(r.Ev.Applied); + foreach (var (axis, value) in r.Values) s[axis] = applied.TryGetValue(axis, out var a) ? a : value; + foreach (var (key, value) in measured) s[key] = value; + return s; + } + + /// The answer of a fact's fixture that covers the row's settings, its axes then read for the running check; null where none does. + public static FactAnswer? Covered(Row r, string fact, Dictionary settings) + { + if (Fact(fact)?.For(settings) is not { } answer) return null; + foreach (var axis in answer.When.Keys) _ = r.Get(axis); + return answer; + } + + static string Cut(string? s) => s is null ? "(null)" : s.Length > 160 ? s[..160] + "..." : s; +} diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index a7fdb059..2c306633 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -257,51 +257,51 @@ static bool WrappedLosesItToo(Entry e, object plain) /// L04's JSON half, json-refusal-is-a-json-exception: the value object's own document reads back as written (the round trip the /// refusal is measured from), and the row's changed input is read or refused with a JsonException, at the root naming the value object /// and carrying no inner exception ("Input it can't read throws a JsonException naming the value object and the underlying type"). + /// Three-sided (Sides): where the value object (3) refuses, the twin without TypeKit's converters (1) refusing alike is the framework's + /// refusal, and so is a converter's value refused under the row's depth limit (KB-040). /// static List JsonRoundTrip(Row r, Entry e) { const string Obligation = "json-refusal-is-a-json-exception"; var o = JsonRow.Apply(r, e).Options!; + var one = Sides.WithoutTypeKit(o); var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var plain = e.Valid(); var vo = Make.Vo(e, plain); var hv = Pos.Build(pos, con, e, true, vo); var hp = Pos.Build(pos, con, e, false, plain); - string? jv = null; Exception? wx = null; - try { jv = JsonSerializer.Serialize(hv.Value, hv.Declared, o); } catch (Exception ex) { wx = ex; } - string? Back(Held h, string text, out Exception? ex) + var w3 = Sides.Write(hv, o); var jv = w3.Value; + bool Guarded(Exception? x, Check c) => c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && x is NotSupportedException; + var fails = new List(); + // the round trip: the value object's write, then its own document read back and written again; side (1) where it refuses + Sides.Answer w2 = Sides.Write(hp, o), three, two, first; + if (w3.Refused) { three = w3; two = w2; first = Sides.Write(hp, one); } + else { - ex = null; - try { var v = JsonSerializer.Deserialize(text, h.Declared, o); return v is null ? "null" : JsonSerializer.Serialize(v, h.Declared, o); } catch (Exception x) { ex = x; return null; } + three = Sides.Read(jv!, hv, o); + two = w2.Value is { } jp ? Sides.Read(jp, hp, o) : w2; + first = Sides.Write(hp, one) is { Value: { } j1 } ? Sides.Read(j1, hp, one) : Sides.Write(hp, one); } - bool Guarded(Exception x, Check c) => c.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && x is NotSupportedException; - var fails = new List(); - fails.Add(r.Check(Obligation, Baseline.RoundTrip, c => + var lazy = three.Refused || two.Refused; + fails.Add(Sides.Check(r, Obligation, Baseline.RoundTrip, lazy ? Sides.Framework(r, e, hp, one, first, three) : null, three, lazy ? first : null, lazy ? two : null, c => { - if (wx is not null) + if (w3.Refused) { - try { JsonSerializer.Serialize(hp.Value, hp.Declared, o); } - catch (Exception px) { return c.Inconclusive("write: the options refuse the shape for the wrapped type too: " + px.GetType().Name); } - // Text-matching site, kept in this step (WS2 step 4): the depth refusal is told by the message's words. Baseline gap: no twin in - // the row shows System.Text.Json charging a converter a level, and no record or fact cites it (the three-sided differential). - if (c.Is("json.MaxDepth", "3") && wx.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "write"); return null; } - if (Guarded(wx, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "write"); - return $"serializing threw {wx.GetType().Name} where the wrapped type did not: {wx.Message}"; + if (w2.Refused) return c.Inconclusive($"write: both refuse, not alike: the wrapped type {w2}, the value object {w3}"); + if (Guarded(w3.Thrown, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "write"); + return $"serializing threw {w3.Thrown!.GetType().Name} where the wrapped type did not: {w3.Thrown.Message}"; } - var b = Back(hv, jv!, out var ex1); - if (ex1 is not null) + if (three.Refused) { - if (Guarded(ex1, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "read back"); - // Text-matching site, kept in this step (WS2 step 4), as above: the depth refusal told by its words, no twin showing it. - if (c.Is("json.MaxDepth", "3") && ex1.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth, "read back"); return null; } - // the wrapped twin does not read its own document back under these options either: nothing to compare - if (PlainFails(hp, o) is { } px) { return c.Inconclusive($"read back: the wrapped type does not read its own document back under these options either: {px.GetType().Name}: {Cut(px.Message)}"); } - return $"own JSON {Cut(jv)} did not read back: {ex1.GetType().Name}: {Cut(ex1.Message)}"; + if (Guarded(three.Thrown, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "read back"); + // the wrapped twin does not read its own document back under these options either, not alike: nothing to compare + if (two.Refused) return c.Inconclusive($"read back: the wrapped type does not read its own document back under these options either, not alike: {two}, the value object {three}"); + return $"own JSON {Cut(jv)} did not read back: {three.Thrown!.GetType().Name}: {Cut(three.Thrown.Message)}"; } - if (b != jv) + if (three.Value != jv) { // under Preserve the serializer numbers references per call: the twin's document read back and written again, alike - if (c.Is("json.ReferenceHandler", "Preserve") && TwinRoundTrip(hp, o) is { } tw && tw.Back != tw.Json) - return c.Inconclusive($"JSON round trip: written again it differs, for the wrapped type alike ({Cut(tw.Json)} / {Cut(tw.Back)})"); - return $"JSON round trip differs: wrote {Cut(jv)}, read and wrote {Cut(b)}"; + if (c.Is("json.ReferenceHandler", "Preserve") && w2.Value is { } tj && two.Value != tj) + return c.Inconclusive($"JSON round trip: written again it differs, for the wrapped type alike ({Cut(tj)} / {Cut(two.Value)})"); + return $"JSON round trip differs: wrote {Cut(jv)}, read and wrote {Cut(three.Value)}"; } c.Note(NoteKeys.Compared, "JSON round trip"); return null; @@ -309,30 +309,35 @@ static bool WrappedLosesItToo(Entry e, object plain) // a changed input must be read or refused with a JsonException (never another type) var input = r["input.json"]; if (input != "valid" && jv is not null) - fails.Add(r.Check(Obligation, Baseline.Refusal, c => + { + var text = JsonParity.InputFor(input, e, o, jv, pos); + var i3 = new Sides.Answer("read", null, Thrown(() => JsonSerializer.Deserialize(text, hv.Declared, o))); + // side (1) where the value object refuses with another exception than a JsonException: the twin without TypeKit, reading the same text + Sides.Answer? i1 = i3.Thrown is not null and not JsonException ? new("read", null, Thrown(() => JsonSerializer.Deserialize(text, hp.Declared, one))) : null; + var framework = i1 is not null && Sides.Alike(i1, i3) && !r.Is("json.Converters", "own-for-value-object") ? Sides.Measured(i1, "the twin reading the same text, less TypeKit's converters") : null; + fails.Add(Sides.Check(r, Obligation, Baseline.Refusal, framework, i3, null, null, c => { // "Your own converter wins": the application's converter reads the value object, so what it throws is its own. It shares the // obligation with the round trip above, which runs under it, so it is decided here, not declared in claims.json. if (c.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "input " + input); - var text = JsonParity.InputFor(input, e, o, jv, pos); if (text == jv) return c.Inconclusive($"input {input}: no place for it in {Cut(jv)}"); - Back(hv, text, out var ex2); - if (ex2 is not null and not JsonException) + if (i3.Thrown is { } ex2 and not JsonException) { if (Guarded(ex2, c)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "input " + input); - // the serializer's own refusal of the document, for the wrapped twin alike - Back(hp, text, out var px); - if (px is not null && px.GetType() == ex2.GetType()) return c.Inconclusive($"input {input}: {ex2.GetType().Name} for the wrapped type alike: {Cut(px.Message)}"); + if (i1 is { Refused: true }) return c.Inconclusive($"input {input}: both refuse, not alike: the wrapped type {i1}, the value object {i3}"); return $"input {input}: {ex2.GetType().Name} instead of JsonException: {Cut(ex2.Message)}" + (ex2.InnerException is { } inner ? $" / inner {inner.GetType().FullName}: {Cut(inner.Message)} @ {Cut(inner.StackTrace?.Split('\n').FirstOrDefault()?.Trim() ?? "")}" : ""); } if (RootRefusal(e, o, input) is { } refusal) return refusal; - c.Note(NoteKeys.Compared, $"input {input}: {(ex2 is null ? "read" : "a JsonException")}"); + c.Note(NoteKeys.Compared, $"input {input}: {(i3.Thrown is null ? "read" : "a JsonException")}"); return null; })); + } return fails; } + static Exception? Thrown(Action a) { try { a(); return null; } catch (Exception x) { return x; } } + /// /// The changed input read at the root, where no container stands between the serializer and the generated converter: a /// JsonException the value object throws there names it and carries no inner exception, "Input it can't read throws a @@ -348,30 +353,22 @@ static bool WrappedLosesItToo(Entry e, object plain) try { JsonSerializer.Deserialize(text, vt, o); return null; } catch (JsonException x) { - if (x.InnerException is null && x.Message.Contains(vt.Name, StringComparison.Ordinal)) return null; - return $"input {input} at the root: the JsonException {(x.InnerException is { } i ? "carries an inner " + i.GetType().Name : "does not name " + vt.Name)}: {Cut(x.Message)}\n text={Cut(text)}"; + if (x.InnerException is { } i) return $"input {input} at the root: the JsonException carries an inner {i.GetType().Name}: {Cut(x.Message)}\n text={Cut(text)}"; + if (!x.Message.Contains(vt.Name, StringComparison.Ordinal)) return $"input {input} at the root: the JsonException does not name {vt.Name}: {Cut(x.Message)}\n text={Cut(text)}"; + return null; } catch (Exception) { return null; } // another exception type is judged above, at the row's position } - /// The wrapped twin's refusal to read back its own document under the options, or null where it reads it back. - static Exception? PlainFails(Held hp, JsonSerializerOptions o) - { - try { var jp = JsonSerializer.Serialize(hp.Value, hp.Declared, o); JsonSerializer.Deserialize(jp, hp.Declared, o); return null; } catch (Exception x) { return x; } - } - /// The wrapped twin's document and that document read back and written again, or null where either throws. - static (string Json, string Back)? TwinRoundTrip(Held hp, JsonSerializerOptions o) - { - try { var jp = JsonSerializer.Serialize(hp.Value, hp.Declared, o); var v = JsonSerializer.Deserialize(jp, hp.Declared, o); return (jp, v is null ? "null" : JsonSerializer.Serialize(v, hp.Declared, o)); } - catch { return null; } - } - static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) - { try { JsonSerializer.Deserialize(text, hp.Declared, o); return false; } catch (Exception x) { return x.InnerException is not null; } } // STJ's own inner (the container, a dictionary value) is not the value object's internal static string Cut(string? s) => s is null ? "(null)" : s.Length > 160 ? s[..160] + "..." : s; // ---- L02: a rejection never repeats the input - // Text-matching site, kept in this step (WS2 step 4): System.Text.Json's Path, which holds a rejected dictionary key (not-promised - // a-rejected-dictionary-key-in-the-path), is cut off by its words. Baseline gap: no twin in the row shows the wrapped type's key in the Path too. - static string StripPath(Exception x) => x.Message.IndexOf(" Path:", StringComparison.Ordinal) is var i and >= 0 ? x.Message[..i] : x.Message; + /// How often a text holds the input: L02's claim is about the text of a refusal, so this is the check, never an excuse. + static int Occurrences(string text, string needle) + { + var n = 0; + for (var i = text.IndexOf(needle, StringComparison.Ordinal); i >= 0; i = text.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++; + return n; + } /// /// L02's checks: a parsing entry's refusal of a secret, or of the text Create refuses, neither quotes it (parsing-never-repeats, a @@ -425,6 +422,7 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) } // JSON half var opts = JsonRow.Apply(r, e).Options!; + var one = Sides.WithoutTypeKit(opts); var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var hp = Pos.Build(pos, con, e, false, plain); var hv = Pos.Build(pos, con, e, true, Make.Vo(e, plain)); var input = r["input.json"]; @@ -455,22 +453,26 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) if (x > 0 && text != doc) { var lit = text.Substring(at, x).Trim('"'); if (lit.Length >= 6 && lit.Length < 60 && lit.All(ch => !char.IsPunctuation(ch) || ch is '-' or ':' or '.')) variants.Add((text, lit)); } } if (variants.Count == 0) return c.Inconclusive($"the wrapped value has no place in {Cut(doc)}: the options leave it out"); - var rejected = 0; + var rejected = 0; var inPaths = new List(); foreach (var (text, needle) in variants) { try { JsonSerializer.Deserialize(text, hv.Declared, opts); } catch (Exception ex) { rejected++; - // the Path is System.Text.Json's (not-promised a-rejected-dictionary-key-in-the-path): a JsonException's message is read without it - var msg = ex is JsonException ? StripPath(ex) : ex.Message; - if (msg.Contains(needle)) return $"JSON read: {ex.GetType().Name} quotes the input: {Cut(ex.Message)}" + (pos == "dictionary-key" ? " [dictionary-key]" : ""); + // System.Text.Json puts a rejected dictionary key in the JsonException's Path (not-promised a-rejected-dictionary-key-in-the-path): + // the Path is read as the exception's property, and the message must hold the input no more often than its Path does + var inPath = ex is JsonException { Path: { } path } ? Occurrences(path, needle) : 0; + if (Occurrences(ex.Message, needle) > inPath) return $"JSON read: {ex.GetType().Name} quotes the input: {Cut(ex.Message)}" + (pos == "dictionary-key" ? " [dictionary-key]" : ""); if (ex.InnerException is { } inner && inner.Message.Contains(needle)) return $"JSON read: inner {inner.GetType().Name} quotes the input"; - if (ex.InnerException is not null && ex is JsonException && !(ex.InnerException is JsonException) && !PlainHasInner(text, hp, opts)) return $"JSON read: refusal carries inner {ex.InnerException.GetType().Name}: {Cut(ex.InnerException.Message)}"; + // side (1): the twin without TypeKit's converters, reading the same text, shows the serializer's own inner and Path + var twin = Thrown(() => JsonSerializer.Deserialize(text, hp.Declared, one)); + if (ex.InnerException is not null && ex is JsonException && !(ex.InnerException is JsonException) && twin?.InnerException is null) return $"JSON read: refusal carries inner {ex.InnerException.GetType().Name}: {Cut(ex.InnerException.Message)}"; + if (inPath > 0) inPaths.Add(twin is JsonException { Path: { } tp } && Occurrences(tp, needle) > 0 ? "the key in the Path, the wrapped type's alike" : "the key in the Path, as not-promised a-rejected-dictionary-key-in-the-path says"); } } if (rejected == 0) return c.Inconclusive("every JSON input read, none refused: nothing to quote"); - c.Note(NoteKeys.Compared, $"JSON read: {rejected} refused without the input"); + c.Note(NoteKeys.Compared, $"JSON read: {rejected} refused without the input" + (inPaths.Count > 0 ? ", " + string.Join(", ", inPaths.Distinct()) : "")); return null; })); return fails.FirstOrDefault(f => f is not null); @@ -559,56 +561,47 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) /// validated one refuses the value Create refuses, where the wrapped type reads it, with a JsonException naming the fault. The two /// named paths are checked as what they are: an application's own converter for the value object takes precedence (it reads its /// own spelling back), and StoredJsonConverterFactory reads the refused value without validation (README, "Only two paths skip - /// validation"). A depth limit is the framework's only where a converter the application registers for the wrapped type, at the - /// same position, is refused alike (design note D1: System.Text.Json counts a custom converter's value one level deeper). + /// validation"). Three-sided (Sides): where the value object (3) refuses, the twin without TypeKit's converters (1) refusing alike is + /// the framework's refusal, the check an expected refusal against it, and so is a converter's value under a depth limit (KB-040). /// static List JsonOneRule(Row r, Entry e, bool validated) { const string Obligation = "json-runs-create"; var o = JsonRow.Apply(r, e).Options!; + var one = Sides.WithoutTypeKit(o); var pos = r["shape.position"]; var con = r.Get("shape.containing") ?? "class"; var plain = e.Valid(); var hp = Pos.Build(pos, con, e, false, plain); var hv = Pos.Build(pos, con, e, true, Make.Vo(e, plain)); - static (string? Json, Exception? Ex) Write(Held h, JsonSerializerOptions x) { try { return (JsonSerializer.Serialize(h.Value, h.Declared, x), null); } catch (Exception ex) { return (null, ex); } } - static (object? Value, Exception? Ex) Read(string text, Held h, JsonSerializerOptions x) { try { return (JsonSerializer.Deserialize(text, h.Declared, x), null); } catch (Exception ex) { return (null, ex); } } - bool OwnConverterRefusedToo(Exception voEx) - { - var own = new JsonSerializerOptions(o); - own.Converters.Insert(0, (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(typeof(OwnWrappedConverter<>).MakeGenericType(Make.Held(e.Plain)), JsonRow.Inner(e))!); - var (text, wex) = Write(hp, own); - var ex = wex ?? Read(text!, hp, own).Ex; - return ex is not null && ex.GetType() == voEx.GetType(); - } - // Where the wrapped type alone refuses under the row's options, checked in the same row with the value object refusing alike, - // the JSON half has nothing to compare and the row is judged by its text entries (as decision 99 judges a document ASP.NET alone refuses). - // A pass, as decision 99's document refused by ASP.NET alone is: the refusal is the framework's, shown by the twin in the same row. - string? Alone(Check c, string what) { c.Note(NoteKeys.JsonHalfWrappedAloneRefuses, what); return null; } bool Guarded(Exception x) => r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && x is NotSupportedException; - var (jp, xp) = Write(hp, o); var (jv, xv) = Write(hv, o); + var w2 = Sides.Write(hp, o); var w3 = Sides.Write(hv, o); + var (jp, xp) = (w2.Value, w2.Thrown); var (jv, xv) = (w3.Value, w3.Thrown); var fails = new List(); + // 1. the valid value reads back as written: the write, then the value object's own document read back; side (1) where (3) refuses + Sides.Answer three = w3, two = w2, first = Sides.Write(hp, one); + if (!w3.Refused) + { + three = Sides.Read(jv!, hv, o); + two = jp is null ? w2 : Sides.Read(jp, hp, o); + first = first.Value is { } j1 ? Sides.Read(j1, hp, one) : first; + } + var framework = three.Refused ? Sides.Framework(r, e, hp, one, first, three) : null; // Why the value object's own document has nothing to compare under the row's options, or null where it reads back: the refused // value is then not told apart from a document the options do not let it read, and step 2 has nothing to check either. - string? nothing = null; - // 1. the valid value reads back as written - fails.Add(r.Check(Obligation, Baseline.RoundTrip, c => + string? nothing = framework is not null && Sides.Alike(framework.Answer, three) ? "the framework refuses it: " + three : null; + fails.Add(Sides.Check(r, Obligation, Baseline.RoundTrip, framework, three, three.Refused || two.Refused ? first : null, three.Refused || two.Refused ? two : null, c => { - string? None(string why) { nothing = why; return Alone(c, why); } - if (xp is not null && xv is not null) return None("write"); + if (xp is not null && xv is not null) { nothing = "write"; return c.Inconclusive($"JSON write: both refuse, not alike: the wrapped type {w2}, the value object {w3}"); } if (xp is not null) { nothing = "the wrapped type is not written"; return c.Inconclusive("JSON write: the wrapped type is not written under these options, the value object is: JSON parity (L01) judges it"); } if (xv is not null) { if (Guarded(xv)) { nothing = "the Preserve guard refuses the value object"; return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "write"); } - if (OwnConverterRefusedToo(xv)) return None("write, with a converter of the application's for the wrapped type"); return $"JSON: the value object is not written where the wrapped type is ({xv.GetType().Name}: {Cut(xv.Message)})"; } - var (back, xb) = Read(jv!, hv, o); - if (xb is not null) + if (three.Refused) { - if (OwnConverterRefusedToo(xb)) return None("read back, with a converter of the application's for the wrapped type"); - if (Read(jp!, hp, o).Ex is not null) return None("read back"); - return $"JSON: the value object does not read back what it writes ({xb.GetType().Name}: {Cut(xb.Message)}): {Cut(jv)}"; + if (two.Refused) { nothing = "read back"; return c.Inconclusive($"JSON read back: both refuse, not alike: the wrapped type {two}, the value object {three}"); } + return $"JSON: the value object does not read back what it writes ({three.Thrown!.GetType().Name}: {Cut(three.Thrown.Message)}): {Cut(jv)}"; } - var again = Write(new Held(hv.Declared, back!), o).Json; - if (again != jv) return $"JSON: read back as {Cut(again)}, written as {Cut(jv)}"; + if (three.Value != jv) return $"JSON: read back as {Cut(three.Value)}, written as {Cut(jv)}"; if (c.Is("json.Converters", "own-for-value-object")) c.Note(NoteKeys.OwnVo, "the application's converter takes precedence and reads its own spelling"); else c.Note(NoteKeys.Compared, "JSON read back"); return null; @@ -616,21 +609,26 @@ bool OwnConverterRefusedToo(Exception voEx) if (!validated || jp is null) return fails; // 2. the value Create refuses, placed where the wrapped value stands in the wrapped type's document var text = JsonParity.InputFor("refused-by-create", e, o, jp, pos); - var (accepted, xr) = Read(text, hv, o); - var twinReads = Read(text, hp, o).Ex is null; // the wrapped type reads the refused value's document: only then is a refusal Create's + var xr = Thrown(() => JsonSerializer.Deserialize(text, hv.Declared, o)); + var r1 = new Sides.Answer("read", null, Thrown(() => JsonSerializer.Deserialize(text, hp.Declared, one))); + var twinReads = Thrown(() => JsonSerializer.Deserialize(text, hp.Declared, o)) is null; // the wrapped type reads the refused value's document: only then is a refusal Create's + var r3 = new Sides.Answer("read", null, xr); var stored = r.Is("json.Converters", "StoredJsonConverterFactory"); - fails.Add(r.Check(Obligation, stored ? Baseline.Docs("README.md#what-typekit-guarantees") : Baseline.Refusal, c => + var ownVo = r.Is("json.Converters", "own-for-value-object"); + // the twin without TypeKit refuses the refused value's document alike: the serializer's refusal, before Create + var refusedAlike = !twinReads && !ownVo && Sides.Alike(r1, r3) ? Sides.Measured(r1, "the twin reading the same text, less TypeKit's converters") : null; + fails.Add(Sides.Check(r, Obligation, stored ? Baseline.Docs("README.md#what-typekit-guarantees") : Baseline.Refusal, refusedAlike, r3, null, null, c => { // "Your own converter wins": the generated converter does not run, so no Create; the round trip above runs under it, which // shares the obligation, so it is decided here, not declared in claims.json - if (c.Is("json.Converters", "own-for-value-object")) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "the refused value"); + if (ownVo) return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says"), "the refused value"); if (xr is not null && Guarded(xr)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "the refused value"); if (nothing is not null) return c.Inconclusive($"the refused value: the value object's own document has nothing to compare under these options ({nothing})"); if (text == jp) return c.Inconclusive($"the refused value has no place in {Cut(jp)} at {pos}"); if (!twinReads) { if (xr is null) return $"JSON read accepted text the wrapped type refuses: {Cut(text)}"; - return Alone(c, "the refused value's JSON"); + return c.Inconclusive($"the refused value's JSON: both refuse, not alike: the wrapped type {r1}, the value object {r3}"); } if (stored) { @@ -647,12 +645,12 @@ bool OwnConverterRefusedToo(Exception voEx) c.Note(NoteKeys.Compared, "the refused value refused"); return null; })); - if (xr is JsonException && twinReads && !stored && nothing is null && !r.Is("json.Converters", "own-for-value-object") && text != jp) + if (xr is JsonException && twinReads && !stored && nothing is null && !ownVo && text != jp) fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => { - if (xr.Message.Contains("Refused")) { c.Note(NoteKeys.Compared, "the JsonException names the fault"); return null; } - if (OwnConverterRefusedToo(xr)) return Alone(c, "refused read, with a converter of the application's for the wrapped type"); - return $"JsonException does not name the fault: {Cut(xr.Message)}\n text={Cut(text)} (the wrapped type reads it; its own document {Cut(jp)})"; + if (!xr.Message.Contains("Refused")) return $"JsonException does not name the fault: {Cut(xr.Message)}\n text={Cut(text)} (the wrapped type reads it; its own document {Cut(jp)})"; + c.Note(NoteKeys.Compared, "the JsonException names the fault"); + return null; })); return fails; } diff --git a/tools/probe/corpus/probes/C06FaultKindsProbe.cs b/tools/probe/corpus/probes/C06FaultKindsProbe.cs index 584a9377..6b7fea30 100644 --- a/tools/probe/corpus/probes/C06FaultKindsProbe.cs +++ b/tools/probe/corpus/probes/C06FaultKindsProbe.cs @@ -30,7 +30,11 @@ public sealed class C06FaultKindsProbe static string? NoKindRefused() { try { new ServiceCollection().AddFaultResponses(); } - catch (ArgumentException ex) when (ex.Message.Contains("FxNoKind.Blank", StringComparison.Ordinal)) { return null; } + catch (ArgumentException ex) + { + if (!ex.Message.Contains("FxNoKind.Blank", StringComparison.Ordinal)) return $"AddFaultResponses() refused, but not with an ArgumentException naming FxNoKind.Blank: {ex.GetType().Name}: {ex.Message}"; + return null; + } catch (Exception ex) { return $"AddFaultResponses() refused, but not with an ArgumentException naming FxNoKind.Blank: {ex.GetType().Name}: {ex.Message}"; } return "AddFaultResponses() took FxNoKind.Blank, a member of no kind"; } diff --git a/tools/probe/corpus/probes/C18Cmtk1023Probe.cs b/tools/probe/corpus/probes/C18Cmtk1023Probe.cs index ba45b2fb..6be2d049 100644 --- a/tools/probe/corpus/probes/C18Cmtk1023Probe.cs +++ b/tools/probe/corpus/probes/C18Cmtk1023Probe.cs @@ -211,8 +211,8 @@ static void Ran(Probes.Shapes.Check c, Shape s) if (!d.Message.StartsWith($"'{Nesting(s, id)}V{id}' inherits ", StringComparison.Ordinal)) return "CMTK1023 does not name the value object: " + d.Message; var named = Named(s, id); - var missing = named.Where(n => !d.Message.Contains(n, StringComparison.Ordinal)).ToList(); - if (missing.Count > 0) return $"CMTK1023 does not name {string.Join(", ", missing)}: {d.Message}"; + foreach (var name in named) + if (!d.Message.Contains(name, StringComparison.Ordinal)) return $"CMTK1023 does not name {name}: {d.Message}"; var count = System.Text.RegularExpressions.Regex.Matches(d.Message, " in its base type ").Count; if (count != named.Count) return $"CMTK1023 names {count} members, the bases declare {named.Count} ({string.Join(", ", named)}): {d.Message}"; if (d.Message.Contains("Mark", StringComparison.Ordinal)) return "CMTK1023 names the explicit member of another interface: " + d.Message; diff --git a/tools/probe/corpus/registry.json b/tools/probe/corpus/registry.json index c4c1f763..808e4805 100644 --- a/tools/probe/corpus/registry.json +++ b/tools/probe/corpus/registry.json @@ -11,6 +11,15 @@ "PROBE_ONLY": "process.converter-lookup=wrapped-type-converter-looked-up-first" } ], + "watched": [ + "design/knowledge/KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md", + "design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md", + "design/knowledge/KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md", + "test/CodoMetis.TypeKit.Facts/FactFixture.cs", + "test/CodoMetis.TypeKit.Facts/KB037.fixture.json", + "test/CodoMetis.TypeKit.Facts/KB040.fixture.json", + "test/CodoMetis.TypeKit.Facts/KB041.fixture.json" + ], "seed": { "patch": "seeds/C07-fault-responses-blanket.patch", "defect": "TryParse builds a validated value object without Create, so binding a route, query, header or form value takes a value Create refuses." @@ -344,6 +353,11 @@ "C10PreserveGuardProbe": { "claim": "C10-preserve-guard", "filter": "*C10PreserveGuardProbe", + "watched": [ + "design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md", + "test/CodoMetis.TypeKit.Facts/FactFixture.cs", + "test/CodoMetis.TypeKit.Facts/KB040.fixture.json" + ], "seeds": [ { "obligation": "refused-where-references-could-be-written", @@ -1141,6 +1155,11 @@ "L01JsonBytesProbe": { "claim": "L01-json-bytes", "filter": "*L01JsonBytesProbe", + "watched": [ + "design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md", + "test/CodoMetis.TypeKit.Facts/FactFixture.cs", + "test/CodoMetis.TypeKit.Facts/KB040.fixture.json" + ], "seeds": [ { "obligation": "as-a-dictionary-key-too", @@ -1207,6 +1226,11 @@ "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT": "1" } ], + "watched": [ + "design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md", + "test/CodoMetis.TypeKit.Facts/FactFixture.cs", + "test/CodoMetis.TypeKit.Facts/KB040.fixture.json" + ], "seeds": [ { "obligation": "parsing-runs-create", @@ -1245,6 +1269,11 @@ "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT": "1" } ], + "watched": [ + "design/knowledge/KB-040-a-converter-is-charged-a-level-of-max-depth.md", + "test/CodoMetis.TypeKit.Facts/FactFixture.cs", + "test/CodoMetis.TypeKit.Facts/KB040.fixture.json" + ], "seeds": [ { "obligation": "invariant-whatever-the-culture", From abd3238f3d0ba6017fea72f64b0abffdabc4573f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 14:04:24 +0200 Subject: [PATCH 5/5] test: no probe row is decided by a message's words but a failure ProbeMessageTextTests in the conventions tests reads the probe corpus's syntax: a .Message.Contains(, StartsWith(, EndsWith( or IndexOf( stands only in the condition of a failure text (an if whose branch returns one or raises a harness error, a ?: between null and one), never in an assignment, a catch filter or a branch that passes. A positive control and a floor of 30 reads (33 when written) keep it from passing on a walker that finds nothing. The EF family stays two-sided in this step: its one excuse by a message (E06's "could not be translated") is listed with its reason, and the list fails once the site is gone. Co-Authored-By: Claude Opus 5.5 --- .../ProbeMessageTextTests.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 test/CodoMetis.TypeKit.Conventions.Tests/ProbeMessageTextTests.cs diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ProbeMessageTextTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ProbeMessageTextTests.cs new file mode 100644 index 00000000..c0e67120 --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ProbeMessageTextTests.cs @@ -0,0 +1,148 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// No probe decides how a row ends by the words of a message, except to fail it: in the probe corpus (harness and probes), a +/// .Message.Contains(, StartsWith(, EndsWith( or IndexOf( stands only in the condition of a failure text, +/// an if whose branch returns one or raises a harness error, or a ?: between null and one. A refusal the framework +/// makes on its own is shown by side (1) of the three-sided differential (the harness's Sides), compared by its type and stage; +/// an intended difference by a citation. A message read to excuse a row, the substring excuse decision 99 retired, is refused here. +/// +/// +/// It reads syntax, as does: a lambda inside the condition counts as the condition (errors.Any(e => +/// e.Message.StartsWith(...))), an assignment, a catch ... when or a branch that passes does not. The EF family stays +/// two-sided in WS2's last step and is not changed by it: its one excuse by a message is listed in with its reason, +/// and a listed site that no longer reads a message fails, so the list cannot outlive it. +/// +public sealed class ProbeMessageTextTests +{ + private static string Corpus => Path.Combine(Repository.Root, "tools", "probe", "corpus"); + + private static readonly string[] Reads = ["Contains", "StartsWith", "EndsWith", "IndexOf"]; + + /// The sites kept, by file, each with why: the EF family is two-sided by decision of WS2's last step. + private static readonly Dictionary Kept = new() + { + ["tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs"] = + "EF stays two-sided in WS2's last step: EF's 'could not be translated' refusal is matched by its words until the EF family gets its side (1)", + }; + + [Fact] + public void The_corpus_reads_of_a_message_are_found() => + Sources().Sum(source => Sites(source.Text).Count()).ShouldBeGreaterThanOrEqualTo(30, + "the corpus read a message's words at 33 places when this rule was written (WS2's three-sided differential), all but the one EF site a failure's condition"); + + [Fact] + public void No_message_text_decides_a_row_but_a_failure() => + Sources().Where(source => !Kept.ContainsKey(source.Path)).SelectMany(source => Problems(source.Path, source.Text)).ShouldBeEmpty(); + + [Fact] + public void Each_kept_site_still_reads_a_message_outside_a_failure() => + Kept.Keys.Where(path => !Problems(path, File.ReadAllText(Path.Combine(Repository.Root, path))).Any()).ShouldBeEmpty(); + + [Fact] + public void A_message_read_to_excuse_a_row_is_found() + { + const string source = + """ + class P + { + string? Check(Check c, Exception x, Exception? y, string[] names) + { + if (x.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth); return null; } + if (!x.Message.Contains("Refused")) return $"does not name the fault: {x.Message}"; + if (x.InnerException is { } i && i.Message.Contains("secret")) return "inner quotes the input"; + if (!x.Message.StartsWith("V") || x.Message.EndsWith(".")) return "not named: " + x.Message; + if (names.Any(n => !x.Message.Contains(n))) { r.Outcome("harness-error", "not named"); return null; } + var named = x.Message.Contains("V"); + try { throw x; } catch (Exception e) when (e.Message.Contains("could not be translated")) { return null; } + var missing = names.Where(n => !x.Message.Contains(n)).ToList(); + return x.Message.Contains("V") ? null : $"not named: {x.Message}"; + // x.Message.Contains("in a comment") is no call + } + } + """; + Problems("P.cs", source).ShouldBe( + [ + "P.cs:5: x.Message.Contains(\"maximum allowed depth\") decides a row by a message's words outside a failure", + "P.cs:10: x.Message.Contains(\"V\") decides a row by a message's words outside a failure", + "P.cs:11: e.Message.Contains(\"could not be translated\") decides a row by a message's words outside a failure", + "P.cs:12: x.Message.Contains(n) decides a row by a message's words outside a failure", + ]); + } + + private static List<(string Path, string Text)> Sources() => + [ + .. Directory.EnumerateFiles(Corpus, "*.cs", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(Repository.Root, path).Replace('\\', '/'), File.ReadAllText(path))) + ]; + + /// Every X.Message.Contains(...) and its kin in a source. + private static IEnumerable Sites(string text) => + CSharpSyntaxTree.ParseText(text).GetRoot().DescendantNodes().OfType() + .Where(call => call.Expression is MemberAccessExpressionSyntax + { + Name.Identifier.ValueText: var read, + Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Message" }, + } && Reads.Contains(read)); + + private static IEnumerable Problems(string path, string text) + { + foreach (var call in Sites(text)) + if (!InAFailuresCondition(call)) + yield return $"{path}:{call.GetLocation().GetLineSpan().StartLinePosition.Line + 1}: {call} decides a row by a message's words outside a failure"; + } + + /// + /// The read stands in the condition of an if whose branch fails the row (returns a failure text, or raises a harness error), or + /// of a ?: between null and a failure text. A lambda on the way up is part of that condition. + /// + private static bool InAFailuresCondition(SyntaxNode read) + { + for (var node = read; node.Parent is { } parent; node = parent) + { + switch (parent) + { + case IfStatementSyntax branch when branch.Condition == node: + return Fails(branch.Statement); + case ConditionalExpressionSyntax choice when choice.Condition == node: + return (IsNull(choice.WhenTrue) && IsFailure(choice.WhenFalse)) || (IsNull(choice.WhenFalse) && IsFailure(choice.WhenTrue)); + case StatementSyntax: + case CatchFilterClauseSyntax: + return false; + } + } + + return false; + } + + /// A branch that fails the row: it returns a failure text, or raises a harness error, first. + private static bool Fails(StatementSyntax statement) => statement switch + { + ReturnStatementSyntax { Expression: { } returned } => IsFailure(returned), + BlockSyntax { Statements: [var first, ..] } => Fails(first), + ExpressionStatementSyntax { Expression: InvocationExpressionSyntax raise } => RaisesAHarnessError(raise), + _ => false, + }; + + private static bool RaisesAHarnessError(InvocationExpressionSyntax call) => + call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Outcome" } + && call.ArgumentList.Arguments is [{ Expression: LiteralExpressionSyntax { Token.ValueText: "harness-error" } }, ..]; + + /// A failure text: a string, an interpolated one, a concatenation with one, or a ?: one of whose branches is one. + private static bool IsFailure(ExpressionSyntax expression) => expression switch + { + LiteralExpressionSyntax literal => literal.IsKind(SyntaxKind.StringLiteralExpression), + InterpolatedStringExpressionSyntax => true, + BinaryExpressionSyntax add when add.IsKind(SyntaxKind.AddExpression) => IsFailure(add.Left) || IsFailure(add.Right), + ConditionalExpressionSyntax choice => IsFailure(choice.WhenTrue) || IsFailure(choice.WhenFalse), + ParenthesizedExpressionSyntax inner => IsFailure(inner.Expression), + _ => false, + }; + + private static bool IsNull(ExpressionSyntax expression) => expression.IsKind(SyntaxKind.NullLiteralExpression); +}