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/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 052b094b..c14f9f18 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -68,3 +68,5 @@ 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` | +| [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.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); +} 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" + } + ] +} 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" + } + ] +} 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 @@ + + + + + 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",