diff --git a/design/knowledge/KB-042-a-char-schema-is-refused-under-write-as-string.md b/design/knowledge/KB-042-a-char-schema-is-refused-under-write-as-string.md new file mode 100644 index 0000000..f04120f --- /dev/null +++ b/design/knowledge/KB-042-a-char-schema-is-refused-under-write-as-string.md @@ -0,0 +1,30 @@ +--- +verified: 2026-10-18 +pins: + - Microsoft.AspNetCore.OpenApi 10.0.12 + - sdk 10.0.4xx +source: the document Microsoft.AspNetCore.OpenApi writes for a minimal API whose body holds a char, and one whose body holds an int, under JsonNumberHandling.WriteAsString and the default number handling, measured in a host without TypeKit; ASP.NET Core 10's OpenApiSchemaService builds each schema with System.Text.Json's JsonSchemaExporter under the host's options and reads the node back as its own OpenApiJsonSchema, and a char's schema is the exporter's minLength and maxLength of 1 +turned-on: row 408 of C21OpenapiSchemaParityProbe (claim C21-openapi-schema-parity), under json.NumberHandling=WriteAsString with json.MaxDepth=3, where AddTypeKit() refused the document with this JsonException, asking for the char a value object wraps, and ASP.NET alone with MaxDepth's InvalidOperationException, met first at another endpoint +test: test/CodoMetis.TypeKit.Facts/KB042.cs +fixture: test/CodoMetis.TypeKit.Facts/KB042.fixture.json +--- + +# KB-042. Under `NumberHandling` with `WriteAsString`, ASP.NET refuses the OpenAPI document of an endpoint whose schema holds a `char` + +ASP.NET builds a schema with System.Text.Json's schema exporter, under the host's JSON options, and reads the node +back as its own `OpenApiJsonSchema`. A `char`'s schema is a string of `minLength` and `maxLength` 1. With +`JsonSerializerOptions.NumberHandling` set to `WriteAsString`, those two numbers reach the reader as strings, and +generating the document fails with a `JsonException`, "The JSON value could not be converted to OpenApiJsonSchema", +whose inner exception cannot read a string token as a number. A host without TypeKit does so for any endpoint +whose body holds a `char`. A number's schema is written under the same option, and a `char`'s under the default +number handling. + +| A minimal API's body of `Holder` | `NumberHandling = WriteAsString` | the default number handling | +|---|---|---| +| `T` is `char` | `JsonException`, the document is not written | `{"maxLength":1,"minLength":1,"type":"string","format":"char"}` | +| `T` is `int` | `{"pattern":"^-?(?:0\|[1-9]\\d*)$","type":["integer","string"],"format":"int32"}` | (not measured) | + +A value object over `char` is described with that same schema, asked of ASP.NET, so under this option its document +is refused as the wrapped type's is. Which of two refusals a document meets first follows the order ASP.NET visits +its endpoints and schemas in, so under a second refusing option (`MaxDepth`) a host with TypeKit, which asks for the +`char` while it describes the value object, can meet this one where ASP.NET alone meets the other. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index c14f9f1..cdcad74 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -70,3 +70,4 @@ test and later for the harness's `Baseline.Fact`. | [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` | +| [KB-042](KB-042-a-char-schema-is-refused-under-write-as-string.md) | Under `NumberHandling` with `WriteAsString`, ASP.NET refuses the OpenAPI document of an endpoint whose schema holds a `char` | OpenApi 10.0.12, SDK 10.0.4xx | `KB042` | diff --git a/design/roadmap.md b/design/roadmap.md index 1729c07..716f8ba 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -11,12 +11,8 @@ refusal may relax in any minor version; an Error that reports more waits for a m ([decision 97](adr/0097-2-2-0-is-a-consolidation-release-and-the-gate-binds.md)). Any gap below that is a defect, a cost or a missing guard may land here; one that adds surface waits for 2.3.0. -- Grow the probe corpus until `prober gate` passes on probes and reviewed waivers alone. Every claim - touched since v2.1.0 is covered pairwise, and no finding is open. - The probing harness (WS2 of the agentic issue-finding concept) is done - ([decision 108](adr/0108-a-probe-check-earns-credit-against-a-baseline-and-ws2-closes.md)). What it leaves the gate: the pairs of C12, C13, C16 and C21 that only a document - ASP.NET alone refuses had credited (a declaration of the document obligations' inapplicability under - `json.ReferenceHandler=Preserve` and `json.MaxDepth=3`, and rows for the rest), and C22's empty footprint. + ([decision 108](adr/0108-a-probe-check-earns-credit-against-a-baseline-and-ws2-closes.md)). WS3 (beyond pairs), WS4 (the mutation nightly), the second half of WS5 (the finder benchmark at equal budgets) and WS6 (PostgreSQL in CI) were dropped on 2026-10-17 by the maintainer's decision, and are not scheduled. - The generated-surface snapshot under "Generators" below. diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 58584d9..213386c 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -118,8 +118,9 @@ element schema, so `Quantity` gets no component at all, and both parameters are OpenAPI 2.0 can't put null around a reference. ASP.NET writes a nullable property of any component as `{"allOf":[{"x-nullable":true}]}` there, without the reference. So under 2.0 a nullable value-object - property loses its type, where the underlying type's nullable property keeps it. Inline value objects - if your 2.0 document needs it. + property loses its type, where the underlying type's nullable property keeps it. A nullable element of + a list or a dictionary loses its type the same way, since it's the component or null too. Inline value + objects if your 2.0 document needs it. - **Put rules in `Create`, not on the property.** A validation attribute on a value-object property (`[MaxLength]`, `[Range]`) ends up in the value object's shared component, as ASP.NET does for any @@ -160,7 +161,8 @@ source-generated JSON can do, because the patch's converter hides the value obje **A described type.** ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a -dictionary's `additionalProperties` of that type. +dictionary's `additionalProperties` of that type. The same goes for a converter a library registers for +its own type, such as NodaTime's for `Instant`. Two things fix that, and the document then describes the type as they say: diff --git a/test/CodoMetis.TypeKit.Facts/KB042.cs b/test/CodoMetis.TypeKit.Facts/KB042.cs new file mode 100644 index 0000000..df2524b --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB042.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-042: under JsonSerializerOptions.NumberHandling with WriteAsString, ASP.NET refuses the OpenAPI document of +/// an endpoint whose schema holds a char, with a JsonException reading the schema back as its OpenApiJsonSchema: the +/// exporter's minLength and maxLength of a char are written as strings. A number's schema is written, and so is a +/// char's under the default number handling. +/// +public sealed class KB042 +{ + public sealed class Holder + { + public T P { get; set; } = default!; + } + + private static readonly FactFixture Fixture = FactFixture.Beside("KB-042"); + + public static TheoryData Answers() => [.. Fixture.Answers.Select(answer => answer.Id)]; + + /// The document of a minimal API whose one endpoint takes a body of Holder<T> for the answer's type. + private static async Task DocumentAsync(IReadOnlyDictionary when) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.ConfigureHttpJsonOptions(options => + { + if (when["json.NumberHandling"] == "WriteAsString") options.SerializerOptions.NumberHandling = JsonNumberHandling.WriteAsString; + }); + builder.Services.AddOpenApi(); + + await using var app = builder.Build(); + _ = when["shape.wrapped"] switch + { + "char" => app.MapPost("/kb042", ([FromBody] Holder body) => Results.Ok()), + "int" => app.MapPost("/kb042", ([FromBody] Holder body) => Results.Ok()), + var other => throw new ArgumentOutOfRangeException(nameof(when), other, "the fixture's types are char and int"), + }; + app.MapOpenApi(); + await app.StartAsync(TestContext.Current.CancellationToken); + + var text = await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken); + return JsonNode.Parse(text)!; + } + + /// Each answer of KB042.fixture.json: the document of a body holding the answer's type, under the number handling it names. + [Theory, MemberData(nameof(Answers))] + public async Task ASP_NET_writes_or_refuses_the_document_as_the_fixture_holds(string id) + { + var answer = Fixture[id]; + + if (answer.Kind == FactAnswer.Exception) + { + var thrown = await Should.ThrowAsync(() => DocumentAsync(answer.When)); + answer.Mismatch(thrown).ShouldBeNull(); + } + else + { + answer.Mismatch(await DocumentAsync(answer.When)).ShouldBeNull(); + } + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB042.fixture.json b/test/CodoMetis.TypeKit.Facts/KB042.fixture.json new file mode 100644 index 0000000..f24316c --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB042.fixture.json @@ -0,0 +1,54 @@ +{ + "fact": "KB-042", + "answers": [ + { + "id": "char-under-write-as-string", + "kind": "exception", + "when": { + "json.NumberHandling": "WriteAsString", + "shape.wrapped": "char" + }, + "value": { + "type": "System.Text.Json.JsonException", + "operation": "document", + "stage": "reading the char's schema back as ASP.NET's OpenApiJsonSchema", + "message": "could not be converted to OpenApiJsonSchema" + }, + "note": "a minimal API's body holding a char, under NumberHandling WriteAsString: the document is refused" + }, + { + "id": "char-by-default", + "kind": "document", + "when": { + "json.NumberHandling": "default", + "shape.wrapped": "char" + }, + "at": "/components/schemas/HolderOfchar/properties/p", + "value": { + "maxLength": 1, + "minLength": 1, + "type": "string", + "format": "char" + }, + "note": "the same body under the default number handling: written" + }, + { + "id": "int-under-write-as-string", + "kind": "document", + "when": { + "json.NumberHandling": "WriteAsString", + "shape.wrapped": "int" + }, + "at": "/components/schemas/HolderOfint/properties/p", + "value": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "note": "a body holding an int under NumberHandling WriteAsString: written" + } + ] +} diff --git a/test/CodoMetis.TypeKit.Prober.Tests/RowsPerProbeTests.cs b/test/CodoMetis.TypeKit.Prober.Tests/RowsPerProbeTests.cs index f3e5a89..c52e5b5 100644 --- a/test/CodoMetis.TypeKit.Prober.Tests/RowsPerProbeTests.cs +++ b/test/CodoMetis.TypeKit.Prober.Tests/RowsPerProbeTests.cs @@ -236,6 +236,47 @@ public void A_change_to_a_watched_path_changes_the_footprint_since_a_release() ledger.FootprintChangedSince("P", "v1", ledger.Footprints()).ShouldBeTrue(); } + /// A replay's record of a probe that runs nothing under src/ in its own process, as C22, which builds its consumer in a child process. + private (int Exit, string Output, string Error) RanNothingUnderSrc() + { + var report = Path.Combine(fx.Root, "coverage.cobertura.xml"); + File.WriteAllText(report, $"""{fx.Root}"""); + var result = fx.Ledger("footprint", "--probe", "P", "--root", fx.Root, "--cobertura", report); + File.Delete(report); + return result; + } + + [Fact] + public void A_probe_that_ran_nothing_under_src_is_recorded_by_what_it_watches() + { + Registry(watched: true); + fx.Write(Catalogue, "class Catalogue {}\n"); + fx.Commit("a probe watching a catalogue, building in a child process"); + var (exit, output, error) = RanNothingUnderSrc(); + exit.ShouldBe(0, output + error); + var line = JsonNode.Parse(File.ReadLines(Path.Combine(fx.BranchDir, "footprints.jsonl")).Last())!; + line["footprint"]!.AsObject().Count.ShouldBe(0); + line["watched"]![Catalogue]!.GetValue().ShouldBe(fx.Sh("git", "rev-parse", $"HEAD:{Catalogue}").Trim()); + fx.Ledger("record-run", "--probe", "P", "--result", "pass", "--evidence", fx.Evidence([R("1", "1"), R("2", "2")])).Exit.ShouldBe(0); + Stale().ShouldBeFalse("what P watches is unchanged"); + fx.Covered().ShouldBe(2); + ChangeTheCatalogue(); + Stale().ShouldBeTrue("the catalogue P watches changed"); + fx.Covered().ShouldBe(0); + } + + [Fact] + public void A_probe_that_ran_nothing_under_src_and_watches_nothing_is_refused() + { + Registry(watched: false); + fx.Write(Catalogue, "class Catalogue {}\n"); + fx.Commit("a probe watching nothing, building in a child process"); + var (exit, output, error) = RanNothingUnderSrc(); + exit.ShouldBe(1, output + error); + (output + error).ShouldContain("refusing to record an empty footprint"); + File.Exists(Path.Combine(fx.BranchDir, "footprints.jsonl")).ShouldBeFalse(); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tools/Prober/Commands.cs b/tools/Prober/Commands.cs index abfd741..91cc7df 100644 --- a/tools/Prober/Commands.cs +++ b/tools/Prober/Commands.cs @@ -352,13 +352,17 @@ public static int Footprint(Ledger l, Args a) } } } - if (hit.Count == 0) - throw new Refusal("the coverage report shows no executed file under " + string.Join(", ", include) + "; refusing to record an empty footprint (it would never go stale)"); + // A probe whose own process runs nothing under the include paths (one that builds a consumer in a child process, as C22 + // does) is recorded by what its registry entry watches; with nothing watched either, the footprint would never go stale. + var watchedPaths = l.Watched(a.Need("probe")); + if (hit.Count == 0 && watchedPaths.Count == 0) + throw new Refusal("the coverage report shows no executed file under " + string.Join(", ", include) + + " and the registry entry watches nothing; refusing to record an empty footprint (it would never go stale): name what the probe depends on in its entry's watched"); var rev = Git.Out(worktree, "rev-parse", "HEAD"); var footprint = new JsonObject(hit.Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p)))); var compileTime = new JsonObject(a.All("compile-time").Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p)))); // What the probe's registry entry watches beyond what ran: recorded with the ids it ran at, so a change stales it. - var watched = new JsonObject(l.Watched(a.Need("probe")).Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p)))); + var watched = new JsonObject(watchedPaths.Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p)))); Files.AppendLine(l.P("footprints.jsonl"), new JsonObject { ["schemaVersion"] = Files.SchemaVersion, ["probe"] = a.Need("probe"), ["commit"] = rev, ["footprint"] = footprint, diff --git a/tools/Prober/Ledger.cs b/tools/Prober/Ledger.cs index 70e3cff..5067324 100644 --- a/tools/Prober/Ledger.cs +++ b/tools/Prober/Ledger.cs @@ -209,13 +209,13 @@ public Dictionary Footprints() => /// /// A probe's footprint is current when every path it ran, every compile-time path, every path its registry entry /// watches and the toolchain are unchanged at HEAD. A watched path the footprint did not record (one added to the - /// entry since) is not current. + /// entry since) is not current. A footprint that ran nothing under src/ is current only by watched paths it recorded. /// public bool FootprintCurrent(string probe, Dictionary footprints) { if (currentCache.TryGetValue(probe, out var cached)) return cached; var ok = false; - if (footprints.TryGetValue(probe, out var r) && r["footprint"] is JsonObject fp && fp.Count > 0) + if (footprints.TryGetValue(probe, out var r) && r["footprint"] is JsonObject fp && (fp.Count > 0 || r["watched"] is JsonObject { Count: > 0 })) { var watched = r["watched"] as JsonObject ?? []; ok = Watched(probe).All(watched.ContainsKey) diff --git a/tools/Prober/README.md b/tools/Prober/README.md index c957ffa..3abf10f 100644 --- a/tools/Prober/README.md +++ b/tools/Prober/README.md @@ -57,7 +57,9 @@ footprint cannot see, since the footprint is what ran in the probe's own process over the analyzer tests' `TypeArgumentSites` and `TypeArgumentKinds`), a build input a child process reads is another (C18's consumer build compiles against the base package and evaluates `src/Directory.Build.*`). The replay records each with its object id in the footprint line, a change to one stales the footprint like a path -the probe ran, and the gate counts it as touching the probe's claim. +the probe ran, and the gate counts it as touching the probe's claim. A probe whose own process runs nothing under `src/` +(C22 builds its consumer in a child process) is recorded by what its entry watches alone; where nothing ran and nothing +is watched, `footprint` refuses to record an empty footprint, which would never go stale. The round lock, `round.lock` and its mutex, sits in the branch's directory (`branches//`), beside everything a round or a replay writes, so rounds and replays of two branches run at once and two of one branch diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index 05eb1ed..301b96a 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -582,6 +582,21 @@ ] } } + ], + "exclude": [ + { + "reason": "Not promised (as-text-refused-in-globalization-invariant-mode): `AsText()` throws `PlatformNotSupportedException` in globalization-invariant mode (docs/text.md#globalization-invariant-mode), and `AsOptionalText()`, which calls it, throws with it, so C02OptionalTextProbe checks that refusal without credit and runs the row on the identifier counterpart; no row holds the pair.", + "pairs": [ + [ + "entry=AsText", + "text.culture=globalization-invariant" + ], + [ + "entry=AsOptionalText", + "text.culture=globalization-invariant" + ] + ] + } ] }, { @@ -629,6 +644,21 @@ "id": "unmapped-set-stops-start", "sentence": "The host doesn't start while either set is unmapped." } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + } ] }, { @@ -799,6 +829,21 @@ ] } } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + } ] }, { @@ -6392,7 +6437,29 @@ "IDescribedJsonConverter", "decimal-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "source": [ + "IDescribedJsonConverter", + "JsonSchemaDescriptions" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "options-describe", @@ -6401,11 +6468,55 @@ "source": [ "JsonSchemaDescriptions" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "source": [ + "IDescribedJsonConverter", + "JsonSchemaDescriptions" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "every-position", - "sentence": "With CodoMetis.TypeKit.AspNetCore, `AddTypeKit()` writes the same descriptions into the OpenAPI document, in every position, a list's elements and a dictionary's values included." + "sentence": "With CodoMetis.TypeKit.AspNetCore, `AddTypeKit()` writes the same descriptions into the OpenAPI document, in every position, a list's elements and a dictionary's values included.", + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "source": [ + "IDescribedJsonConverter", + "JsonSchemaDescriptions" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "parameter-keeps-aspnet", @@ -6414,7 +6525,29 @@ "source": [ "decimal-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "source": [ + "IDescribedJsonConverter", + "JsonSchemaDescriptions" + ] + }, + "cite": "record:0099" + } + ] } ], "exclude": [ @@ -6463,11 +6596,31 @@ "json.RespectRequiredConstructorParameters": [ "true" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "doc-optional-where-absent", - "sentence": "`AddTypeKit()` leaves a patch out of `required` where the serializer lets it be absent, and keeps it there where the options reject a body without it." + "sentence": "`AddTypeKit()` leaves a patch out of `required` where the serializer lets it be absent, and keeps it there where the options reject a body without it.", + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "absent-is-default", @@ -6654,7 +6807,17 @@ "both", "controller-without-ApiController-visible" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "error-names-type-and-descriptions", @@ -6668,7 +6831,17 @@ "both", "controller-without-ApiController-visible" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "patch-held-refused", @@ -6682,7 +6855,17 @@ "both", "controller-without-ApiController-visible" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "same-converters-build", @@ -6692,7 +6875,17 @@ "naming-policy", "number-handling" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] }, { "id": "minimal-host-not-compared", @@ -6705,7 +6898,17 @@ "minimal", "route-group" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + } + ] } ], "not-promised": [ @@ -11478,6 +11681,36 @@ ] }, "cite": "not-promised:own-converter-for-the-wrapped-type" + }, + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.NumberHandling": [ + "WriteAsString" + ], + "shape.wrapped": [ + "char" + ] + }, + "cite": "fact:KB-042" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "shape.wrapped": [ + "own-record-class" + ] + }, + "cite": "record:0099" } ] }, @@ -11505,6 +11738,36 @@ ] }, "cite": "not-promised:own-converter-for-the-wrapped-type" + }, + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.NumberHandling": [ + "WriteAsString" + ], + "shape.wrapped": [ + "char" + ] + }, + "cite": "fact:KB-042" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "shape.wrapped": [ + "own-record-class" + ] + }, + "cite": "record:0099" } ] }, @@ -11536,6 +11799,36 @@ ] }, "cite": "not-promised:own-converter-for-the-wrapped-type" + }, + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.NumberHandling": [ + "WriteAsString" + ], + "shape.wrapped": [ + "char" + ] + }, + "cite": "fact:KB-042" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "shape.wrapped": [ + "own-record-class" + ] + }, + "cite": "record:0099" } ] }, @@ -11563,6 +11856,36 @@ ] }, "cite": "not-promised:own-converter-for-the-wrapped-type" + }, + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.NumberHandling": [ + "WriteAsString" + ], + "shape.wrapped": [ + "char" + ] + }, + "cite": "fact:KB-042" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "shape.wrapped": [ + "own-record-class" + ] + }, + "cite": "record:0099" } ] }, @@ -11591,6 +11914,36 @@ ] }, "cite": "not-promised:own-converter-for-the-wrapped-type" + }, + { + "axes": { + "json.ReferenceHandler": [ + "Preserve" + ] + }, + "cite": "record:0099" + }, + { + "axes": { + "json.NumberHandling": [ + "WriteAsString" + ], + "shape.wrapped": [ + "char" + ] + }, + "cite": "fact:KB-042" + }, + { + "axes": { + "json.MaxDepth": [ + "3" + ], + "shape.wrapped": [ + "own-record-class" + ] + }, + "cite": "record:0099" } ] } @@ -11637,6 +11990,16 @@ ] } }, + { + "id": "a-nullable-value-object-admits-null", + "source": "design/adr/0036-nullable-value-object-admits-null-openapi.md#0036-a-nullable-value-object-admits-null-in-the-openapi-document-wherever-it-appears", + "sentence": "A nullable value object admits null in the OpenAPI document wherever it appears", + "axes": { + "http.nullability": [ + "nullable" + ] + } + }, { "id": "openapi-2-nullable-component-loses-its-type", "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", @@ -11686,6 +12049,50 @@ "validated-class" ] } + }, + { + "id": "enum-component-lists-the-null-of-its-first-use", + "source": "design/knowledge/KB-027-an-enum-component-is-its-first-use.md#kb-027-the-component-aspnet-makes-for-an-enum-written-by-name-is-the-schema-of-the-first-use-it-meets", + "sentence": "What the component says is whatever schema ASP.NET built for the first use it met: met first as a nullable parameter, the component lists null among its values, for its non-nullable uses too; met first as itself, it does not, for its nullable uses too.", + "axes": { + "json.Converters": [ + "JsonStringEnumConverter" + ], + "shape.wrapped": [ + "enum" + ] + } + }, + { + "id": "openapi-2-nullable-element-loses-its-type", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "sentence": "A nullable element of a list or a dictionary loses its type the same way, since it's the component or null too.", + "axes": { + "openapi.OpenApiVersion": [ + "OpenApi2_0" + ], + "openapi.CreateSchemaReferenceId": [ + "default", + "own-prefixing" + ], + "shape.position": [ + "list-element", + "array-element", + "immutable-array-element", + "read-only-list-element", + "dictionary-value" + ] + } + }, + { + "id": "a-librarys-converter-for-the-wrapped-type", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "sentence": "The same goes for a converter a library registers for its own type, such as NodaTime's for `Instant`.", + "axes": { + "shape.wrapped": [ + "NodaTime.Instant" + ] + } } ], "exclude": [ @@ -12660,6 +13067,25 @@ ] } } + ], + "exclude": [ + { + "reason": "C# defines no `>=` for a `string`, a `bool` or a `Uri`, so no query compares their `.Value` with it; E02ValueTranslatesProbe writes where-equals in its place there, so no row holds the pair.", + "pairs": [ + [ + "ef.query=where-value-compare", + "shape.wrapped=string" + ], + [ + "ef.query=where-value-compare", + "shape.wrapped=bool" + ], + [ + "ef.query=where-value-compare", + "shape.wrapped=Uri" + ] + ] + } ] }, { @@ -13874,6 +14300,21 @@ ] } } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + } ] }, { @@ -13949,6 +14390,16 @@ ] }, "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "text.entry": [ + "TryFrom", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#when-text-is-rejected" } ] }, @@ -14031,6 +14482,16 @@ ] }, "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "text.entry": [ + "TryFrom", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#when-text-is-rejected" } ] } @@ -14057,6 +14518,30 @@ ] } } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + }, + { + "reason": "System.Text.Json writes no dictionary key of a record class but through a converter of the application's (NotSupportedException for the wrapped type alike, L01's rows), where the JSON half is not promised (not-promised the-applications-own-converter), and a value object over a record class has no parsing (Generators README#what-is-generated), so no check of the claim can read the pair.", + "pairs": [ + [ + "shape.position=dictionary-key", + "shape.wrapped=own-record-class" + ] + ] + } ] }, { @@ -14179,6 +14664,21 @@ ] } } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + } ] }, { @@ -14335,6 +14835,21 @@ ] } } + ], + "exclude": [ + { + "reason": "System.Text.Json refuses PreferredObjectCreationHandling.Populate together with a ReferenceHandler on the options object; JsonRow substitutes the default, so the pair never applies.", + "pairs": [ + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=Preserve" + ], + [ + "json.PreferredObjectCreationHandling=Populate", + "json.ReferenceHandler=IgnoreCycles" + ] + ] + } ] }, { diff --git a/tools/probe/corpus/harness/HttpBaseline.cs b/tools/probe/corpus/harness/HttpBaseline.cs index ad6bd23..731f5d8 100644 --- a/tools/probe/corpus/harness/HttpBaseline.cs +++ b/tools/probe/corpus/harness/HttpBaseline.cs @@ -500,11 +500,25 @@ static bool NullOrReference(JsonNode? n) => 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. /// A schema that is the OpenAPI 2.0 null flag and nothing else: {"allOf":[{"x-nullable":true}]}. static bool OnlyTheNullFlag(JsonObject o) => o.Count == 1 && o["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag && flag["x-nullable"]?.GetValueKind() == JsonValueKind.True; + /// + /// The OpenAPI 2.0 null flag with the wrapped type's format beside it and nothing else, {"format":"uri","allOf":[{"x-nullable":true}]}: + /// a nullable component's property, which TypeKit gives the wrapped type's format (KB-026). + /// + static bool TheNullFlagBeside(JsonObject o) => + o.ContainsKey("format") && o.Count == 2 && OnlyTheNullFlag(new JsonObject { ["allOf"] = o["allOf"]?.DeepClone() }); + + /// The keys of a difference up to the property it lies in (…, properties, P), or null where it lies in none. + static IReadOnlyList? PropertyAt(IReadOnlyList keys) + { + for (var i = keys.Count - 2; i >= 0; i--) + if (keys[i] == "properties") return [.. keys.Take(i + 2)]; + return null; + } + /// /// OpenAPI 2.0's nullable property of a component (KB-026): allOf: [{x-nullable: true}], without the reference and so without a /// type, beside what TypeKit kept of the wrapped type (a format). @@ -513,9 +527,14 @@ static bool TheNullFlagWithoutItsReference(JsonObject o) => o["allOf"] is JsonArray { Count: 1 } all && all[0] is JsonObject { Count: 1 } flag && flag["x-nullable"]?.GetValueKind() == JsonValueKind.True && !o.ContainsKey("$ref") && !o.ContainsKey(Hx.ResolvedReference) && !o.ContainsKey("type"); + /// + /// A body whose schema is its format and its null alone, {"schema": {"format": "int32", "allOf": [{"x-nullable": true}]}}, or its null + /// alone where the type has no format, anywhere in an operation: told by its structure (KB-001). + /// 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 + JsonObject o => o["schema"] is JsonObject schema && schema.All(kv => kv.Key is "allOf" or "format") + && 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), @@ -579,6 +598,23 @@ string Sig(JsonNode d, JsonNode op) o["ok"] = Schemas(Hx.Deep(d, op["responses"]?["200"], mark: true)); return o.ToJsonString(); } + // The media types of an operation's request body and 200 response, each named by where it is + static HashSet MediaTypes(JsonNode op) => + [ + .. (op["requestBody"]?["content"] as JsonObject ?? []).Select(kv => "requestBody:" + kv.Key), + .. (op["responses"]?["200"]?["content"] as JsonObject ?? []).Select(kv => "ok:" + kv.Key), + ]; + // A signature with only the request body's and the 200 response's media types given + static string UnderMediaTypes(string sig, IEnumerable media) + { + var keep = media.ToHashSet(); + var o = JsonNode.Parse(sig)!.AsObject(); + foreach (var (part, name) in new[] { ("requestBody", "requestBody"), ("ok", "ok") }) + if (o[part]?["content"] is JsonObject content) + foreach (var key in content.Select(kv => kv.Key).ToList()) + if (!keep.Contains(name + ":" + key)) content.Remove(key); + return o.ToJsonString(); + } var checks = new List<(string, string)>(); if (Hx.HasMinimal(r)) { checks.Add((Hx.M(r, "/schema/plain"), Hx.M(r, "/schema/vo"))); } if (Hx.HasMvc(r)) @@ -642,6 +678,17 @@ string Inputs(JsonNode op) continue; } var sp = Sig(doc, po); var sv = Sig(doc, vo); + // MVC lists a body's and a response's media types by what its formatters can read and write of the .NET type, and the twin + // and the value object are two types (row 38 of C21: XmlSerializerInputFormatter reads a record class value object, not the + // Uri it wraps). Where the lists differ and side (1), ASP.NET alone with both sides' endpoints, lists each side's the same, + // the formatters decided it, not TypeKit: the schemas are compared under the media types both list. Otherwise as they are. + if (MediaTypes(po) is var mp && MediaTypes(vo) is var mv && !mp.SetEquals(mv) + && await Document(alone) is { } both0 && Op(both0, pp, method) is { } pAlone && Op(both0, vp, method) is { } vAlone + && MediaTypes(pAlone).SetEquals(mp) && MediaTypes(vAlone).SetEquals(mv)) + { + r.Log.AppendLine($"{pp} vs {vp}: media types the formatters list differ alike without TypeKit, compared under [{string.Join(", ", mp.Intersect(mv))}]"); + sp = UnderMediaTypes(sp, mp.Intersect(mv)); sv = UnderMediaTypes(sv, mp.Intersect(mv)); + } if (NullAroundReferences(sp, sv) is var (np, nv, around)) { sp = np; sv = nv; if (around) c.Note(NoteKeys.SchemaNullAroundReferenceNormalised); } if (sp != sv && Make.AdmitsNull(e.Vo) && Make.AdmitsNull(e.Plain) && e.Vo.IsValueType != e.Plain.IsValueType) { @@ -660,7 +707,17 @@ 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; } + // A form binds the wrapped record class by its properties and the value object otherwise, as the parameters above: the two + // sides' fields are not alike, and no sentence cites it, so the check cannot tell (row 62 of C21). + if (e.Wrapped == "own-record-class" && pp.Contains("/form/") && method == "post") + { c.Inconclusive($"{pp} vs {vp}: the wrapped record class is bound from a form by its properties, the value object otherwise: the fields differ"); continue; } var d = Difference(JsonNode.Parse(sp), JsonNode.Parse(sv), "$"); + // Decision 36: a nullable value object admits null wherever it appears, where ASP.NET may write the twin's nullable type without + // it (the items of a list of double? under AllowNamedFloatingPointLiterals, row 216 of C21; the twin is held to side (1) above). + // Only that null differs: the value object admits it where the twin does not, and the schemas are otherwise the same. + if (Make.AdmitsNull(e.Vo) && d.Keys is [.., "x-null"] && d.P is null && d.V?.GetValueKind() == JsonValueKind.True + && JsonNode.DeepEquals(Hx.NoNull(JsonNode.Parse(sp)), Hx.NoNull(JsonNode.Parse(sv)))) + { c.Inapplicable(Cite.NotPromised("a-nullable-value-object-admits-null"), d.Path); continue; } 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)); @@ -672,6 +729,28 @@ string Inputs(JsonNode op) // 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; } + // The same, where the spellings of the null were normalised above, which takes the null flag out of the value object's + // schema and leaves it without what the 2.0 writer dropped with the reference: its type, its pattern (rows 386, 178, 181 + // and 243 of C21: a nullable struct value object over Uri as a required-init property, and class value objects whose + // property admits null as a type parameter's or set through the constructor). Told by its structure: the value object's + // property is the null flag, alone or beside the wrapped type's format, and the twin's, which admits null, has that format. + if (openApi2 && PropertyAt(d.Keys) is { } property + && At(vo3, property) is JsonObject flagged3 && (TheNullFlagBeside(flagged3) || OnlyTheNullFlag(flagged3)) + && At(JsonNode.Parse(Sig(doc, po)), property) is JsonObject typed2 + && (typed2["x-nullable"]?.GetValueKind() == JsonValueKind.True + // or the twin is a component itself (a wrapped record class, row 444 of C21), whose reference ASP.NET's own rule + // leaves without the null the value object admits (decision 36: a-nullable-value-object-admits-null) + || typed2.ContainsKey(Hx.ResolvedReference) && Make.AdmitsNull(e.Vo)) + && JsonNode.DeepEquals(typed2["format"], flagged3["format"])) + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-component-loses-its-type"), d.Path); continue; } + // AspNetCore README "What the document says": a nullable element is the component or null, as a nullable property is, which + // OpenAPI 2.0 writes as allOf [{x-nullable: true}] without the reference (not-promised openapi-2-nullable-element-loses-its-type; + // row 358 of C21, a dictionary's value). ASP.NET alone drops a converter-backed element, so side (1) has nothing there; told by + // its structure, the value object's element is the null flag alone where the twin's admits null. + if (openApi2 && d.Keys is [.., "items" or "additionalProperties", "allOf"] && d.P is null + && At(vo3, d.Keys.SkipLast(1)) is JsonObject element3 && OnlyTheNullFlag(element3) + && At(JsonNode.Parse(Sig(doc, po)), d.Keys.SkipLast(1)) is JsonObject element2 && element2["x-nullable"]?.GetValueKind() == JsonValueKind.True) + { c.Inapplicable(Cite.NotPromised("openapi-2-nullable-element-loses-its-type"), d.Path); continue; } // The same loss told by the row's axes, where side (1) cannot show it: a nullable value object that is a component is a // nullable property wherever the serializer's contract says so (a get-only or a type parameter's property among them), where // ASP.NET alone adds the null only for a settable annotated one. Its property is then the null flag without the reference, @@ -680,9 +759,10 @@ string Inputs(JsonNode op) && At(vo3, d.Keys.SkipLast(1)) is JsonObject lost && TheNullFlagWithoutItsReference(lost) && At(JsonNode.Parse(Sig(doc, po)), d.Keys.SkipLast(1)) is JsonObject kept && kept["x-nullable"]?.GetValueKind() == JsonValueKind.True) { 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). Side (1) shows it: without TypeKit, the twin's schema there is that null alone. + // KB-001: OpenAPI 2.0 writes a nullable body of the wrapped value type as its format and allOf [{x-nullable: true}] alone (the + // null alone where it has no format, an enum), 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, and rows 113 and 282 over TimeOnly and char). Side (1) + // shows it: without TypeKit, the twin's schema there is that format and that null alone. // AspNetCore README "What the document says" (KB-026), seen from the twin: OpenAPI 2.0 writes a nullable property of any component, // the twin's nullable enum among them, as allOf [{x-nullable: true}] without the reference, where the value object keeps its type. // Side (1) shows it: without TypeKit, the twin's property there is that null alone. @@ -694,11 +774,24 @@ string Inputs(JsonNode op) // 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). 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 + // The same component met first as a nullable use lists that null for a property too, the twin's non-nullable one included + // (not-promised enum-component-lists-the-null-of-its-first-use; row 196 of C21, a polymorphic derived type's property). + if (d.Keys is [.., "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(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; } + { + if (d.Keys is ["parameters", ..]) c.Inapplicable(Cite.NotPromised("enum-component-is-its-first-use"), vp); + else c.Inapplicable(Cite.NotPromised("enum-component-lists-the-null-of-its-first-use"), vp); + continue; + } + // AspNetCore README "A described type": ASP.NET cannot see through a converter it did not write, one a library registers for + // its own type among them, and drops a list's items and a dictionary's additionalProperties of that type, where the value + // object's element is put back (not-promised a-librarys-converter-for-the-wrapped-type; row 422 of C21, a list of Instant?). + // Side (1) shows it: without TypeKit, the twin's container has no element schema either. + if (e.Wrapped == "NodaTime.Instant" && d.Keys is [.., "items" or "additionalProperties"] && d.P is null && d.V is not null + && twin1 is not null && At(twin1, d.Keys.SkipLast(1)) is JsonObject container1 && !container1.ContainsKey(d.Keys[^1])) + { c.Inapplicable(Cite.NotPromised("a-librarys-converter-for-the-wrapped-type"), d.Path); 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/Shapes.g.cs b/tools/probe/corpus/harness/Shapes.g.cs index f0c47fa..786b9c5 100644 --- a/tools/probe/corpus/harness/Shapes.g.cs +++ b/tools/probe/corpus/harness/Shapes.g.cs @@ -9555,4 +9555,5 @@ static partial void Fill(List e) [JsonSerializable(typeof(Cn_record_struct_F>), TypeInfoPropertyName = "T8870")] [JsonSerializable(typeof(Cn_struct_P>>), TypeInfoPropertyName = "T8871")] [JsonSerializable(typeof(Cn_generic_type_P>, int>), TypeInfoPropertyName = "T8872")] +[JsonSerializable(typeof(Cn_record_struct_F>), TypeInfoPropertyName = "T8873")] public partial class ProbeContext : JsonSerializerContext { } diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index a3da0fb..a2df1d5 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -454,7 +454,7 @@ static int Occurrences(string text, string needle) } 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 inPaths = new List(); - foreach (var (text, needle) in variants) + string? Read(string text, string needle) { try { JsonSerializer.Deserialize(text, hv.Declared, opts); } catch (Exception ex) @@ -470,7 +470,14 @@ static int Occurrences(string text, string needle) 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"); } + return null; } + foreach (var (text, needle) in variants) + if (Read(text, needle) is { } quoted) return quoted; + // A wrapped type that reads every string (a string, a Uri) refuses none of these: what a client sends that it cannot read is a value + // of another kind, and an array holding the secret where the wrapped value stands is one (a key is a property name, never an array). + if (rejected == 0 && at >= 0 && pos != "dictionary-key" + && Read(doc[..at] + "[\"" + TextOps.Secret + "\"]" + doc[(at + sample.Length)..], TextOps.Secret) is { } quotedInArray) return quotedInArray; 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" + (inPaths.Count > 0 ? ", " + string.Join(", ", inPaths.Distinct()) : "")); return null; diff --git a/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs b/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs index 9287e38..372ea70 100644 --- a/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs +++ b/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs @@ -4,7 +4,8 @@ // value, get-value, value-or-null (by ef.accessor): "`.Value`, `GetValue()` and `ValueOrNull()` translate to the column itself.": the rows the query answers // column-not-cast (every row): "A table's column is re-typed, not cast, so an index on it still serves the query.": the SQL // Expected: no failure for any row; where a side refuses the query, EfSides says how the check ends. Where SQLite cannot compare or -// order the wrapped type, every obligation declares itself inapplicable (claims.json, fact KB-039). +// order the wrapped type, every obligation declares itself inapplicable (claims.json, fact KB-039). where-value-compare is >= as C# writes +// it, over the underlying type for an enum; a string, a bool and a Uri have none, and claims.json excludes the pair. using System.Linq.Expressions; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -20,6 +21,10 @@ public sealed class E02ValueTranslatesProbe static MethodInfo? Unwrap(Type vo, string name) => vo.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(m => m.Name == name && m.GetParameters().Length == 1); + /// The type C# compares an enum (or a nullable enum) over: its underlying type, nullable where it is; null for any other type. + static Type? Underlying(Type t) => Nullable.GetUnderlyingType(t) is { IsEnum: true } e ? typeof(Nullable<>).MakeGenericType(Enum.GetUnderlyingType(e)) + : t.IsEnum ? Enum.GetUnderlyingType(t) : null; + static async Task Check(Probes.Shapes.Row r) { var e = Probes.Ef3.E4.At(r, r["shape.kind"], r["shape.wrapped"], r["shape.site"]); @@ -53,6 +58,10 @@ IQueryable Build(DbContext db, Type entity, bool vo) case "where-value-compare": BinaryExpression cmp; try { cmp = Expression.GreaterThanOrEqual(access, param); } + // C# compares two values of an enum with >= over its underlying type, and an expression tree it compiles says so + // with a conversion on each side; Expression.GreaterThanOrEqual defines no >= for the enum itself. + catch (InvalidOperationException) when (Underlying(at) is { } u) { cmp = Expression.GreaterThanOrEqual(Expression.Convert(access, u), Expression.Convert(param, u)); } + // string, bool and Uri have no >= in C# (excluded in claims.json): no row asks for one catch (InvalidOperationException) { r.Substituted("ef.query", "where-equals", "the wrapped type has no >= operator"); cmp = Expression.Equal(access, param); } return Q.Call("Where", [entity], src, Lam(cmp)); case "order-by": return Q.Call("OrderBy", [entity, at], src, Lam(access)); diff --git a/tools/probe/corpus/registry.json b/tools/probe/corpus/registry.json index 808e480..55240d8 100644 --- a/tools/probe/corpus/registry.json +++ b/tools/probe/corpus/registry.json @@ -755,6 +755,8 @@ "filter": "*C22GeneratorRefusalsProbe", "watched": [ "src/CodoMetis.TypeKit", + "src/CodoMetis.TypeKit.Analyzers", + "src/CodoMetis.TypeKit.Generators", "src/Directory.Build.props", "src/Directory.Build.targets" ], diff --git a/tools/probe/corpus/rows/C02OptionalTextProbe.json.gz b/tools/probe/corpus/rows/C02OptionalTextProbe.json.gz index 303240a..51817f8 100644 Binary files a/tools/probe/corpus/rows/C02OptionalTextProbe.json.gz and b/tools/probe/corpus/rows/C02OptionalTextProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C12DescribedConverterProbe.json.gz b/tools/probe/corpus/rows/C12DescribedConverterProbe.json.gz index 608850e..694df30 100644 Binary files a/tools/probe/corpus/rows/C12DescribedConverterProbe.json.gz and b/tools/probe/corpus/rows/C12DescribedConverterProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C13PatchRequiredProbe.json.gz b/tools/probe/corpus/rows/C13PatchRequiredProbe.json.gz index 881ba33..28c4797 100644 Binary files a/tools/probe/corpus/rows/C13PatchRequiredProbe.json.gz and b/tools/probe/corpus/rows/C13PatchRequiredProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C16OptionsAgreementProbe.json.gz b/tools/probe/corpus/rows/C16OptionsAgreementProbe.json.gz index 3c1e958..762426f 100644 Binary files a/tools/probe/corpus/rows/C16OptionsAgreementProbe.json.gz and b/tools/probe/corpus/rows/C16OptionsAgreementProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C17Cmtk0014Probe.json.gz b/tools/probe/corpus/rows/C17Cmtk0014Probe.json.gz index d72d0fe..c373c11 100644 Binary files a/tools/probe/corpus/rows/C17Cmtk0014Probe.json.gz and b/tools/probe/corpus/rows/C17Cmtk0014Probe.json.gz differ diff --git a/tools/probe/corpus/rows/C19Cmtk0010KindsProbe.json.gz b/tools/probe/corpus/rows/C19Cmtk0010KindsProbe.json.gz index 629507d..f0242ce 100644 Binary files a/tools/probe/corpus/rows/C19Cmtk0010KindsProbe.json.gz and b/tools/probe/corpus/rows/C19Cmtk0010KindsProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C21OpenapiSchemaParityProbe.json.gz b/tools/probe/corpus/rows/C21OpenapiSchemaParityProbe.json.gz index 0cd59f9..bdcf9ef 100644 Binary files a/tools/probe/corpus/rows/C21OpenapiSchemaParityProbe.json.gz and b/tools/probe/corpus/rows/C21OpenapiSchemaParityProbe.json.gz differ diff --git a/tools/probe/corpus/rows/C22GeneratorRefusalsProbe.json.gz b/tools/probe/corpus/rows/C22GeneratorRefusalsProbe.json.gz index 2368e4b..a435a31 100644 Binary files a/tools/probe/corpus/rows/C22GeneratorRefusalsProbe.json.gz and b/tools/probe/corpus/rows/C22GeneratorRefusalsProbe.json.gz differ diff --git a/tools/probe/corpus/rows/E02ValueTranslatesProbe.json.gz b/tools/probe/corpus/rows/E02ValueTranslatesProbe.json.gz index f262adf..b420251 100644 Binary files a/tools/probe/corpus/rows/E02ValueTranslatesProbe.json.gz and b/tools/probe/corpus/rows/E02ValueTranslatesProbe.json.gz differ diff --git a/tools/probe/corpus/rows/L01JsonBytesProbe.json.gz b/tools/probe/corpus/rows/L01JsonBytesProbe.json.gz index 1a024db..10767ec 100644 Binary files a/tools/probe/corpus/rows/L01JsonBytesProbe.json.gz and b/tools/probe/corpus/rows/L01JsonBytesProbe.json.gz differ diff --git a/tools/probe/corpus/rows/L02NoQuoteProbe.json.gz b/tools/probe/corpus/rows/L02NoQuoteProbe.json.gz index d788d8a..8c537a2 100644 Binary files a/tools/probe/corpus/rows/L02NoQuoteProbe.json.gz and b/tools/probe/corpus/rows/L02NoQuoteProbe.json.gz differ diff --git a/tools/probe/corpus/rows/L03OneRuleProbe.json.gz b/tools/probe/corpus/rows/L03OneRuleProbe.json.gz index 19c95f7..fffe2fa 100644 Binary files a/tools/probe/corpus/rows/L03OneRuleProbe.json.gz and b/tools/probe/corpus/rows/L03OneRuleProbe.json.gz differ diff --git a/tools/probe/corpus/rows/L06OffTheWireProbe.json.gz b/tools/probe/corpus/rows/L06OffTheWireProbe.json.gz index 823554e..46bf206 100644 Binary files a/tools/probe/corpus/rows/L06OffTheWireProbe.json.gz and b/tools/probe/corpus/rows/L06OffTheWireProbe.json.gz differ