From f82f5830d6d7e194442cf92784d220d1149e2650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:16:31 +0200 Subject: [PATCH 1/3] test: the json and text families check each obligation (WS2 step 4) Every probe of the json and text families (C01, C02, C10, L01 to L04, L06) and the generation and option-result probes whose harness is theirs (C09, C20, L05) runs one Row.Check per obligation its row reaches, named by the claim's obligation id and given its baseline: the wrapped twin, the value object's own document read back, the documented refusal, or the docs sentence. Every excuse of outcome inapplicable, expected-refusal or inconclusive is a cited c.Inapplicable/c.Refused, a comparison, or a c.Inconclusive with its reason, except the text-matching sites, which stay and are marked. No NoteKeys member is removed; the pass keys stay as notes. The excuses that depend on the axes alone are declared on their obligation in claims.json (#50): L01's own converter for the value object, its byte array and its closed-set key under a key policy; L02's and L03's entries that are not generated (span parsing over a type that has none, a closed set's span parsing, parsing over a record class) and L02's own converters; L03's TryFrom of a closed set; L04's closed-set text entries, parsing over a record class, span parsing, and a date's type converter. A closed set parses by name (Parse(string), TryParse(string, out)), which TextOps now reaches, so its text entries are checked instead of skipped. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/claims.json | 405 ++++++++++++- tools/probe/corpus/harness/DecimalSpelling.cs | 189 ++++-- tools/probe/corpus/harness/Equality.cs | 37 +- tools/probe/corpus/harness/JsonFamily.cs | 2 + tools/probe/corpus/harness/OffTheWire.cs | 120 ++-- tools/probe/corpus/harness/Positions.cs | 151 +++-- tools/probe/corpus/harness/PreserveGuard.cs | 179 +++--- tools/probe/corpus/harness/TextRow.cs | 561 ++++++++++++------ .../corpus/probes/C02OptionalTextProbe.cs | 40 +- .../probes/C09BaseRecordEqualityProbe.cs | 43 +- .../probe/corpus/probes/C20ShouldHoldProbe.cs | 82 ++- 11 files changed, 1336 insertions(+), 473 deletions(-) diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index 401f9afe..8eb3f750 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -13616,7 +13616,34 @@ "obligations": [ { "id": "the-underlying-types-json", - "sentence": "Its JSON is the underlying type's under your options" + "sentence": "Its JSON is the underlying type's under your options", + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "byte" + ], + "shape.position": [ + "array-element" + ] + }, + "cite": "not-promised:a-byte-array-is-base64" + } + ] }, { "id": "as-a-dictionary-key-too", @@ -13625,11 +13652,74 @@ "shape.position": [ "dictionary-key" ] - } + }, + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-value-object" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "shape.position": [ + "dictionary-key" + ], + "json.DictionaryKeyPolicy": [ + "CamelCase", + "SnakeCaseLower", + "KebabCaseUpper", + "own-upper-case" + ] + }, + "cite": "not-promised:a-closed-set-key-is-its-name" + } + ] }, { "id": "read-as-the-underlying-type-reads", - "sentence": "**Swapping a primitive for a value object keeps the JSON, the column and the schema.**" + "sentence": "**Swapping a primitive for a value object keeps the JSON, the column and the schema.**", + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "byte" + ], + "shape.position": [ + "array-element" + ] + }, + "cite": "not-promised:a-byte-array-is-base64" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "shape.position": [ + "dictionary-key" + ], + "json.DictionaryKeyPolicy": [ + "CamelCase", + "SnakeCaseLower", + "KebabCaseUpper", + "own-upper-case" + ] + }, + "cite": "not-promised:a-closed-set-key-is-its-name" + } + ] } ], "not-promised": [ @@ -13751,11 +13841,76 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "json-never-repeats", - "sentence": "A JSON or parsing rejection names the value object and the rule, or the underlying type it could not read." + "sentence": "A JSON or parsing rejection names the value object and the rule, or the underlying type it could not read.", + "inapplicable": [ + { + "axes": { + "json.Converters": [ + "own-for-wrapped", + "own-for-value-object" + ] + }, + "cite": "not-promised:the-applications-own-converter" + } + ] }, { "id": "no-inner-that-quotes", @@ -13768,7 +13923,61 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] } ], "not-promised": [ @@ -13813,7 +14022,20 @@ "text.entry": [ "TryFrom" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "TryFrom" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + } + ] }, { "id": "parsing-runs-create", @@ -13826,7 +14048,61 @@ "ISpanParsable", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "json-runs-create", @@ -13871,11 +14147,120 @@ "TryFormat-short-span", "type-converter" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "type-converter" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + } + ] }, { "id": "text-reads-back", - "sentence": "That text reads back as the same value wherever the underlying type's own does" + "sentence": "That text reads back as the same value wherever the underlying type's own does", + "inapplicable": [ + { + "axes": { + "shape.kind": [ + "closed-set" + ], + "text.entry": [ + "TryFrom", + "ISpanParsable", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#closed-sets" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "own-record-class" + ], + "text.entry": [ + "Parse", + "TryParse", + "IParsable", + "ISpanParsable", + "type-converter", + "ToString-format", + "TryFormat-short-span" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "Uri", + "enum", + "flags-enum", + "NodaTime.Instant" + ], + "text.entry": [ + "ISpanParsable" + ] + }, + "cite": "docs:src/CodoMetis.TypeKit.Generators/README.md#what-is-generated" + }, + { + "axes": { + "shape.kind": [ + "record-struct", + "sealed-record-class", + "validated-struct", + "validated-class" + ], + "shape.wrapped": [ + "TimeOnly", + "DateTime", + "DateTimeOffset" + ], + "text.entry": [ + "type-converter" + ] + }, + "cite": "not-promised:dates-and-times-lose-detail-as-text" + } + ] }, { "id": "json-refusal-is-a-json-exception", diff --git a/tools/probe/corpus/harness/DecimalSpelling.cs b/tools/probe/corpus/harness/DecimalSpelling.cs index 2f09a3fa..d8147c2d 100644 --- a/tools/probe/corpus/harness/DecimalSpelling.cs +++ b/tools/probe/corpus/harness/DecimalSpelling.cs @@ -44,6 +44,17 @@ static List Held(object? v, Type declared, decimal d) static bool Same(decimal a, decimal b) => decimal.GetBits(a).SequenceEqual(decimal.GetBits(b)); static string Show(List l) => string.Join(",", l.Select(x => x.ToString(Inv))); + /// What one form of the document read as: the decimal's and the value object's refusals and the decimals each holds. + sealed record Form(string Name, string Json, Exception? Ep, Exception? Ev, List Cp, List Cv, bool BothRefused); + + /// + /// C01's checks, one per obligation, over one write and one read of the row's shape: the converter's spelling of each value + /// (smallest-scale; number-is-a-number or string-is-a-string, by the row's converter) against the docs' minimal spelling worked out + /// here; every decimal of the document, a key's included, and the value object's document against the plain decimal's + /// (covers-every-decimal, the twin); each form of the input read (reads-a-number-or-a-string) with the scale it carries + /// (keeps-the-scale-it-reads). The options' own leaving-out is shown by a baseline without the converter in the same row: a + /// direction it leaves out has nothing to check. Every check runs; returns the first failure. + /// public static string? Check(Row r) { var kind = r["shape.kind"]; @@ -63,87 +74,149 @@ static List Held(object? v, Type declared, decimal d) foreach (var c in ob.Converters.Where(c => c.CanConvert(typeof(decimal))).ToList()) ob.Converters.Remove(c); o.Converters.Insert(0, conv); // registered first: an application converter for decimals (own-for-wrapped) comes after it var ownVo = r.Is("json.Converters", "own-for-value-object"); + var docs = Baseline.Docs("docs/json.md#decimals-in-one-spelling"); + const string Smallest = "smallest-scale", Covers = "covers-every-decimal", Reads = "reads-a-number-or-a-string", Keeps = "keeps-the-scale-it-reads"; + var kindOf = number ? "number-is-a-number" : "string-is-a-string"; // Writing: one spelling, the smallest scale, as a number or a string; the value object's document is the decimal's. var (jp, xp) = Jx.Ser(hp, o); var (jv, xv) = Jx.Ser(hv, o); var (jb, xb) = Jx.Ser(hp, ob); - var wrote = false; if (xb is not null && xp is not null && (xv is not null || ownVo) && xp.GetType() == xb.GetType()) - { r.Note(NoteKeys.BothThrew, "write: " + Jx.Ex(xb)); return null; } // the options refuse the shape for the serializer's own decimal too - if (xp is not null) return $"write: the decimal with {conv.GetType().Name} threw {Jx.Ex(xp)}; without it the serializer wrote {Jx.Cut(jb)}"; - if (xv is not null && !ownVo) return $"write: the value object threw {Jx.Ex(xv)} where the decimal wrote {Jx.Cut(jp)}"; - var tp = Jx.Tokens(jp!).Where(t => IsDecimal(t, d)).ToList(); - var tb = xb is null ? Jx.Tokens(jb!).Where(t => IsDecimal(t, d)).ToList() : null; - if (tb is { Count: 0 }) { - if (tp.Count != 0) return $"write: the options leave the member out without the converter ({Jx.Cut(jb)}) but it wrote {Jx.Cut(jp)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jb)); + // the options refuse the shape for the serializer's own decimal too: nothing is written or read to compare + 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); + r.Check(Covers, Baseline.Twin, None); r.Check(Reads, docs, None); r.Check(Keeps, docs, None); + return null; } - else + var tp = xp is null ? Jx.Tokens(jp!).Where(t => IsDecimal(t, d)).ToList() : []; + var tb = xb is null ? Jx.Tokens(jb!).Where(t => IsDecimal(t, d)).ToList() : null; + var writeLeftOut = tb is { Count: 0 } && xp is null && tp.Count == 0; + string? WriteLeftOut(Check c) { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jb)); return c.Inconclusive("write: the options leave the member out, for the serializer's own decimal too: " + Jx.Cut(jb)); } + var want = number ? JsonTokenType.Number : JsonTokenType.String; + var f1 = r.Check(Smallest, docs, c => { + if (xp is not null) return $"write: the decimal with {conv.GetType().Name} threw {Jx.Ex(xp)}; without it the serializer wrote {Jx.Cut(jb)}"; + if (tb is { Count: 0 } && tp.Count != 0) return $"write: the options leave the member out without the converter ({Jx.Cut(jb)}) but it wrote {Jx.Cut(jp)}"; + if (writeLeftOut) return WriteLeftOut(c); if (tp.Count == 0) return $"write: no decimal in {Jx.Cut(jp)}; the serializer alone wrote {Jx.Cut(jb)}"; - if (tb is not null && tp.Count != tb.Count) return $"write: {tp.Count} decimals in {Jx.Cut(jp)}, {tb.Count} in {Jx.Cut(jb)}"; - foreach (var t in tp) - { - if (t.Type == JsonTokenType.PropertyName) { if (t.Raw != canon) return $"write: key {t.Raw}, expected {canon}, in {Jx.Cut(jp)}"; continue; } - var want = number ? JsonTokenType.Number : JsonTokenType.String; - if (t.Type != want || t.Raw != canon) return $"write: {t.Type} {t.Raw}, expected the {want} {canon}, in {Jx.Cut(jp)}"; - } - wrote = true; - } - if (ownVo) + foreach (var t in tp.Where(t => t.Type != JsonTokenType.PropertyName)) + if (t.Raw != canon) return $"write: {t.Type} {t.Raw}, expected the smallest scale {canon}, in {Jx.Cut(jp)}"; + c.Note(NoteKeys.Compared, $"write {text} -> {canon}"); + return null; + }); + var f2 = r.Check(kindOf, docs, c => { - r.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back - var (_, xr) = Jx.De(jv!, hv.Declared, o); - if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); - } - else if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the decimal {Jx.Cut(jp)}"; + if (xp is not null) return c.Inconclusive("write: the decimal with the converter threw, judged by smallest-scale"); + if (writeLeftOut) return WriteLeftOut(c); + var values = tp.Where(t => t.Type != JsonTokenType.PropertyName).ToList(); + if (values.Count == 0) return c.Inconclusive($"write: no decimal value in {Jx.Cut(jp)}, only keys, which are names"); + foreach (var t in values) + if (t.Type != want) return $"write: {t.Type} {t.Raw}, expected the {want} {canon}, in {Jx.Cut(jp)}"; + c.Note(NoteKeys.Compared, $"write as a {want}"); + return null; + }); // Reading: a number, a string holding one, and an escaped string all read, the scale they carry kept, on both sides alike. var full = Jx.Full(o); var (jf, xf) = Jx.Ser(hp, full); - if (xf is not null) { r.Outcome("harness-error", "the document with every member written threw " + Jx.Ex(xf)); return null; } + if (xf is not null) { r.Outcome("harness-error", "the document with every member written threw " + Jx.Ex(xf)); return f1 ?? f2; } var at = Jx.Tokens(jf!).Where(t => IsDecimal(t, d)).ToList(); + string? readNothing = null; + var forms = new List
(); + var leftOut = false; if (at.Count == 0) { // Nothing to read either: the options leave the member out of every document (a field without IncludeFields). var (jfb, _) = Jx.Ser(hp, Jx.Full(ob)); - if (jfb is null || Jx.Tokens(jfb).Any(t => IsDecimal(t, d))) { r.Outcome("harness-error", "no decimal in the document with every member written: " + jf); return null; } - if (!wrote) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions, jf); - return null; + if (jfb is null || Jx.Tokens(jfb).Any(t => IsDecimal(t, d))) { r.Outcome("harness-error", "no decimal in the document with every member written: " + jf); return f1 ?? f2; } + readNothing = "read: the options leave the member out of every document, for the serializer's own decimal too: " + Jx.Cut(jf); } - var key = at.Any(t => t.Type == JsonTokenType.PropertyName); - var forms = new List<(string Name, string Json)>(); - if (!key) forms.Add(("number", Jx.Rewrite(jf!, at, _ => text))); - forms.Add(("string", Jx.Rewrite(jf!, at, _ => "\"" + text + "\""))); - forms.Add(("escaped string", Jx.Rewrite(jf!, at, _ => "\"" + Bs + "u00" + ((int)text[0]).ToString("x2") + text[1..] + "\""))); - // The baseline reads the first form with the serializer's own decimal: it shows whether these options read the member at all. - var (vb, eb) = Jx.De(forms[0].Json, hp.Declared, ob); - var leftOut = eb is null && Held(vb, hp.Declared, d).Count == 0; - var read = false; - foreach (var (name, json) in forms) + else { - var (vp, ep) = Jx.De(json, hp.Declared, o); - var (vv, ev) = ownVo ? (null, null) : Jx.De(json, hv.Declared, o); - if (ep is not null) + var key = at.Any(t => t.Type == JsonTokenType.PropertyName); + var texts = new List<(string Name, string Json)>(); + if (!key) texts.Add(("number", Jx.Rewrite(jf!, at, _ => text))); + texts.Add(("string", Jx.Rewrite(jf!, at, _ => "\"" + text + "\""))); + texts.Add(("escaped string", Jx.Rewrite(jf!, at, _ => "\"" + Bs + "u00" + ((int)text[0]).ToString("x2") + text[1..] + "\""))); + // The baseline reads the first form with the serializer's own decimal: it shows whether these options read the member at all. + var (vb, eb) = Jx.De(texts[0].Json, hp.Declared, ob); + leftOut = eb is null && Held(vb, hp.Declared, d).Count == 0; + foreach (var (name, json) in texts) { - if (eb is not null && (ownVo || ev is not null) && ep.GetType() == eb.GetType()) { r.Note(NoteKeys.ReadBothThrew, name + ": " + Jx.Ex(eb)); continue; } - return $"read {name}: the decimal with {conv.GetType().Name} threw {Jx.Ex(ep)}\n text={Jx.Cut(json)}"; + var (vp, ep) = Jx.De(json, hp.Declared, o); + var (vv, ev) = ownVo ? (null, null) : Jx.De(json, hv.Declared, o); + var both = ep is not null && eb is not null && (ownVo || ev is not null) && ep.GetType() == eb.GetType(); + var cp = ep is null ? Held(vp, hp.Declared, d) : []; var cv = ownVo ? cp : ev is null ? Held(vv, hv.Declared, d) : []; + forms.Add(new(name, json, ep, ev, cp, cv, both)); } - if (ev is not null) return $"read {name}: the value object threw {Jx.Ex(ev)} where the decimal read it\n text={Jx.Cut(json)}"; - var cp = Held(vp, hp.Declared, d); var cv = ownVo ? cp : Held(vv, hv.Declared, d); - if (leftOut) + if (leftOut && forms.All(f => f.Ep is null && f.Ev is null && f.Cp.Count == 0 && f.Cv.Count == 0)) + readNothing = "read: the options read no member, for the serializer's own decimal too"; + } + string? ReadLeftOut(Check c) { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read"); return c.Inconclusive(readNothing!); } + // The forms both sides refuse alike under these options (the serializer's own decimal too) have nothing to compare. + var compared = forms.Where(f => !f.BothRefused).ToList(); + string? AllRefused(Check c) + { + foreach (var f in forms.Where(f => f.BothRefused)) c.Log.AppendLine($"read {f.Name}: both refuse: {Jx.Ex(f.Ep!)}"); + return c.Inconclusive("read: every form is refused for the serializer's own decimal too: " + Jx.Ex(forms[0].Ep!)); + } + var f4 = r.Check(Reads, docs, c => + { + if (readNothing is not null) return ReadLeftOut(c); + foreach (var f in forms) { - if (cp.Count != 0 || cv.Count != 0) return $"read {name}: the options read no member for the serializer's own decimal, yet it read {Show(cp)} / {Show(cv)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read: " + name); - continue; + if (f.BothRefused) continue; + if (f.Ep is not null) return $"read {f.Name}: the decimal with {conv.GetType().Name} threw {Jx.Ex(f.Ep)}\n text={Jx.Cut(f.Json)}"; + if (leftOut) { if (f.Cp.Count != 0) return $"read {f.Name}: the options read no member for the serializer's own decimal, yet it read {Show(f.Cp)}"; continue; } + if (f.Cp.Count == 0) return $"read {f.Name}: the decimal read nothing\n text={Jx.Cut(f.Json)}"; } - if (cp.Count == 0) return $"read {name}: the decimal read nothing\n text={Jx.Cut(json)}"; - if (cp.Any(x => !Same(x, d))) return $"read {name}: read {Show(cp)}, expected {d.ToString(Inv)} with its scale\n text={Jx.Cut(json)}"; - if (cp.Count != cv.Count || cp.Zip(cv).Any(z => !Same(z.First, z.Second))) return $"read {name}: the value object read {Show(cv)}, the decimal {Show(cp)}\n text={Jx.Cut(json)}"; - read = true; - } - if (!wrote && !read) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions); - r.Note(NoteKeys.Compared, $"{(number ? "number" : "string")} {text} -> {canon}"); - return null; + if (compared.Count == 0) return AllRefused(c); + c.Note(NoteKeys.Compared, "read " + string.Join(", ", compared.Select(f => f.Name))); + return null; + }); + var f5 = r.Check(Keeps, docs, c => + { + if (readNothing is not null) return ReadLeftOut(c); + if (compared.Count == 0) return AllRefused(c); + var kept = 0; + foreach (var f in compared) + { + if (f.Cp.Count == 0) continue; // read nothing, or refused: reads-a-number-or-a-string judges it + if (f.Cp.Any(x => !Same(x, d))) return $"read {f.Name}: read {Show(f.Cp)}, expected {d.ToString(Inv)} with its scale\n text={Jx.Cut(f.Json)}"; + kept++; + } + if (kept == 0) return c.Inconclusive("read: no form read a decimal to hold its scale to"); + c.Note(NoteKeys.Compared, $"read with the scale of {text}"); + return null; + }); + var f3 = r.Check(Covers, ownVo ? Baseline.RoundTrip : Baseline.Twin, c => + { + if (xv is not null && !ownVo) return $"write: the value object threw {Jx.Ex(xv)} where the decimal wrote {Jx.Cut(jp)}"; + if (xp is not null) return c.Inconclusive("write: the decimal with the converter threw, judged by smallest-scale"); + if (tb is not null && tb.Count != 0 && tp.Count != tb.Count) return $"write: {tp.Count} decimals in {Jx.Cut(jp)}, {tb.Count} in {Jx.Cut(jb)}"; + foreach (var t in tp.Where(t => t.Type == JsonTokenType.PropertyName)) + if (t.Raw != canon) return $"write: key {t.Raw}, expected {canon}, in {Jx.Cut(jp)}"; + if (ownVo) + { + // "Your own converter wins" (AspNetCore README#what-the-document-says): the application's converter writes the value object, + // so the decimal's document is none of its; its own document reads back under the same options + c.Note(NoteKeys.OwnVo); + var (_, xr) = Jx.De(jv!, hv.Declared, o); + if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); + return null; + } + if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the decimal {Jx.Cut(jp)}"; + foreach (var f in compared) + { + if (f.Ep is not null) continue; // the decimal refused it: reads-a-number-or-a-string judges it + if (f.Ev is not null) return $"read {f.Name}: the value object threw {Jx.Ex(f.Ev)} where the decimal read it\n text={Jx.Cut(f.Json)}"; + if (f.Cp.Count != f.Cv.Count || f.Cp.Zip(f.Cv).Any(z => !Same(z.First, z.Second))) return $"read {f.Name}: the value object read {Show(f.Cv)}, the decimal {Show(f.Cp)}\n text={Jx.Cut(f.Json)}"; + } + if (writeLeftOut && (readNothing is not null || compared.Count == 0)) return WriteLeftOut(c); + c.Note(NoteKeys.Compared, $"{(number ? "number" : "string")} {text} -> {canon}, the value object's document the decimal's"); + return null; + }); + return f1 ?? f2 ?? f3 ?? f4 ?? f5; } } diff --git a/tools/probe/corpus/harness/Equality.cs b/tools/probe/corpus/harness/Equality.cs index ee30e6ef..7cabd460 100644 --- a/tools/probe/corpus/harness/Equality.cs +++ b/tools/probe/corpus/harness/Equality.cs @@ -70,7 +70,9 @@ static object Make(Type v, Type w, object value) x1 = Make(v, w, a1); x2 = Make(v, w, a2); y = Make(v, w, b); if (!cw.Equals(v.GetProperty("Value")!.GetValue(x1), a1)) return $"Value of {v.Name} is not the value it was made from"; } - var fails = new List(); + // each failure goes to the obligation it breaks: the value object's own equality and wherever it is held, its ordering, its bases + List eqF = [], ordF = [], baseF = []; + var fails = eqF; void Expect(bool got, bool want, string what) { if (got != want) fails.Add($"{what}: {got}, expected {want}"); } // the value object's own equality @@ -86,6 +88,7 @@ static object Make(Type v, Type w, object value) if (Op(v, "op_Inequality", x1, x2) is { } n12) Expect((bool)n12, !eq12, "!= of equal values"); // ordering: generated where the wrapped type is comparable, ordinal for a string, zero exactly when equal + fails = ordF; var icv = typeof(IComparable<>).MakeGenericType(v); if (!closed) { @@ -113,8 +116,13 @@ static object Make(Type v, Type w, object value) } // through every base, and every IEquatable it implements + var bases = 0; for (var t = v.BaseType; t is not null; t = t.BaseType) { + // object and ValueType are every value object's, a base of none: held as one, it is its own equality + var own = t == typeof(object) || t == typeof(ValueType); + if (!own) bases++; + fails = own ? eqF : baseF; var c = Comparer(t); Expect(c.Equals(x1, x2), eq12, $"EqualityComparer<{t.Name}> of equal values"); Expect(c.Equals(x1, y), false, $"EqualityComparer<{t.Name}> of unequal values"); @@ -127,12 +135,14 @@ static object Make(Type v, Type w, object value) } foreach (var i in v.GetInterfaces().Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEquatable<>))) { + fails = i.GetGenericArguments()[0] == v ? eqF : baseF; // IEquatable is its own equality, IEquatable a base's var m = i.GetMethod("Equals")!; Expect((bool)m.Invoke(x1, [x2])!, eq12, $"{Jx.Name(i)}.Equals of equal values"); Expect((bool)m.Invoke(x1, [y])!, false, $"{Jx.Name(i)}.Equals of unequal values"); } // wherever it is held + fails = eqF; var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; var e = new Entry(kind, wrapped, v, v, () => x1, () => null, typeof(L05PolyBase), typeof(L05PolyDerived<>).MakeGenericType(v), typeof(L05PolyBase), typeof(L05PolyDerived<>).MakeGenericType(v)); @@ -175,8 +185,27 @@ static object Make(Type v, Type w, object value) break; } } - if (fails.Count > 0) return $"{Jx.Name(v)} ({kind}, {wrapped}, {inh}, {site}, {position} in {containing}): " + string.Join("; ", fails); - r.Note(NoteKeys.Compared, $"{v.Name} {position}"); - return null; + string What(List f) => $"{Jx.Name(v)} ({kind}, {wrapped}, {inh}, {site}, {position} in {containing}): " + string.Join("; ", f); + var f1 = r.Check("equality-is-the-underlying-values", Baseline.Twin, c => + { + if (eqF.Count > 0) return What(eqF); + c.Note(NoteKeys.Compared, $"{v.Name} {position}: equality, against EqualityComparer<{(w is null ? v.Name : w.Name)}>"); + return null; + }); + var f2 = r.Check("ordering-follows-the-underlying-type", Baseline.Twin, c => + { + if (ordF.Count > 0) return What(ordF); + c.Note(NoteKeys.Compared, $"{v.Name}: ordering{(icv.IsAssignableFrom(v) ? "" : ", none generated where the wrapped type has none")}"); + return null; + }); + // a value object with a base record: each base compares it as it does (the chain is shape.inheritance's) + var f3 = bases == 0 ? null : r.Check("through-every-base", Baseline.Twin, c => + { + _ = c["shape.inheritance"]; + if (baseF.Count > 0) return What(baseF); + c.Note(NoteKeys.Compared, $"{v.Name}: {bases} base type(s)"); + return null; + }); + return f1 ?? f2 ?? f3; } } diff --git a/tools/probe/corpus/harness/JsonFamily.cs b/tools/probe/corpus/harness/JsonFamily.cs index 29b322ec..515d8b11 100644 --- a/tools/probe/corpus/harness/JsonFamily.cs +++ b/tools/probe/corpus/harness/JsonFamily.cs @@ -56,6 +56,8 @@ 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. diff --git a/tools/probe/corpus/harness/OffTheWire.cs b/tools/probe/corpus/harness/OffTheWire.cs index 35357b2c..9829bb14 100644 --- a/tools/probe/corpus/harness/OffTheWire.cs +++ b/tools/probe/corpus/harness/OffTheWire.cs @@ -38,6 +38,7 @@ public static class OffTheWire var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; var shape = r["or.shape"]; var op = r["or.operation"]; + _ = r["input.json"]; // the row's input, which every check of the row runs under; the read applies it if (shape == "Result-E" && op is "query-syntax" or "FirstOrNone") { r.Substituted("or.operation", "Match", "Result has no " + op + " (claims.json excludes the pair)"); op = "Match"; } var voValue = Make.Vo(e, e.Valid()); @@ -68,63 +69,74 @@ public static class OffTheWire // the baseline: the application's own converter for the type wins, in both directions var (jOwn, xOwn) = Jx.Ser(ho, oOwn); var (jv, xv) = Jx.Ser(hv, o); - if (xOwn is not null) + var refused = Baseline.Refusal; + var fw = r.Check("never-written", refused, c => { - if (xv is not null && xv.GetType() == xOwn.GetType()) { r.Note(NoteKeys.BothThrew, "the options refuse the shape itself: " + Jx.Ex(xv)); return null; } - return $"write: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwn)}"; - } - var ownWritten = Jx.Tokens(jOwn!).Any(t => t.Text == "own-or"); - - // writing - var (jo, xo) = Jx.Ser(ho, o); - var wrote = false; - if (xo is null) - { - if (ownWritten || jo != jOwn) return $"write: {name} was written: {Jx.Cut(jo)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jo)); - } - else { if (Refused(xo, "write") is { } f) return f; wrote = true; } - - // 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 = r["input.json"]; text = JsonParity.InputFor(input, e, full, jf!, position); } - else - { - // 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) { r.Note(NoteKeys.BothThrew, "read: no document has the member: " + Jx.Ex(xf)); return null; } - _ = r["input.json"]; r.Substituted("input.json", "valid", "the value object's document cannot be written: " + xf.GetType().Name); - input = "valid"; text = jOwnFull; - } - 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 - r.Note(NoteKeys.Compared, $"read null: the Patch<{name}> is null, nothing of {name} read"); + if (xOwn is not null) + { + if (xv is not null && xv.GetType() == xOwn.GetType()) return c.Inconclusive("write: the options refuse the shape itself, with the application's converter alike: " + Jx.Ex(xv)); + return $"write: a converter registered on the options for {name} does not take precedence: {Jx.Ex(xOwn)}"; + } + var ownWritten = Jx.Tokens(jOwn!).Any(t => t.Text == "own-or"); + var (jo, xo) = Jx.Ser(ho, o); + if (xo is null) + { + if (ownWritten || jo != jOwn) return $"write: {name} was written: {Jx.Cut(jo)}"; + // the options leave the member out, the application's converter's document alike: no converter ran + c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jo)); + return c.Inconclusive("write: the options leave the member out, so no converter runs: " + Jx.Cut(jo)); + } + if (Refused(xo, "write") is { } f) return f; + c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: write refused"); return null; - } - if (xr is null) + }); + var fr = await r.Check("never-read", refused, async c => { - if (memberRead) return $"read {input}: {name} was read without an exception\n text={Jx.Cut(text)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); // not-promised a-missing-property-reaches-no-converter - if (!wrote) r.Note(NoteKeys.JsonBothDirectionsLeftOutByOptions); - } - else 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)) r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(xr)); - else return f + $"\n text={Jx.Cut(text)}"; - } - r.Note(NoteKeys.Compared, $"{name} by {op}, {position}"); - return null; + 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 + { + // 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; + } + 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"); + if (xr is null) + { + if (memberRead) return $"read {input}: {name} was read without an exception\n text={Jx.Cut(text)}"; + 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)) + { + c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(xr)); + return c.Inconclusive($"read {input}: the serializer refuses the document before {name}'s converter, the value object's alike: {Jx.Ex(xr)}"); + } + return f + $"\n text={Jx.Cut(text)}"; + } + c.Note(NoteKeys.Compared, $"{name} by {op}, {position}: read {input} refused"); + return null; + }); + return fw ?? fr; } static async Task<(Type, object)> Produce(string shape, string op, V v) where V : notnull diff --git a/tools/probe/corpus/harness/Positions.cs b/tools/probe/corpus/harness/Positions.cs index 989e0d85..d3a94d63 100644 --- a/tools/probe/corpus/harness/Positions.cs +++ b/tools/probe/corpus/harness/Positions.cs @@ -188,8 +188,13 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) } static bool Validated(Entry e) => e.Kind.StartsWith("validated"); - /// The oracle "JSON parity" (differential): a shape with the value object and the same shape with the wrapped type give the same - /// bytes under the same options and read each other's JSON. Returns null for a pass, otherwise the failure. + /// + /// 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. + /// public static string? Check(Row r, Entry e, JsonSerializerOptions o, string position, string containing, string input, JsonSerializerOptions? oPlain = null) { oPlain ??= o; @@ -201,74 +206,112 @@ public static bool ReadMayDiffer(Row r, Entry e, string input, Exception? xv) 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 ownVo = r.Get("json.Converters") == "own-for-value-object"; - var preserveOwn = r.Get("json.ReferenceHandler") == "Preserve"; - if (ownVo) + 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; + if (r.Get("json.Converters") == "own-for-value-object") { - if (ev is not null && ep is null) - { - // The application's converter sits on the value object's side only. Its baseline is a converter the application - // registers for the wrapped type at the same position: where that throws alike, System.Text.Json refuses any - // custom converter there (MaxDepth counts its value one level deeper, design note D1), not the value object. - var oOwn = new JsonSerializerOptions(oPlain); - oOwn.Converters.Insert(0, (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(typeof(OwnWrappedConverter<>).MakeGenericType(Make.Held(e.Plain)), JsonRow.Inner(e))!); - var (_, eo) = Ser(hp, oOwn); - if (eo is not null && eo.GetType() == ev.GetType()) { r.Note(NoteKeys.OwnConverterRefusedForWrappedToo, eo.GetType().Name); return null; } - return $"own VO converter row: VO serialization threw {ev.GetType().Name}: {ev.Message}"; - } - r.Note(NoteKeys.OwnVo); - if (jv is not null) - { - var (back, eb) = De(jv, hv, o); - if (eb is not null) { if (r.Get("json.UnmappedMemberHandling") == "Disallow" || input != "valid") { r.Note(NoteKeys.OwnVoReadBackRefused); return null; } return "own VO converter: read-back threw " + eb.GetType().Name + ": " + eb.Message; } - if (back != jv) return $"own VO converter round trip differs: {jv} vs {back}"; - } - return null; + // "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)); + return fw ?? fr; } - if (e.Wrapped == "byte" && position == "array-element" && jp != jv) { r.Note(NoteKeys.ByteArrayIsBase64, "write"); return null; } // STJ writes byte[] as base64; an array of byte value objects is a JSON array + 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)); + 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) + { + // 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. Refusing differently is no agreement at all. - if (ep.GetType() != ev.GetType()) { r.Note(NoteKeys.BothThrewDifferently, ep.GetType().Name + "/" + ev.GetType().Name); return null; } - r.Note(NoteKeys.BothThrew, ep.GetType().Name + ": " + ev.Message[..Math.Min(70, ev.Message.Length)]); return 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) { - // Text-matching site, kept in this step (WS2 step 4, the http family): 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 (r.Is("json.MaxDepth", "3") && ev is JsonException && ev.Message.Contains("maximum allowed depth")) { r.Note(NoteKeys.StjMaxdepth, "charges custom converters"); return null; } // inherent to STJ: any custom converter costs a level (design note D1) - if (preserveOwn && e.Wrapped == "own-record-class" && ev is NotSupportedException) { r.Note(NoteKeys.PreserveDocumented); return null; } // documented (Generators README#json; not-promised preserved-references-over-a-walked-class) + // 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 (ep is not null && ev is null) return $"value object serialized ({jv}) where the wrapped type threw {ep.GetType().Name}: {ep.Message}"; - if (jp != jv && e.Kind == "closed-set" && position == "dictionary-key" && r.Get("json.DictionaryKeyPolicy") != "default") { r.Note(NoteKeys.ClosedSetKeyUnpoliced); return null; } // a closed set travels as its name (not-promised a-closed-set-key-is-its-name) - if (jp != jv) - { - // intended: a struct VO over... none. DateTime is written as UTC; sample is UTC so equal. - return $"JSON differs: wrapped {Cut(jp)} vs value object {Cut(jv)}"; - } - // reading - var text = Input(input, e, oPlain, jp!, position); + // 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)}"; + 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) + { + 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) - { - if (xp.GetType() != xv.GetType() && !(xp is JsonException && xv is JsonException)) { r.Note(NoteKeys.BothThrewDifferently, "read: " + xp.GetType().Name + "/" + xv.GetType().Name); return null; } - r.Note(NoteKeys.ReadBothThrew, xp.GetType().Name); return 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 (preserveOwn && e.Wrapped == "own-record-class" && xv is NotSupportedException) return null; - if (Validated(e) && input == "refused-by-create" && r.Get("json.Converters") != "StoredJsonConverterFactory") return xv is JsonException ? null : $"refusal is {xv.GetType().Name}, not JsonException: {xv.Message}"; - if (input == "null" && e.Vo.IsValueType) return null; // intended: a struct value object refuses JSON null (not-promised a-struct-refuses-a-json-null) - if (e.Kind == "closed-set" && input != "valid") return xv is JsonException ? null : $"closed set refusal is {xv.GetType().Name}"; // a closed set has the members it has + 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)}"; + if (Validated(e) && input == "refused-by-create" && !stored) return c.Refused(Cite.NotPromised("a-validated-value-object-refuses-what-create-refuses"), $"read {input}"); + if (input == "null" && e.Vo.IsValueType) return c.Refused(Cite.NotPromised("a-struct-refuses-a-json-null"), $"read {input}"); + if (e.Kind == "closed-set" && input != "valid") return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#matching-parsing-and-binding"), $"read {input}"); // "Unknown names are rejected." return $"input {input}: wrapped read it, value object threw {xv.GetType().Name}: {Cut(xv.Message)}\n text={Cut(text)}"; } - if (xp is not null && xv is null && input == "null" && !e.Vo.IsValueType && e.Plain.IsValueType) return null; // a reference-type value object takes JSON null as null, as any class does (README: "a JSON null for a struct value object") + // 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 (Validated(e) && input == "refused-by-create" && text != jp && r.Get("json.Converters") != "StoredJsonConverterFactory" && position != "dictionary-key") - return $"validated value object accepted the refused value: {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)}"; - r.Note(NoteKeys.Compared); return null; + c.Note(NoteKeys.Compared, "read " + input); + return null; + } + + /// + /// Under an application's converter for the value object, the generated converter does not run: the value object's document is that + /// converter's, and it reads back as written under the row's options. Where the value object's document cannot be written, a converter + /// the application registers for the wrapped type at the same position is the baseline. + /// + static string? OwnVoRead(Check c, Entry e, Held hp, Held hv, JsonSerializerOptions oPlain, JsonSerializerOptions o, string? jv, Exception? ev, Exception? ep) + { + var oOwn = new JsonSerializerOptions(oPlain); + oOwn.Converters.Insert(0, (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(typeof(OwnWrappedConverter<>).MakeGenericType(Make.Held(e.Plain)), JsonRow.Inner(e))!); + 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 + // 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}"; + } + c.Note(NoteKeys.OwnVo); + var (back, eb) = De(jv!, hv, o); + if (eb is not null) + { + // the same converter for the wrapped type, at the same position: its own document does not read back under these options either + var ebOwn = jo is null ? eo : De(jo, hp, oOwn).Ex; + if (ebOwn is not null && ebOwn.GetType() == eb.GetType()) return c.Inconclusive($"the application's converter's document does not read back under these options, for the wrapped type too: {eb.GetType().Name}"); + return "own VO converter: read-back threw " + eb.GetType().Name + ": " + eb.Message; + } + if (back != jv) return $"own VO converter round trip differs: {jv} vs {back}"; + return null; } static string Cut(string? s) => s is null ? "(null)" : s.Length > 200 ? s[..200] + "..." : s; } diff --git a/tools/probe/corpus/harness/PreserveGuard.cs b/tools/probe/corpus/harness/PreserveGuard.cs index 2b7afd5e..157145c4 100644 --- a/tools/probe/corpus/harness/PreserveGuard.cs +++ b/tools/probe/corpus/harness/PreserveGuard.cs @@ -27,6 +27,7 @@ public static class PreserveGuard var e = kind == "closed-set" ? Registry.Get(kind, "-") : Registry.Get(kind, r["shape.wrapped"]); var position = r["shape.position"]; var containing = position == "root" ? "class" : r["shape.containing"]; + _ = r["input.json"]; // the row's input, which every check of the row runs under; the reads apply it var plain = e.Valid(); var voValue = Make.Vo(e, plain); var hp = Pos.Build(position, containing, e, false, plain); var hv = Pos.Build(position, containing, e, true, voValue); @@ -38,10 +39,15 @@ public static class PreserveGuard var ownWrapped = row.Is("json.Converters", "own-for-wrapped"); var ownVo = row.Is("json.Converters", "own-for-value-object"); var guarded = want == "Preserve" && e.Wrapped == "own-record-class" && !ownWrapped && !ownVo; - return guarded ? Guarded(r, e, hp, hv, o) : Unaffected(r, e, hp, hv, op, o, ownVo); + // The obligation the row's handler and converters select: a converter of the application's, the guarded class under Preserve, + // IgnoreCycles, and otherwise a value the serializer writes with a value converter, under Preserve or with no handler at all. + var obligation = ownWrapped || ownVo ? "own-converters-unaffected" : guarded ? "refused-where-references-could-be-written" + : want == "IgnoreCycles" ? "ignore-cycles-unaffected" : "value-converters-unaffected"; + return guarded ? Guarded(r, obligation, e, hp, hv, o) : Unaffected(r, obligation, e, hp, hv, op, o, ownVo); } - static string? Guarded(Row r, Entry e, Held hp, Held hv, JsonSerializerOptions o) + /// The guarded rows: NotSupportedException naming the wrapped type and Preserve, in both directions, never a document. + static string? Guarded(Row r, string obligation, Entry e, Held hp, Held hv, JsonSerializerOptions o) { var wrapped = Make.Held(e.Plain); string? Refusal(Exception? x, string what) @@ -52,73 +58,104 @@ public static class PreserveGuard } var (jv, xv) = Jx.Ser(hv, o); var (jp, xp) = Jx.Ser(hp, o); - if (jv is not null) + var fw = r.Check(obligation, Baseline.Refusal, c => { - // the options may leave the member out of the document, for the wrapped type too: then no converter ran - if (jp is not null && !Jx.Tokens(jp).Any(t => t.Text == "x") && !Jx.Tokens(jv).Any(t => t.Text == "x")) r.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jv)); - else return Refusal(null, "write") + $"; it wrote {Jx.Cut(jv)}" + (DuplicateId(jv) is { } dup ? $" with {dup} twice" : ""); - } - else if (Refusal(xv, "write") is { } f) return f; - if (xp is not null) { r.Note(NoteKeys.BothThrew, "the wrapped type's document under these options: " + Jx.Ex(xp)); return null; } - // 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); - 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) - if (vv is not null && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Text == "x")) - return $"read {input}: the value object over {Make.Held(e.Plain).Name} read a document under Preserve: {Jx.Cut(text)}"; - r.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); + if (jv is not null) + { + // the options may leave the member out of the document, for the wrapped type too: then no converter ran, and nothing was refused + if (jp is not null && !Jx.Tokens(jp).Any(t => t.Text == "x") && !Jx.Tokens(jv).Any(t => t.Text == "x")) + { c.Note(NoteKeys.JsonMemberLeftOutByOptions, "write: " + Jx.Cut(jv)); return c.Inconclusive("write: the options leave the member out, for the wrapped type too: " + Jx.Cut(jv)); } + return Refusal(null, "write") + $"; it wrote {Jx.Cut(jv)}" + (DuplicateId(jv) is { } dup ? $" with {dup} twice" : ""); + } + if (Refusal(xv, "write") is { } f) return f; + c.Note(NoteKeys.Compared, "write refused"); return null; - } - if (ev is NotSupportedException) return Refusal(ev, "read " + input); - if (ep is not null && ev.GetType() == ep.GetType() && ev.Message == ep.Message) { r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } - if (ev is JsonException && ev.Message.Contains(Make.Held(e.Vo).Name) && ev.InnerException is null) { r.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)}"; + }); + var fr = r.Check(obligation, Baseline.Refusal, 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) + if (vv is not null && Jx.Tokens(JsonSerializer.Serialize(vv, hv.Declared, Jx.Plain)).Any(t => t.Text == "x")) + return $"read {input}: the value object over {Make.Held(e.Plain).Name} read a document under Preserve: {Jx.Cut(text)}"; + c.Note(NoteKeys.JsonMemberLeftOutByOptions, "read " + input); + 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: the guard was never reached + if (ep is not null && ev.GetType() == ep.GetType() && ev.Message == ep.Message) + { c.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return c.Inconclusive($"read {input}: the serializer refuses the document before the guard, the wrapped type's alike: {Jx.Ex(ev)}"); } + 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)}"; + }); + return fw ?? fr; } - static string? Unaffected(Row r, Entry e, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions o, bool ownVo) + /// + /// 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. + /// + 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); - // MaxDepth=3: a converter costs a level of depth, the serializer's or the generated one (design note D1, the catalogue's stj-maxdepth) - if (Jx.Depth(r, xv) || Jx.Depth(r, xp)) { r.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) { r.Note(NoteKeys.BothThrew, "write: " + Jx.Ex(xp)); return null; } - return $"write: both threw, differently: the wrapped type {Jx.Ex(xp)}, the value object {Jx.Ex(xv)}"; - } - 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)}"; - if (DuplicateId(jv!) is { } dup) return $"write: $id {dup} twice in {Jx.Cut(jv)}"; - if (ownVo) - { - r.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back, unaffected - var (jf, _) = Jx.Ser(hv, Jx.Full(o)); - var (_, xr) = Jx.De(jf ?? jv!, hv.Declared, o); - if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); - return OwnReadsInput(r, e, hv, o); - } - if (jv != jp) + // 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 => { - if (e.Wrapped == "byte" && r.Is("shape.position", "array-element")) + // 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 (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)}"; + if (DuplicateId(jv!) is { } dup) return $"write: $id {dup} twice in {Jx.Cut(jv)}"; + if (ownVo) + { + c.Note(NoteKeys.OwnVo); // the application's converter for the value object writes its own document; it reads it back, unaffected + var (jf, _) = Jx.Ser(hv, Jx.Full(o)); + var (_, xr) = Jx.De(jf ?? jv!, hv.Declared, o); + if (xr is not null) return "own converter for the value object: its document does not read back: " + Jx.Ex(xr); + return null; + } + if (byteArray) { - // byte[] is base64 by the serializer's own rule, an array of value objects a JSON array: unaffected means the value - // object's document reads back as written var (back, xb) = Jx.De(jv!, hv.Declared, o); if (xb is not null) return "write: the array of byte value objects does not read back: " + Jx.Ex(xb); var again = JsonSerializer.Serialize(back, hv.Declared, o); if (again != jv) return $"round trip: {Jx.Cut(jv)} read back as {Jx.Cut(again)}"; - r.Note(NoteKeys.Compared, "an array of byte value objects round-trips as a JSON array"); + c.Note(NoteKeys.Compared, "an array of byte value objects round-trips as a JSON array"); return null; } - return $"write: the value object wrote {Jx.Cut(jv)}, the wrapped type {Jx.Cut(jp)}"; - } - var input = r["input.json"]; - var full = Jx.Ser(hp, Jx.Full(op)).Json ?? jp!; - var text = JsonParity.InputFor(input, e, Jx.Full(op), full, r["shape.position"]); - return ReadAlike.Check(r, e, input, text, hp, hv, op, ownVo ? null : o); + if (jv != jp) return $"write: the value object wrote {Jx.Cut(jv)}, the wrapped type {Jx.Cut(jp)}"; + 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); + }); + return fw ?? fr; } /// @@ -126,9 +163,9 @@ public static class PreserveGuard /// 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. /// - static string? OwnReadsInput(Row r, Entry e, Held hv, JsonSerializerOptions o) + static string? OwnReadsInput(Row r, Check c, Entry e, Held hv, JsonSerializerOptions o) { - var input = r["input.json"]; + var input = c["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"); @@ -144,12 +181,12 @@ public static class PreserveGuard return (JsonSerializer.Serialize(v, hv.Declared, bare), null, null); } var handled = Through(o); var unhandled = Through(bare); - if ((handled.Depth ?? unhandled.Depth) is { } depth) { r.Note(NoteKeys.StjMaxdepth, "own converter, read " + input + ": " + Jx.Ex(depth)); return null; } + 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) - { r.Note(NoteKeys.JsonMemberLeftOutByOptions, "own converter: the options write no member"); return 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 {r["handler"]}, {unhandled.Read ?? unhandled.Refused ?? "no member"} without one"; - r.Note(NoteKeys.Compared, $"read {input} through the application's converter, alike with and without the reference handler: {Jx.Cut(handled.Read ?? handled.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; } @@ -170,40 +207,40 @@ public static class PreserveGuard /// public static class ReadAlike { - public static string? Check(Row r, Entry e, string input, string text, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions? ov) + public static string? Check(Row r, Check c, Entry e, string input, string text, Held hp, Held hv, JsonSerializerOptions op, JsonSerializerOptions ov) { var (vp, ep) = Jx.De(text, hp.Declared, op); - if (ov is null) { if (ep is null) r.Note(NoteKeys.Compared, "read " + input + " (the wrapped type; the value object's converter is the application's)"); else r.Note(NoteKeys.ReadBothThrew, Jx.Ex(ep)); return null; } 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 = r.Is("json.Converters", "StoredJsonConverterFactory"); - if (Jx.Depth(r, ev) || Jx.Depth(r, ep)) { r.Note(NoteKeys.StjMaxdepth, "read " + input + ": " + Jx.Ex((ev ?? ep)!)); return null; } + 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 (e.Kind.StartsWith("validated") && input == "refused-by-create" && !stored && e.Refused() is not null && text != Jx.Ser(hp, op).Json && !r.Is("shape.position", "dictionary-key")) + 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)}"; - r.Note(NoteKeys.Compared, "read " + input); return null; + c.Note(NoteKeys.Compared, "read " + input); return null; } if (ep is not null && ev is not null) { - if (ep.GetType() == ev.GetType() && Jx.Swap(ep.Message, Make.Held(e.Plain), Make.Held(e.Vo)) == ev.Message) { r.Note(NoteKeys.JsonReadRefusedByStjAlone, input + ": " + Jx.Ex(ev)); return null; } + // 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)}"; - r.Note(NoteKeys.Compared, "read " + input + ": both refuse, the value object naming itself"); return null; + c.Note(NoteKeys.Compared, "read " + input + ": both refuse, the value object naming itself"); return null; } if (ev is not null) { 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; - r.Note(NoteKeys.Compared, "read " + input + ": refused as documented"); return null; + 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)) - { r.Note(NoteKeys.Compared, "read null: the sealed record reads null, as any class does"); return 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)}"; } } diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index df9c261d..f133d150 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -46,7 +46,10 @@ static Out Unwrap(Func f) /// Plain text of a wrapped value the way the value object formats it (invariant), used as the sample's text. public static string PlainText(Entry e, object plain) => plain is IFormattable f ? f.ToString(Fmt(e), CultureInfo.InvariantCulture) : plain.ToString()!; - public static bool HasText(Entry e) => e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]) is not null; + /// The value object reads text: a generated Parse(string, IFormatProvider), or a closed set's Parse(string) by name. + public static bool HasText(Entry e) => e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]) is not null || ByName(e, "Parse", [typeof(string)]) is not null; + /// A closed set's own Parse and TryParse, by name and ordinal (Generators README#matching-parsing-and-binding): the provider means nothing to a name. + static MethodInfo? ByName(Entry e, string name, Type[] parameters) => e.Kind == "closed-set" ? e.Vo.GetMethod(name, parameters) : null; public static Out Read(Entry e, string entry, string text) => Unwrap(() => { @@ -54,14 +57,17 @@ public static Out Read(Entry e, string entry, string text) => Unwrap(() => { case "Parse": { - var m = e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]); if (m is null) return Out.NA; - return new(false, true, m.Invoke(null, [text, null]), null, null); + var m = e.Vo.GetMethod("Parse", [typeof(string), typeof(IFormatProvider)]); + if (m is not null) return new(false, true, m.Invoke(null, [text, null]), null, null); + return ByName(e, "Parse", [typeof(string)]) is { } n ? new(false, true, n.Invoke(null, [text]), null, null) : Out.NA; } case "TryParse": { - var m = e.Vo.GetMethod("TryParse", [typeof(string), typeof(IFormatProvider), e.Vo.MakeByRefType()]); if (m is null) return Out.NA; - var a = new object?[] { text, null, null }; var ok = (bool)m.Invoke(null, a)!; - return new(false, ok, a[2], null, null); + var m = e.Vo.GetMethod("TryParse", [typeof(string), typeof(IFormatProvider), e.Vo.MakeByRefType()]); + if (m is not null) { var a = new object?[] { text, null, null }; var ok = (bool)m.Invoke(null, a)!; return new(false, ok, a[2], null, null); } + if (ByName(e, "TryParse", [typeof(string), e.Vo.MakeByRefType()]) is not { } n) return Out.NA; + var b = new object?[] { text, null }; var found = (bool)n.Invoke(null, b)!; + return new(false, found, b[1], null, null); } case "IParsable": { @@ -123,6 +129,7 @@ public static Out TryFrom(Entry e, object plain) => Unwrap(() => public static class TextClaims { static readonly string[] Entries = ["Parse", "TryParse", "TryFrom", "type-converter", "IParsable", "ISpanParsable", "ToString-format", "TryFormat-short-span"]; + static bool Reads(string entry) => entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter"; static bool WrappedLosesItToo(Entry e, object plain) { @@ -134,102 +141,197 @@ static bool WrappedLosesItToo(Entry e, object plain) static bool Same(Entry e, object? a, object? b) => a is not null && b is not null && (a.Equals(b) || (e.Wrapped == "DateTime" && ((DateTime)Make.Unwrap(e, a)).ToUniversalTime() == ((DateTime)Make.Unwrap(e, b)).ToUniversalTime())); + /// + /// Why a generated text entry is missing, where a document says it is not generated: a closed set's surface is its own (Generators + /// README#closed-sets), no parsing is generated over a type that cannot be parsed, and no span parsing over a type that has none + /// (#what-is-generated). Each is declared in claims.json too; null where nothing says it is missing. + /// + static string? Missing(Entry e, string entry) + { + if (e.Kind == "closed-set") return entry is "TryFrom" or "ISpanParsable" or "ToString-format" or "TryFormat-short-span" ? "closed-set" : null; + if (!TextOps.HasText(e) && entry is not ("ToString-format" or "TryFormat-short-span" or "TryFrom")) return "not-parsable"; + if (entry == "ISpanParsable" && e.Wrapped is "Uri" or "enum" or "flags-enum" or "NodaTime.Instant") return "no-span-parsing"; + return null; + } + /// The check does not apply: the entry names is not generated, cited by the sentence that says so. + static string? NotGenerated(Check c, Entry e, string entry) => Missing(e, entry) switch + { + "closed-set" => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#closed-sets"), entry), + _ => c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#what-is-generated"), entry), + }; + // ---- L04: write then read gives back the value; text half and JSON half + /// + /// L04's checks: the generated ToString() is the wrapped type's invariant text whatever the culture (invariant-whatever-the-culture); + /// the row's writing entry writes the same (the-type-converter-writes-the-same); that text, or TryFrom's value, reads back as the same + /// value wherever the wrapped type's own does (text-reads-back); and the JSON half, the value object's own document read back and the + /// row's changed input refused with a JsonException naming the value object (json-refusal-is-a-json-exception). Every check runs; + /// returns the first failure. + /// public static string? RoundTrip(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); var vo = Make.Vo(e, plain); + var fails = new List(); if (entry == "TryFrom") - { - var t = TextOps.TryFrom(e, plain); - if (t.NotApplicable) r.Note(NoteKeys.EntryNotApplicable, "TryFrom"); else if (!t.Ok || !Same(e, t.Value, vo)) return $"TryFrom(valid) gave ok={t.Ok} value={t.Value}, expected {vo}"; - } - else if (!TextOps.HasText(e)) r.Note(NoteKeys.NoText, e.Kind); + fails.Add(r.Check("text-reads-back", Baseline.RoundTrip, c => + { + var t = TextOps.TryFrom(e, plain); + if (t.NotApplicable) return Missing(e, entry) is not null ? NotGenerated(c, e, entry) : "TryFrom is not generated for the value object"; + if (!t.Ok || !Same(e, t.Value, vo)) return $"TryFrom(valid) gave ok={t.Ok} value={t.Value}, expected {vo}"; + c.Note(NoteKeys.Compared, "TryFrom(valid)"); + return null; + })); else { - var w = TextOps.Write(e, entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" ? "ToString" : entry, vo); - if (w.NotApplicable) r.Note(NoteKeys.EntryNotApplicable, entry); - else if (w.Ex is not null) return $"{entry}: writing text threw {w.Ex.GetType().Name}: {w.Ex.Message}"; - else + var invariant = TextOps.PlainText(e, plain); + var w0 = TextOps.Write(e, "ToString", vo); + fails.Add(r.Check("invariant-whatever-the-culture", Baseline.Twin, c => { - var text = w.Text!; - var wantText = entry is "TryFormat-short-span" or "type-converter" ? (plain is IFormattable pf ? pf.ToString(null, CultureInfo.InvariantCulture) : plain.ToString()!) : TextOps.PlainText(e, plain); - if (e.Kind != "closed-set" && entry is "ToString-format" or "TryFormat-short-span" or "type-converter" && text != wantText) return $"{entry}: text '{text}' differs from the wrapped type's invariant text '{wantText}' under culture {r["text.culture"]}"; - if (entry == "TryFormat-short-span") + if (w0.Ex is not null) return $"ToString(): threw {w0.Ex.GetType().Name}: {w0.Ex.Message}"; + if (w0.Text != invariant) return $"ToString(): text '{w0.Text}' differs from the wrapped type's invariant text '{invariant}' under culture {c["text.culture"]}"; + c.Note(NoteKeys.Compared, $"ToString() under {c["text.culture"]}"); + return null; + })); + var writes = entry is "ToString-format" or "TryFormat-short-span" or "type-converter"; + var w = writes ? TextOps.Write(e, entry, vo) : w0; + if (writes) + fails.Add(r.Check("the-type-converter-writes-the-same", Baseline.Twin, c => { - var args = new object?[] { vo, 1, null, null }; - try + if (w.NotApplicable) { - typeof(TextOps).GetMethod(nameof(TextOps.FS))!.MakeGenericMethod(e.Vo).Invoke(null, args); - if (text.Length > 1 && (bool)args[2]!) return $"TryFormat into a 1-char span said it fit text of {text.Length} chars"; - if (text.Length > 1 && (int)args[3]! != 0) return "TryFormat wrote chars into a too-short span and still returned false"; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + // TryFormat is generated where the wrapped type has it (ValueObjectFormattableAspect); the wrapped type has none either + if (entry == "TryFormat-short-span" && !typeof(ISpanFormattable).IsAssignableFrom(Make.Held(e.Plain))) + return c.Inconclusive($"no TryFormat: the wrapped {Make.Held(e.Plain).Name} has no ISpanFormattable either"); + return $"{entry}: the value object writes no text where the wrapped {Make.Held(e.Plain).Name} does"; } - catch (TargetInvocationException t) { return $"TryFormat into a short span threw {t.InnerException!.GetType().Name}"; } - } - var readEntry = entry is "ToString-format" or "TryFormat-short-span" ? "Parse" : entry; - if (!(entry == "type-converter" && TextOps.Lossy(e))) // not-promised dates-and-times-lose-detail-as-text - { - var back = TextOps.Read(e, readEntry, text); - if (!back.NotApplicable) + if (w.Ex is not null) return $"{entry}: writing text threw {w.Ex.GetType().Name}: {w.Ex.Message}"; + var text = w.Text!; + var wantText = entry is "TryFormat-short-span" or "type-converter" ? (plain is IFormattable pf ? pf.ToString(null, CultureInfo.InvariantCulture) : plain.ToString()!) : invariant; + if (text != wantText) return $"{entry}: text '{text}' differs from the wrapped type's invariant text '{wantText}' under culture {c["text.culture"]}"; + if (entry == "TryFormat-short-span") { - if (back.Ex is not null) return $"{readEntry}: reading back '{text}' threw {back.Ex.GetType().Name}: {back.Ex.Message}"; - if (!back.Ok) return $"{readEntry}: could not read back its own text '{text}'"; - if (!Same(e, back.Value, vo)) + var args = new object?[] { vo, 1, null, null }; + try { - // Differential: the value object only has to round-trip where its wrapped type does - // (TimeOnly.ToString() under the invariant culture prints HH:mm and drops the seconds). - if (!WrappedLosesItToo(e, plain)) return $"{readEntry}: '{text}' read back as {back.Value}, expected {vo}"; - r.Note(NoteKeys.TextWrappedRoundTripLossyToo); + typeof(TextOps).GetMethod(nameof(TextOps.FS))!.MakeGenericMethod(e.Vo).Invoke(null, args); + if (text.Length > 1 && (bool)args[2]!) return $"TryFormat into a 1-char span said it fit text of {text.Length} chars"; + if (text.Length > 1 && (int)args[3]! != 0) return "TryFormat wrote chars into a too-short span and still returned false"; } + catch (TargetInvocationException t) { return $"TryFormat into a short span threw {t.InnerException!.GetType().Name}"; } } + c.Note(NoteKeys.Compared, $"{entry} under {c["text.culture"]}"); + return null; + })); + fails.Add(r.Check("text-reads-back", Baseline.RoundTrip, c => + { + var readEntry = entry is "ToString-format" or "TryFormat-short-span" ? "Parse" : entry; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (Missing(e, readEntry) is not null) return NotGenerated(c, e, readEntry); + if (w.NotApplicable || w.Ex is not null || w.Text is null) return c.Inconclusive($"{entry}: no text written to read back, judged by the-type-converter-writes-the-same"); + // claims.json declares it: not-promised dates-and-times-lose-detail-as-text + if (entry == "type-converter" && TextOps.Lossy(e)) return c.Inapplicable(Cite.NotPromised("dates-and-times-lose-detail-as-text"), entry); + var text = w.Text; + var back = TextOps.Read(e, readEntry, text); + if (back.NotApplicable) return $"{readEntry}: the value object has no {readEntry} to read its own text '{text}' back"; + if (back.Ex is not null) return $"{readEntry}: reading back '{text}' threw {back.Ex.GetType().Name}: {back.Ex.Message}"; + if (!back.Ok) return $"{readEntry}: could not read back its own text '{text}'"; + if (!Same(e, back.Value, vo)) + { + // Differential: the value object only has to round-trip where its wrapped type does + // (TimeOnly.ToString() under the invariant culture prints HH:mm and drops the seconds). + if (!WrappedLosesItToo(e, plain)) return $"{readEntry}: '{text}' read back as {back.Value}, expected {vo}"; + c.Note(NoteKeys.TextWrappedRoundTripLossyToo); + if (TextOps.Lossy(e)) return c.Inapplicable(Cite.NotPromised("dates-and-times-lose-detail-as-text"), $"{readEntry}: the wrapped type's own text does not read back either"); + return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting"), $"{readEntry}: the wrapped type's own text does not read back either"); // "wherever the underlying type's own does" } - } + c.Note(NoteKeys.Compared, $"{readEntry} read back '{text}'"); + return null; + })); } - return JsonRoundTrip(r, e); + fails.AddRange(JsonRoundTrip(r, e)); + return fails.FirstOrDefault(f => f is not null); } - static string? JsonRoundTrip(Row r, Entry e) + /// + /// 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"). + /// + static List JsonRoundTrip(Row r, Entry e) { + const string Obligation = "json-refusal-is-a-json-exception"; var o = JsonRow.Apply(r, e).Options!; 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; - try { jv = JsonSerializer.Serialize(hv.Value, hv.Declared, o); } - catch (Exception ex) - { - try { JsonSerializer.Serialize(hp.Value, hp.Declared, o); } catch { r.Note(NoteKeys.SerBothThrew); return null; } - if (r.Is("json.MaxDepth", "3") && ex.Message.Contains("maximum allowed depth")) return null; - if (r.Is("json.ReferenceHandler", "Preserve") && e.Wrapped == "own-record-class" && ex is NotSupportedException) return null; // documented - return $"serializing threw {ex.GetType().Name} where the wrapped type did not: {ex.Message}"; - } - string? Back(string text, out Exception? ex) + 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) { ex = null; - try { var v = JsonSerializer.Deserialize(text, hv.Declared, o); return v is null ? "null" : JsonSerializer.Serialize(v, hv.Declared, o); } catch (Exception x) { ex = x; return 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; } } - var b = Back(jv, out var ex1); - if (ex1 is not null) + 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 => { - if (JsonParity.ReadMayDiffer(r, e, "valid", ex1) || r.Get("json.UnmappedMemberHandling") == "Disallow" || (r.Is("json.MaxDepth", "3") && ex1.Message.Contains("maximum allowed depth")) || r.Is("json.ReferenceHandler", "Preserve")) { r.Note(NoteKeys.ReadBackRefused, ex1.GetType().Name); } - else if (PlainFailsToo(r, hp, o, pos, e)) r.Note(NoteKeys.PlainReadBackFailsToo); - else return $"own JSON {Cut(jv)} did not read back: {ex1.GetType().Name}: {Cut(ex1.Message)}"; - } - else if (b != jv && !r.Is("json.ReferenceHandler", "Preserve")) return $"JSON round trip differs: wrote {Cut(jv)}, read and wrote {Cut(b)}"; + if (wx is not null) + { + 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}"; + } + var b = Back(hv, jv!, out var ex1); + if (ex1 is not null) + { + 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) { c.Note(NoteKeys.PlainReadBackFailsToo); 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 (b != 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)}"; + } + c.Note(NoteKeys.Compared, "JSON round trip"); + return null; + })); // a changed input must be read or refused with a JsonException (never another type) var input = r["input.json"]; - if (input != "valid") - { - var text = JsonParity.InputFor(input, e, o, jv, pos); - Back(text, out var ex2); - // The application's own converter is in charge under own-for-value-object: what it throws is its own. - if (ex2 is not null && r.Is("json.Converters", "own-for-value-object")) r.Note(NoteKeys.OwnVoConverterThrew, ex2.GetType().Name); - else if (ex2 is not null && ex2 is not (JsonException or NotSupportedException or InvalidOperationException { })) - 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 (!r.Is("json.Converters", "own-for-value-object") && RootRefusal(e, o, input) is { } refusal) return refusal; - } - return null; + if (input != "valid" && jv is not null) + fails.Add(r.Check(Obligation, Baseline.Refusal, 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 (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)}"); + 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")}"); + return null; + })); + return fails; } /// @@ -252,114 +354,202 @@ static bool WrappedLosesItToo(Entry e, object plain) } catch (Exception) { return null; } // another exception type is judged above, at the row's position } - static bool PlainFailsToo(Row r, Held hp, JsonSerializerOptions o, string pos, Entry e) + /// 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); JsonSerializer.Deserialize(jp, hp.Declared, o); return false; } catch { return true; } + 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 - static string? Quotes(Exception ex, string needle, string how, bool stripPath) - { - string Msg(Exception x) => stripPath && x.Message.IndexOf(" Path:", StringComparison.Ordinal) is var i and >= 0 ? x.Message[..i] : x.Message; - if (needle.Length > 0 && Msg(ex).Contains(needle)) return $"{how}: {ex.GetType().Name} quotes the input: {Cut(ex.Message)}"; - if (ex.InnerException is { } inner && needle.Length > 0 && inner.Message.Contains(needle)) return $"{how}: inner {inner.GetType().Name} quotes the input"; - return null; - } + static string StripPath(Exception x) => x.Message.IndexOf(" Path:", StringComparison.Ordinal) is var i and >= 0 ? x.Message[..i] : x.Message; + /// + /// L02's checks: a parsing entry's refusal of a secret, or of the text Create refuses, neither quotes it (parsing-never-repeats, a + /// FormatException where it is not the type converter's) nor carries an inner exception that does (no-inner-that-quotes); and a JSON + /// read of the secret, or of the row's changed input, where the wrapped value stands in the document refuses without quoting it, but + /// for the dictionary key System.Text.Json puts in the Path (json-never-repeats). Every check runs; returns the first failure. + /// public static string? NoQuote(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); var texts = new List { TextOps.Secret }; if (TextOps.ValidatedKind(e) && e.Refused() is { } rf) texts.Add(TextOps.PlainText(e, rf)); - if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") + var fails = new List(); + if (Reads(entry)) { - foreach (var t in texts) + var outs = texts.Select(t => (Text: t, Out: TextOps.Read(e, entry, t))).ToList(); + string? Absent(Check c) => Missing(e, entry) is not null ? NotGenerated(c, e, entry) : $"{entry}: the value object does not read text through it"; + fails.Add(r.Check("parsing-never-repeats", Baseline.Refusal, c => { - var o = TextOps.Read(e, entry, t); - if (o.NotApplicable) { r.Note(NoteKeys.EntryNotApplicable, entry); continue; } - if (o.Ex is not null) + if (outs.Any(x => x.Out.NotApplicable)) return Absent(c); + var refused = 0; + foreach (var (t, o) in outs) { + if (o.Ex is null) { if (!o.Ok) refused++; continue; } // TryParse answers false: a refusal with nothing to quote + refused++; if (entry == "TryParse") return $"TryParse threw {o.Ex.GetType().Name} on '{t}': {Cut(o.Ex.Message)}"; - if (Quotes(o.Ex, t, entry, false) is { } q) return q; - if (o.Ex.InnerException is not null && !(entry == "type-converter")) return $"{entry}: refusal of '{t}' carries an inner {o.Ex.InnerException.GetType().Name}"; + if (o.Ex.Message.Contains(t)) return $"{entry}: {o.Ex.GetType().Name} quotes the input: {Cut(o.Ex.Message)}"; if (entry != "type-converter" && o.Ex is not FormatException) return $"{entry}: refusal of '{t}' is {o.Ex.GetType().Name}, not FormatException: {Cut(o.Ex.Message)}"; - if (entry == "type-converter" && o.Ex.InnerException is not null) r.Note(NoteKeys.ConverterWrapsInner); } - } - } - else if (entry == "TryFrom" && TextOps.ValidatedKind(e) && e.Refused() is { } rf2) - { - var o = TextOps.TryFrom(e, rf2); if (o.Ex is not null) return $"TryFrom(refused) threw {o.Ex.GetType().Name}"; if (o.Ok) return "TryFrom accepted the refused value"; + if (refused == 0) return c.Inconclusive($"{entry}: every text read, none refused: nothing to quote"); + c.Note(NoteKeys.Compared, $"{entry}: {refused} refused without the text"); + return null; + })); + fails.Add(r.Check("no-inner-that-quotes", Baseline.Refusal, c => + { + if (outs.Any(x => x.Out.NotApplicable)) return Absent(c); + var thrown = 0; + foreach (var (t, o) in outs) + { + if (o.Ex is null) continue; + thrown++; + if (o.Ex.InnerException is { } inner && inner.Message.Contains(t)) return $"{entry}: inner {inner.GetType().Name} quotes the input"; + if (o.Ex.InnerException is not null && entry != "type-converter") return $"{entry}: refusal of '{t}' carries an inner {o.Ex.InnerException.GetType().Name}"; + if (entry == "type-converter" && o.Ex.InnerException is not null) c.Note(NoteKeys.ConverterWrapsInner); + } + if (thrown == 0) return c.Inconclusive($"{entry}: no text refused with an exception: no inner to look at"); + c.Note(NoteKeys.Compared, $"{entry}: {thrown} refusals without a quoting inner"); + return null; + })); } // JSON half var opts = JsonRow.Apply(r, e).Options!; 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)); - string jp; string sample; - try { jp = JsonSerializer.Serialize(hv.Value, hv.Declared, opts); sample = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, plain)), e.Plain, opts); } catch { r.Note(NoteKeys.SerRefused); return null; } - if (r.Get("json.Converters") is "own-for-wrapped" or "own-for-value-object") return null; // the application's own converter owns the message (not-promised the-applications-own-converter) - var at = jp.IndexOf(sample, StringComparison.Ordinal); - var variants = new List<(string Text, string Needle)>(); - if (at >= 0) variants.Add((jp[..at] + "\"" + TextOps.Secret + "\"" + jp[(at + sample.Length)..], TextOps.Secret)); var input = r["input.json"]; - if (input != "valid" && at >= 0) - { - var text = JsonParity.InputFor(input, e, opts, jp, pos); - var x = text.Length - jp.Length + sample.Length; - if (x > 0 && text != jp) { var lit = text.Substring(at, x).Trim('"'); if (lit.Length >= 6 && lit.Length < 60 && lit.All(c => !char.IsPunctuation(c) || c is '-' or ':' or '.')) variants.Add((text, lit)); } - } - foreach (var (text, needle) in variants) + fails.Add(r.Check("json-never-repeats", Baseline.Refusal, c => { - try { JsonSerializer.Deserialize(text, hv.Declared, opts); } - catch (Exception ex) + // the application's own converter owns the message; claims.json declares it + if (c.Get("json.Converters") is "own-for-wrapped" or "own-for-value-object") return c.Inapplicable(Cite.NotPromised("the-applications-own-converter")); + string? doc = null, sample; + try { doc = JsonSerializer.Serialize(hv.Value, hv.Declared, opts); } + catch (Exception vx) { - if (ex is JsonException je && Quotes(je, needle, "JSON read", true) is { } q) // the Path is System.Text.Json's (not-promised a-rejected-dictionary-key-in-the-path) - return q + (pos == "dictionary-key" ? " [dictionary-key]" : ""); - 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)}"; + // the value object's document is the wrapped type's (L01): where it cannot be written, the twin's places the input + try { doc = JsonSerializer.Serialize(hp.Value, hp.Declared, opts); } + catch (Exception px) { return c.Inconclusive($"the options refuse the shape, for the wrapped type too: {vx.GetType().Name} / {px.GetType().Name}"); } } - } - return null; + try { sample = JsonSerializer.Serialize(Make.Unwrap(e, Make.Vo(e, plain)), e.Plain, opts); } + catch (Exception sx) { return c.Inconclusive("the wrapped value alone is not written under these options: " + sx.GetType().Name); } + var at = doc.IndexOf(sample, StringComparison.Ordinal); + var variants = new List<(string Text, string Needle)>(); + if (at >= 0) variants.Add((doc[..at] + "\"" + TextOps.Secret + "\"" + doc[(at + sample.Length)..], TextOps.Secret)); + else if (pos == "dictionary-key" && Probes.Json.Jx.Tokens(doc).FirstOrDefault(t => t.Type == JsonTokenType.PropertyName && t.Text == sample.Trim('"')) is { } key) + // a key is a property name, the wrapped value's text without its quotes: the secret goes there + variants.Add((Probes.Json.Jx.Rewrite(doc, [key], _ => "\"" + TextOps.Secret + "\""), TextOps.Secret)); + if (input != "valid" && at >= 0) + { + var text = JsonParity.InputFor(input, e, opts, doc, pos); + var x = text.Length - doc.Length + sample.Length; + 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; + 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]" : ""); + 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)}"; + } + } + 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"); + return null; + })); + return fails.FirstOrDefault(f => f is not null); } // ---- L03: one set of rules: every entry runs Create and reports its fault + /// + /// L03's checks: TryFrom (tryfrom-runs-create) and the parsing entries (parsing-runs-create) refuse the value Create refuses and accept + /// a valid one, the JSON converter reads back what it writes and refuses what Create refuses (json-runs-create), and every refusal, + /// FromKnownGood's, the FormatException and the JsonException, names the fault (refusals-name-the-fault). A value object without Create + /// is held to accepting the valid value. Every check runs; returns the first failure. + /// public static string? OneRule(Row r, Entry e) { TextOps.Prepare(r, e); var entry = r["text.entry"]; var plain = e.Valid(); - if (!TextOps.ValidatedKind(e) || e.Refused() is not { } refused) + var fails = new List(); + var validated = TextOps.ValidatedKind(e) && e.Refused() is not null; + if (!validated) { // no Create: the valid value passes every entry - if (entry == "TryFrom") { var t = TextOps.TryFrom(e, plain); if (!t.NotApplicable && (!t.Ok || t.Ex is not null)) return "TryFrom(valid) refused on a value object with no rule"; } - else if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") - { var o = TextOps.Read(e, entry, TextOps.PlainText(e, plain)); if (!o.NotApplicable && (o.Ex is not null || !o.Ok)) return $"{entry}: valid text refused: {o.Ex?.GetType().Name} {Cut(o.Ex?.Message)}"; } - return JsonOneRule(r, e, false); + if (entry == "TryFrom") + fails.Add(r.Check("tryfrom-runs-create", Baseline.RoundTrip, c => + { + var t = TextOps.TryFrom(e, plain); + if (t.NotApplicable) return Missing(e, entry) is not null ? NotGenerated(c, e, entry) : "TryFrom is not generated for the value object"; + if (!t.Ok || t.Ex is not null) return "TryFrom(valid) refused on a value object with no rule"; + c.Note(NoteKeys.Compared, "TryFrom(valid), no rule"); return null; + })); + else if (Reads(entry)) + fails.Add(r.Check("parsing-runs-create", Baseline.RoundTrip, c => + { + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + var o = TextOps.Read(e, entry, TextOps.PlainText(e, plain)); + if (o.NotApplicable) return $"{entry}: the value object does not read text through it"; + if (o.Ex is not null || !o.Ok) return $"{entry}: valid text refused: {o.Ex?.GetType().Name} {Cut(o.Ex?.Message)}"; + c.Note(NoteKeys.Compared, $"{entry}(valid), no rule"); return null; + })); + fails.AddRange(JsonOneRule(r, e, false)); + return fails.FirstOrDefault(f => f is not null); } - var rtext = TextOps.PlainText(e, refused); + var refusedValue = e.Refused()!; + var rtext = TextOps.PlainText(e, refusedValue); if (entry == "TryFrom") { - var t = TextOps.TryFrom(e, refused); if (t.Ex is not null) return "TryFrom threw " + t.Ex.GetType().Name; if (t.Ok) return "TryFrom accepted the value Create refuses"; - var kg = Unwrap(() => e.Vo.GetMethod("FromKnownGood")!.Invoke(null, [refused, null])); - if (kg is not { } kex) return "FromKnownGood accepted the value Create refuses"; if (!kex.Message.Contains("Refused")) return "FromKnownGood does not name the fault: " + Cut(kex.Message); + fails.Add(r.Check("tryfrom-runs-create", Baseline.Refusal, c => + { + var t = TextOps.TryFrom(e, refusedValue); if (t.Ex is not null) return "TryFrom threw " + t.Ex.GetType().Name; if (t.Ok) return "TryFrom accepted the value Create refuses"; + c.Note(NoteKeys.Compared, "TryFrom refused"); return null; + })); + fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => + { + var kg = Unwrap(() => e.Vo.GetMethod("FromKnownGood")!.Invoke(null, [refusedValue, null])); + if (kg is not { } kex) return "FromKnownGood accepted the value Create refuses"; if (!kex.Message.Contains("Refused")) return "FromKnownGood does not name the fault: " + Cut(kex.Message); + c.Note(NoteKeys.Compared, "FromKnownGood names the fault"); return null; + })); } - else if (TextOps.HasText(e) && entry is "Parse" or "TryParse" or "IParsable" or "ISpanParsable" or "type-converter") + else if (Reads(entry)) { - var o = TextOps.Read(e, entry, rtext); - if (!o.NotApplicable) + var o = Missing(e, entry) is null ? TextOps.Read(e, entry, rtext) : Out.NA; + fails.Add(r.Check("parsing-runs-create", Baseline.Refusal, c => { + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (o.NotApplicable) return $"{entry}: the value object does not read text through it"; if (entry == "TryParse") { if (o.Ex is not null) return "TryParse threw " + o.Ex.GetType().Name; if (o.Ok) return "TryParse accepted text Create refuses"; } - else + else if (o.Ex is null) return $"{entry} accepted '{rtext}' that Create refuses"; + c.Note(NoteKeys.Compared, $"{entry} refused"); return null; + })); + if (entry != "TryParse") + fails.Add(r.Check("refusals-name-the-fault", Baseline.Refusal, c => { - if (o.Ex is null) return $"{entry} accepted '{rtext}' that Create refuses"; + if (Missing(e, entry) is not null) return NotGenerated(c, e, entry); + if (o.NotApplicable || o.Ex is null) return c.Inconclusive($"{entry}: no refusal to name the fault, judged by parsing-runs-create"); if (!o.Ex.Message.Contains("Refused")) return $"{entry}: refusal does not name the fault ({o.Ex.GetType().Name}): {Cut(o.Ex.Message)}"; if (entry != "type-converter" && o.Ex is not FormatException) return $"{entry}: refusal is {o.Ex.GetType().Name}, not FormatException"; - } - } + c.Note(NoteKeys.Compared, $"{entry}: the {o.Ex.GetType().Name} names the fault"); return null; + })); } - return JsonOneRule(r, e, true); + fails.AddRange(JsonOneRule(r, e, true)); + return fails.FirstOrDefault(f => f is not null); } static Exception? Unwrap(Func f) { try { f(); return null; } catch (TargetInvocationException t) { return t.InnerException; } catch (Exception ex) { return ex; } } @@ -371,8 +561,9 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) /// 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). /// - static string? JsonOneRule(Row r, Entry e, bool validated) + static List JsonOneRule(Row r, Entry e, bool validated) { + const string Obligation = "json-runs-create"; var o = JsonRow.Apply(r, e).Options!; 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)); @@ -386,49 +577,81 @@ bool OwnConverterRefusedToo(Exception voEx) var ex = wex ?? Read(text!, hp, own).Ex; return ex is not null && ex.GetType() == voEx.GetType(); } - var (jp, xp) = Write(hp, o); var (jv, xv) = Write(hv, o); // 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). - if (xp is not null && xv is not null) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "write"); return null; } - if (xp is not null) { r.Note(NoteKeys.PlainRefused, "the wrapped type is not written under these options"); return null; } - if (xv is not null) - { - if (OwnConverterRefusedToo(xv)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "write, with a converter of the application's for the wrapped type"); return null; } - r.Note(NoteKeys.SerRefused, "the value object is not written where the wrapped type is: JSON parity (L01) judges it"); return null; - } + string? Alone(Check c, string what) { c.Note(NoteKeys.JsonHalfWrappedAloneRefuses, what); return c.Inconclusive($"JSON {what}: the wrapped type alone refuses under these options, the value object alike"); } + 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 fails = new List(); + // 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 - var (back, xb) = Read(jv!, hv, o); - if (xb is not null) + fails.Add(r.Check(Obligation, Baseline.RoundTrip, c => { - if (OwnConverterRefusedToo(xb)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "read back, with a converter of the application's for the wrapped type"); return null; } - if (Read(jp!, hp, o).Ex is not null) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "read back"); return null; } - return $"JSON: the value object does not read back what it writes ({xb.GetType().Name}: {Cut(xb.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 (r.Is("json.Converters", "own-for-value-object")) { r.Note(NoteKeys.OwnVo, "the application's converter takes precedence and reads its own spelling"); return null; } - if (!validated) return null; - // 2. the value Create refuses - var text = JsonParity.InputFor("refused-by-create", e, o, jp!, pos); - if (text == jp) { r.Note(NoteKeys.RefusedInputNotPlaceable, pos); return null; } + 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) { 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 (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)}"; + } + 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 (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; + })); + 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); - if (Read(text, hp, o).Ex is not null) - { - if (xr is null) return $"JSON read accepted text the wrapped type refuses: {Cut(text)}"; - r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "the refused value's JSON"); return null; - } - if (r.Is("json.Converters", "StoredJsonConverterFactory")) + 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 stored = r.Is("json.Converters", "StoredJsonConverterFactory"); + fails.Add(r.Check(Obligation, stored ? Baseline.Docs("README.md#what-typekit-guarantees") : Baseline.Refusal, c => { - if (xr is not null) return $"StoredJsonConverterFactory refused a value Create refuses ({xr.GetType().Name}): it reads stored JSON without validation"; - r.Note(NoteKeys.StoredFactoryReadUnvalidated); return null; // not-promised stored-json-skips-validation - } - if (xr is null) return $"JSON read accepted the value Create refuses: {Cut(text)}"; - if (xr is not JsonException) return $"JSON refusal is {xr.GetType().Name}, not JsonException: {Cut(xr.Message)}"; - if (!xr.Message.Contains("Refused")) - { - if (OwnConverterRefusedToo(xr)) { r.Note(NoteKeys.JsonHalfWrappedAloneRefuses, "refused read, with a converter of the application's for the wrapped type"); return null; } - return $"JsonException does not name the fault: {Cut(xr.Message)}"; - } - return null; + // "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 (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"); + } + if (stored) + { + // "Only two paths skip validation, and both are named": StoredJsonConverterFactory reads the stored value without Create + if (xr is not null) return $"StoredJsonConverterFactory refused a value Create refuses ({xr.GetType().Name}): it reads stored JSON without validation"; + c.Note(NoteKeys.StoredFactoryReadUnvalidated); return null; // not-promised stored-json-skips-validation + } + if (xr is null) return $"JSON read accepted the value Create refuses: {Cut(text)}"; + if (xr is not JsonException) + { + if (Guarded(xr)) return c.Refused(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#json"), "the refused value"); + return $"JSON refusal is {xr.GetType().Name}, not JsonException: {Cut(xr.Message)}"; + } + 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) + 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)})"; + })); + return fails; } } diff --git a/tools/probe/corpus/probes/C02OptionalTextProbe.cs b/tools/probe/corpus/probes/C02OptionalTextProbe.cs index e1dcb8bf..30419505 100644 --- a/tools/probe/corpus/probes/C02OptionalTextProbe.cs +++ b/tools/probe/corpus/probes/C02OptionalTextProbe.cs @@ -42,24 +42,34 @@ public sealed class C02OptionalTextProbe } var optional = entry.StartsWith("AsOptional"); var text = entry.EndsWith("Text"); - var invariantMode = gi; + string got, want; + if (optional) { - string got; - if (optional) - { - var res = text ? s.AsOptionalText() : s.AsOptionalIdentifier(); - got = res.Match(o => o.Match(v => "Some:" + v, () => "None"), f => "Error:" + f); - var want = expected switch { "ok" => "Some:word", "Blank" => "None", _ => "Error:Hidden" }; - if (got != want) return $"{entry}({kind}) = {got}; expected {want}"; - } - else + var res = text ? s.AsOptionalText() : s.AsOptionalIdentifier(); + got = res.Match(o => o.Match(v => "Some:" + v, () => "None"), f => "Error:" + f); + want = expected switch { "ok" => "Some:word", "Blank" => "None", _ => "Error:Hidden" }; + } + else + { + var res = text ? s.AsText() : s.AsIdentifier(); + got = res.Match(v => "ok:" + v, f => "Error:" + f); + want = expected switch { "ok" => "ok:word", _ => "Error:" + expected }; + } + // The obligation the input selects: a hidden character is an error in both; a blank is an error for a required field and no value + // for an optional one, and a null is a blank too. A word is checked as the blank obligations' control: it is no blank, in either. + var obligations = new List { expected == "Hidden" ? "hidden-is-an-error-in-both" : optional ? "optional-blank-is-no-value" : "required-blank-is-an-error" }; + if (kind == "null") obligations.Add("null-is-blank"); + string? fail = null; + foreach (var obligation in obligations) + { + var f = r.Check(obligation, Baseline.Docs("docs/text.md#required-and-optional"), c => { - var res = text ? s.AsText() : s.AsIdentifier(); - got = res.Match(v => "ok:" + v, f => "Error:" + f); - var want = expected switch { "ok" => "ok:word", _ => "Error:" + expected }; if (got != want) return $"{entry}({kind}) = {got}; expected {want}"; - } + c.Note(NoteKeys.Compared, $"{entry}({kind}) = {got}"); + return null; + }); + fail ??= f; } - return null; + return fail; } } diff --git a/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs b/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs index 19a904b6..b1e977c2 100644 --- a/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs +++ b/tools/probe/corpus/probes/C09BaseRecordEqualityProbe.cs @@ -58,24 +58,37 @@ public sealed class C09BaseRecordEqualityProbe if (!(bool)eq.Invoke(comparer, [a, same])! || (bool)eq.Invoke(comparer, [a, other])!) { r.Outcome("harness-error", "the samples do not compare as intended under the wrapped type's equality"); return null; } var va = c.Make(a); var vb = c.Make(same); var vc = c.Make(other); - var fails = new List(); - if (!va.Equals(vb)) fails.Add("Equals(object) is false for equal wrapped values"); - if (va.Equals(vc)) fails.Add("Equals(object) is true for different wrapped values"); - if (va.GetHashCode() != vb.GetHashCode()) fails.Add("equal value objects have different hash codes"); - var op = c.Vo.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static, [c.Vo, c.Vo]); - if (op is null) fails.Add("no operator =="); - else if (!(bool)op.Invoke(null, [va, vb])!) fails.Add("== is false for equal wrapped values"); - - foreach (var t in c.Bases.Prepend(c.Vo)) + var baseline = Probes.Shapes.Baseline.Twin; // the wrapped type's EqualityComparer, which the samples were checked against + var f1 = r.Check("equal-by-the-wrapped-value", baseline, k => { - var label = t == c.Vo ? "the value object" : $"the base {t.Name}"; - var f = (string?)typeof(C09BaseRecordEqualityProbe).GetMethod(nameof(Collect), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(t).Invoke(null, [collection, va, vb, vc]); - if (f is not null) fails.Add($"{label}: {f}"); - } - r.Note(NoteKeys.Compared, $"{c.Bases.Length + 1} types, against EqualityComparer<{a.GetType().Name}>"); - return fails.Count == 0 ? null : string.Join("; ", fails); + var fails = new List(); + if (!va.Equals(vb)) fails.Add("Equals(object) is false for equal wrapped values"); + if (va.Equals(vc)) fails.Add("Equals(object) is true for different wrapped values"); + if (va.GetHashCode() != vb.GetHashCode()) fails.Add("equal value objects have different hash codes"); + var op = c.Vo.GetMethod("op_Equality", BindingFlags.Public | BindingFlags.Static, [c.Vo, c.Vo]); + if (op is null) fails.Add("no operator =="); + else if (!(bool)op.Invoke(null, [va, vb])!) fails.Add("== is false for equal wrapped values"); + if (Held(c.Vo, collection, va, vb, vc) is { } f) fails.Add($"the value object: {f}"); + if (fails.Count > 0) return string.Join("; ", fails); + k.Note(NoteKeys.Compared, $"the value object, against EqualityComparer<{a.GetType().Name}>"); + return null; + }); + var f2 = r.Check("in-a-collection-of-the-base", baseline, k => + { + if (c.Bases.Length == 0) return k.Inconclusive("no base record in the chain: claims.json excludes the row's pairs"); + var fails = new List(); + foreach (var t in c.Bases) + if (Held(t, collection, va, vb, vc) is { } f) fails.Add($"the base {t.Name}: {f}"); + if (fails.Count > 0) return string.Join("; ", fails); + k.Note(NoteKeys.Compared, $"{c.Bases.Length} base type(s), against EqualityComparer<{a.GetType().Name}>"); + return null; + }); + return f1 ?? f2; } + static string? Held(Type t, string collection, object a, object b, object c) => + (string?)typeof(C09BaseRecordEqualityProbe).GetMethod(nameof(Collect), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(t).Invoke(null, [collection, a, b, c]); + /// Three values held as , two of them equal: the collection holds two, and IEquatable agrees. static string? Collect(string collection, object a, object b, object c) where T : notnull { diff --git a/tools/probe/corpus/probes/C20ShouldHoldProbe.cs b/tools/probe/corpus/probes/C20ShouldHoldProbe.cs index 4d323979..f84da364 100644 --- a/tools/probe/corpus/probes/C20ShouldHoldProbe.cs +++ b/tools/probe/corpus/probes/C20ShouldHoldProbe.cs @@ -50,37 +50,73 @@ public sealed class C20ShouldHoldProbe var wrong = r["wrong-reason"] == "yes"; // the held fault: with wrong-reason=yes the carrier holds a fault of ANOTHER type (foreign) and the test asks for TW's reason a TC held = wrong ? foreign : make(a); - string? fail = null; var op = r["or.operation"]; var shape = r["or.shape"]; TC? got = await Carry(r, held, op, shape); - if (got is null) return null; // inapplicable, noted + if (got is null) { r.Outcome("harness-error", $"the carrier gave no fault back ({shape}, {op})"); return null; } var composed = got.Value; Fault? hand = null; Exception? thrown = null; try { hand = holdA(composed); } catch (Exception ex) { thrown = ex; } + var docs = Baseline.Docs("src/CodoMetis.TypeKit.Testing/README.md"); if (op == "default-instance") + return r.Check("uninitialized-fails", docs, c => + { + if (thrown is not TypeKitAssertionException u || !u.Message.Contains("uninitialized")) return $"default composed fault: ShouldHold gave {thrown?.GetType().Name ?? "no exception"} {thrown?.Message}; expected TypeKitAssertionException 'uninitialized'"; + c.Note(NoteKeys.Compared, "uninitialized: TypeKitAssertionException"); + return null; + }); + if (wrong) { - if (thrown is not TypeKitAssertionException u || !u.Message.Contains("uninitialized")) return $"default composed fault: ShouldHold gave {thrown?.GetType().Name ?? "no exception"} {thrown?.Message}; expected TypeKitAssertionException 'uninitialized'"; - return null; + // the carrier holds a fault of another type, and the test asks for TW's reason a + var w1 = r.Check("fails-unless-it-holds-that-reason", docs, c => + { + if (thrown is not TypeKitAssertionException) return $"ShouldHold({a}) on a fault of another type gave {(thrown is null ? "a result: " + hand : thrown.GetType().Name)}; expected TypeKitAssertionException"; + // same type other value + try { holdB(composed); return "ShouldHold(other value) passed on a fault of another type"; } catch (TypeKitAssertionException) { } + c.Note(NoteKeys.Compared, "fails for a reason it does not hold"); + return null; + }); + var w2 = r.Check("a-failure-says-the-outcome", docs, c => + { + if (thrown is not TypeKitAssertionException e) return c.Inconclusive("no assertion failed to say anything: judged by fails-unless-it-holds-that-reason"); + if (!e.Message.Contains("Expected the fault") || e.Message.Contains("UNEXPECTED")) return "message: " + e.Message; + // the outcome: the fault the composed fault holds, as ShouldHold of its own reason hands it back + string outcome; + try { outcome = holdForeignSet(composed).ToString()!; } catch (Exception ex) { return c.Inconclusive("the held fault is not handed back by its own reason, judged by whichever-of-its-types: " + ex.GetType().Name); } + if (!e.Message.Contains(outcome)) return $"the failure does not say what the outcome was ({outcome}): {e.Message}"; + c.Note(NoteKeys.Compared, "the failure names " + outcome); + return null; + }); + var w3 = r.Check("whichever-of-its-types", docs, c => + { + // the held reason itself is still reachable by its own type + try { holdForeignSet(composed); } catch (Exception ex) { return "ShouldHold(own reason) threw " + ex.GetType().Name + ": " + ex.Message; } + c.Note(NoteKeys.Compared, "the held fault's own reason, whichever of its types"); + return null; + }); + return w1 ?? w2 ?? w3; } - if (wrong) + var f1 = r.Check("whichever-of-its-types", docs, c => { - if (thrown is not TypeKitAssertionException e) return $"ShouldHold({a}) on a fault of another type gave {(thrown is null ? "a result: " + hand : thrown.GetType().Name)}; expected TypeKitAssertionException"; - if (!e.Message.Contains("Expected the fault") || e.Message.Contains("UNEXPECTED")) return "message: " + e.Message; - // the outcome: the fault the composed fault holds, as ShouldHold of its own reason hands it back - var outcome = holdForeignSet(composed).ToString()!; - if (!e.Message.Contains(outcome)) return $"the failure does not say what the outcome was ({outcome}): {e.Message}"; - // same type other value - try { holdB(composed); return "ShouldHold(other value) passed on a fault of another type"; } catch (TypeKitAssertionException) { } - // and the held reason itself is still reachable by its own type - try { holdForeignSet(composed); } catch (Exception ex) { return "ShouldHold(own reason) threw " + ex.GetType().Name + ": " + ex.Message; } + if (thrown is not null) return $"ShouldHold({a}) on a fault holding it threw {thrown.GetType().Name}: {thrown.Message}"; + if (!EqualityComparer.Default.Equals(hand!.Value.Reason, a)) return $"returned fault has reason {hand.Value.Reason}, expected {a}"; + c.Note(NoteKeys.Compared, $"the reason of the {r["set"]} of {r["arity"]} types"); return null; - } - if (thrown is not null) return $"ShouldHold({a}) on a fault holding it threw {thrown.GetType().Name}: {thrown.Message}"; - if (!EqualityComparer.Default.Equals(hand!.Value.Reason, a)) return $"returned fault has reason {hand.Value.Reason}, expected {a}"; - var want = Held(a); - if (hand.Value.Detail != want.Detail || hand.Value.Rule != want.Rule) return $"returned fault has detail '{hand.Value.Detail}' and rule '{hand.Value.Rule}', expected '{want.Detail}' and '{want.Rule}'"; - try { holdB(composed); fail = "ShouldHold(other value of the same type) passed"; } catch (TypeKitAssertionException) { } - return fail; + }); + var f2 = r.Check("hands-back-detail-and-rule", docs, c => + { + if (thrown is not null || hand is null) return c.Inconclusive("nothing handed back: judged by whichever-of-its-types"); + var want = Held(a); + if (hand.Value.Detail != want.Detail || hand.Value.Rule != want.Rule) return $"returned fault has detail '{hand.Value.Detail}' and rule '{hand.Value.Rule}', expected '{want.Detail}' and '{want.Rule}'"; + c.Note(NoteKeys.Compared, "detail and rule intact"); + return null; + }); + var f3 = r.Check("fails-unless-it-holds-that-reason", docs, c => + { + try { holdB(composed); return "ShouldHold(other value of the same type) passed"; } catch (TypeKitAssertionException) { } + c.Note(NoteKeys.Compared, "fails for another value of the reason's type"); + return null; + }); + return f1 ?? f2 ?? f3; } // Carries the fault through the carrier and the operation, then extracts it with the Testing assertions. null = the combination has no such operation (noted inapplicable). @@ -122,7 +158,7 @@ public sealed class C20ShouldHoldProbe case "Sequence-Transpose": re = new[] { re }.Sequence(); break; case "Result.Catch": re = Result.Catch((Action)(() => throw new InvalidOperationException())).MapError(_ => fault); break; case "after-await-elsewhere": await Task.Yield(); re = await Task.Run(() => re).ConfigureAwait(false); break; - default: r.Note(NoteKeys.EntryNotApplicable, op + " on Result-E"); return null; + default: throw new ArgumentException(op + " on Result-E: the row's operation is substituted above where the carrier has none"); } return re.ShouldBeError(); } @@ -137,7 +173,7 @@ public sealed class C20ShouldHoldProbe case "FirstOrNone": opt = new[] { fault }.FirstOrNone(); break; case "Sequence-Transpose": { var back = Option.Some>(Result.Error(fault)).Transpose().ShouldBeError(); opt = Option.Some(back); break; } case "after-await-elsewhere": await Task.Yield(); opt = await Task.Run(() => opt).ConfigureAwait(false); break; - default: r.Note(NoteKeys.EntryNotApplicable, op + " on Option"); return null; + default: throw new ArgumentException(op + " on Option: the row's operation is substituted above where the carrier has none"); } return opt.ShouldBeSome(); } From a4e0e7d2d5cbca8e3bfd431da1823ee1fd32fc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:23:28 +0200 Subject: [PATCH 2/3] test: a JSON half the framework alone refuses is a pass, as decision 99's document is json-half:wrapped-alone-refuses (L03) and json:read-refused-by-stj-alone (C10, L06) are pass keys: the refusal is shown to be the framework's by the twin in the same row, as doc:refused-by-aspnet-alone is in the http family (#53). The first conversion ended those checks inconclusive, which lost 21 rows of L03 and L06 that passed on them. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/harness/OffTheWire.cs | 3 ++- tools/probe/corpus/harness/PreserveGuard.cs | 4 ++-- tools/probe/corpus/harness/TextRow.cs | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/probe/corpus/harness/OffTheWire.cs b/tools/probe/corpus/harness/OffTheWire.cs index 9829bb14..3424695b 100644 --- a/tools/probe/corpus/harness/OffTheWire.cs +++ b/tools/probe/corpus/harness/OffTheWire.cs @@ -128,8 +128,9 @@ public static class OffTheWire 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 c.Inconclusive($"read {input}: the serializer refuses the document before {name}'s converter, the value object's alike: {Jx.Ex(xr)}"); + return null; } return f + $"\n text={Jx.Cut(text)}"; } diff --git a/tools/probe/corpus/harness/PreserveGuard.cs b/tools/probe/corpus/harness/PreserveGuard.cs index 157145c4..43ec6af6 100644 --- a/tools/probe/corpus/harness/PreserveGuard.cs +++ b/tools/probe/corpus/harness/PreserveGuard.cs @@ -88,9 +88,9 @@ 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: the guard was never reached + // 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 c.Inconclusive($"read {input}: the serializer refuses the document before the guard, the wrapped type's alike: {Jx.Ex(ev)}"); } + { 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)}"; }); diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index f133d150..97027b26 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -579,7 +579,8 @@ bool OwnConverterRefusedToo(Exception voEx) } // 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). - string? Alone(Check c, string what) { c.Note(NoteKeys.JsonHalfWrappedAloneRefuses, what); return c.Inconclusive($"JSON {what}: the wrapped type alone refuses under these options, the value object alike"); } + // 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 fails = new List(); From 2002740d81007253bdd0d633517da48c2d231567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:39:32 +0200 Subject: [PATCH 3/3] test: no check of the text family notes an outcome key beside its own outcome, and every text-matching site is marked L04's read back no longer notes plain-read-back-fails-too (expected refusal) before ending inconclusive, nor text:wrapped-round-trip-lossy-too (inapplicable) before its cited inapplicable: the outcome is the check's own call. OffTheWire's "not a wire type" and TextRow's Path cut are marked as the text-matching sites they are, with their baseline gaps. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/harness/OffTheWire.cs | 2 ++ tools/probe/corpus/harness/TextRow.cs | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/probe/corpus/harness/OffTheWire.cs b/tools/probe/corpus/harness/OffTheWire.cs index 3424695b..75d25c6b 100644 --- a/tools/probe/corpus/harness/OffTheWire.cs +++ b/tools/probe/corpus/harness/OffTheWire.cs @@ -62,6 +62,8 @@ 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)}"; return null; } diff --git a/tools/probe/corpus/harness/TextRow.cs b/tools/probe/corpus/harness/TextRow.cs index 97027b26..a7fdb059 100644 --- a/tools/probe/corpus/harness/TextRow.cs +++ b/tools/probe/corpus/harness/TextRow.cs @@ -242,7 +242,6 @@ static bool WrappedLosesItToo(Entry e, object plain) // Differential: the value object only has to round-trip where its wrapped type does // (TimeOnly.ToString() under the invariant culture prints HH:mm and drops the seconds). if (!WrappedLosesItToo(e, plain)) return $"{readEntry}: '{text}' read back as {back.Value}, expected {vo}"; - c.Note(NoteKeys.TextWrappedRoundTripLossyToo); if (TextOps.Lossy(e)) return c.Inapplicable(Cite.NotPromised("dates-and-times-lose-detail-as-text"), $"{readEntry}: the wrapped type's own text does not read back either"); return c.Inapplicable(Cite.Docs("src/CodoMetis.TypeKit.Generators/README.md#parsing-and-formatting"), $"{readEntry}: the wrapped type's own text does not read back either"); // "wherever the underlying type's own does" } @@ -294,7 +293,7 @@ static bool WrappedLosesItToo(Entry e, object plain) // 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) { c.Note(NoteKeys.PlainReadBackFailsToo); 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)}"); } + 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 (b != jv) @@ -370,6 +369,8 @@ static bool PlainHasInner(string text, Held hp, JsonSerializerOptions o) 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; ///