From 26882e5081d50911fc1ce73d05639732e77bdb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:03:35 +0100 Subject: [PATCH 01/30] build: open 2.3.1 from v2.3.0, and move the package-validation baseline to 2.3.0 2.3.1 is a patch of 2.3.0 (tag v2.3.0 at edeb90c): release/2.3.1 is cut from that tag, per decision 94's patch release, and carries only fixes for bugs that shipped in 2.3.0. Version is 2.3.1, and the changelog opens its section as Unreleased with its first line, since pack fails while the section is empty. Package validation compares with 2.3.0, as release/2.4.0 does, so a fix here can't change what 2.3.0 ships. Closes #199 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ Directory.Build.props | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6422f6a..59c7192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ A version's heading reads `## — Unreleased` until the release is pre the release date. The release workflow refuses a tag whose section still says `Unreleased`, and the section is the GitHub release's notes. +## 2.3.1 — Unreleased + +### Changed + +- Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. + ## 2.3.0 — 2026-10-29 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index 4883433..7333e24 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.3.0 + 2.3.1 CaffeinatedCoder Copyright (c) 2026 CaffeinatedCoder MIT @@ -42,7 +42,7 @@ --> true - 2.2.0 + 2.3.0 From 4a3d9a6c0be180d2992abffbb0ceab8ef839d051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 16:31:29 +0100 Subject: [PATCH 02/30] fix: refuse an explicit implementation of a generated member wherever a value object reaches it CMTK1011, and CMTK1008 for comparison, listed the members of the value object and its base records. An interface's explicit implementation of a member the generators implement is the default every implementing type reaches until it declares a public member, so the generated one replaced it without a word: Max() read the generated MaxValue where IBounded's threw, and an IFormattable call printed 1 where the interface answered its own text. Metalama lists no private member of a type in another assembly, so a base record from a referenced project was missed too, replaced (IFormattable) or kept answering beside the generated member (IMinMaxValue). ExplicitImplementationsOf now asks the interface mapping (TryFindImplementationForInterfaceMember) for each member of a generated interface the type implements, and refuses an explicit implementation, naming where it is: "in its base type X" or "in its interface X". An interface's == maps to the record's own, so it stays allowed. INumberBase's UTF-8 bridges, the only such members in .NET's interfaces, are not refused, compared by assembly symbol. IConvertible declared through an interface, and a closed set's IParsable through one, are CMTK1011 where the aspect crashed (LAMA0041, LAMA0512). Decision 121 records it, an exception to decision 34 under decision 119. Input class (GTS): an explicit implementation of a generated interface's member, by where it sits. Catalogue in BuildOutcomeTests: the eleven interfaces of #191 on a record struct, plain and validated; two interfaces down; through a base record's interface; on a record class over string; from another assembly (interface and base record, in Generators.Probes); a closed set through an interface of either assembly. Four controls build: a ToString() seam, a Uri's missing ordering, an interface's ==, an INumberBase value object. Siblings found and fixed here: another assembly's base record (missed since phase 11), IConvertible through an interface (LAMA0041), a closed set's IClosedSet and IParsable through an interface. ExplicitlyImplementsEquatableBetween has no gap: a record base's synthesized Equals(TBase) wins over any interface default. Left for its own issue: IValueObject<,> through a user interface still fails with LAMA0512. Reverting the three source files fails 30 of 263 BuildOutcomeTests; each guard's own seed fails only its rows. Cherry-picked from 466c541 (#193, on release/2.4.0) for 2.3.1, since the defect shipped in 2.3.0: decision 121 (124 on release/2.4.0, renumbered here so 2.3.1's records run without a gap) records the exception to decision 34 as taken in the patch, and the changelog line sits in 2.3.1's section. Refs #191 Defect-Class: GTS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 86 +++++ design/adr/README.md | 1 + design/aspects.md | 8 + .../ClosedSetTypes.cs | 16 +- src/CodoMetis.TypeKit.Generators/README.md | 23 +- .../ValueObjectComparableAspect.cs | 3 +- .../ValueObjectDeclaration.cs | 65 +++- .../Probes.cs | 44 +++ .../BuildOutcomeTests.cs | 312 +++++++++++++++++- .../GeneratedSurface.verified.txt | 2 + test/oracles/claims.json | 29 ++ 12 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c7192..a40191a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ section is the GitHub release's notes. ### Changed - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. +- Generators: an explicit implementation of a generated member, such as `IFormattable.ToString` or `IMinMaxValue.MaxValue`, in an interface a value object or closed set implements, or in a base record from another project, now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade. The generated member replaced the interface's without a word, or the base's kept answering beside it: remove it, or don't implement that interface. `IConvertible` declared through an interface now fails with CMTK1011 rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md new file mode 100644 index 0000000..ff45c2e --- /dev/null +++ b/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md @@ -0,0 +1,86 @@ +# 0121. An explicit implementation is refused wherever the value object reaches it + +Date: 2026-10-30 +Status: accepted + +Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191), under the 2.3.1 epic +[#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while +[#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic +operators, where an interface's explicit implementation is kept, from an outside review of that pull request; +refines +[decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to +[decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses an explicit +implementation of an interface the generators implement, on the value object and, since phase 11, on a base +record (CMTK1011, CMTK1008 for the comparison interfaces). `ValueObjectDeclaration.ExplicitImplementationsOf` +listed the members of the value object and its base records, and nothing else. + +## What was measured + +On `release/2.4.0` at `97c87c1`, 2026-10-30, with a consumer of the generators, the code these members are +generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: + +- **An interface's explicit implementation was replaced without a word.** An interface can implement a member + of a base interface explicitly, and every type that declares it reaches that default until it declares a + public member of its own. The generated member is one, for each of the eleven interfaces the generators + implement: `IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, + `IUtf8SpanParsable`, `IMinMaxValue`, `IComparable`, `IComparable`, `IComparisonOperators` (each answered + the generated member's result through a generic call, where the interface's threw or answered its own) and + `IConvertible` (below). The same held two interfaces down and through an interface of a base record. +- **Another assembly's were missed too, a base record's included.** Metalama lists no private member of a type + in another assembly, and an explicit implementation is private, so the listing saw none in a referenced + project. An interface there was replaced as above. A base record there was replaced or kept answering the + interface: an `IFormattable` call on a value object over a referenced record that implements it printed `1`, + where the base answered its own text, and `Max()` over a referenced `MinMaxBase` reached the base's, + which threw. The rule has had this gap since phase 11. + `INamedType.TryFindImplementationForInterfaceMember` finds every one of them. +- **`IConvertible` declared through an interface** failed the aspect (LAMA0041, naming nothing the user wrote), + as declaring it directly did before CMTK1011 named it. So did a closed set's `IParsable` declared + through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or + `TryParse` explicitly was replaced by the generated members without a word. +- **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record + as in a value object, so the generators change nothing there. For the same reason + `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized + `Equals(TBase)` implements `IEquatable` before any interface's default (a `HashSet` of the base held + both value objects). +- **.NET implements three such members itself**: `INumberBase` implements `IUtf8SpanFormattable.TryFormat` + and `IUtf8SpanParsable.Parse` and `TryParse` explicitly, as bridges to the UTF-16 members. A scan of the + interfaces of `System.Private.CoreLib` for explicit implementations of the eleven found no other; the reference + assembly carries the same three. + +## Decision + +**CMTK1011, and CMTK1008 for the comparison interfaces, refuse an explicit implementation of a member of an +interface the generators implement wherever the value object reaches it**: its own, a base record's, or one in +an interface it implements, directly or through another, in this assembly or another. The error names the member +and where it is, "in its base type `X`" or "in its interface `X`". A closed set's `IClosedSet` is held the +same way. + +- **Found by the interface mapping, never by listing members.** For each member of a generated interface the + value object implements, `TryFindImplementationForInterfaceMember` gives the implementation C# maps it to; an + explicit one is refused. The record's own `==` is what the mapping finds for an interface's `==`, so that case + needs no rule of its own. +- **`IConvertible` and a set's `IParsable` declared through an interface** are CMTK1011, naming the + interface, where the aspect failed. +- **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That + is .NET's `INumberBase`: every .NET number replaces its bridges with its own UTF-8 members, as the + generated ones do, and refusing them would leave no value object able to implement `INumberBase`. The + test is by assembly symbol, never by name. +- **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared + through an interface of the user's (an `IEntityId : IValueObject`) still fails the aspect + (LAMA0512). Whether it should be accepted is a question of its own. + +**The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1, wherever a value object or a +closed set reaches an interface's explicit implementation of a generated member, or a base record's in another +assembly. Each is code that never runs (an interface's default the generated member replaced) or a second answer +beside the generated one (a base's kept implementation), in every build that contains it, so the error names a +defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: +waiting would keep a wrong answer in every 2.3 build, and decision 119's reason, an error that names a defect in +every build that contains it, holds after a tag as it does before one. +The changelog's Changed section says which declarations fail and what to write instead. The forms that failed the +aspect before fail the build with a CMTK error now, which breaks no build. + +`BuildOutcomeTests` holds it: one value object per interface, two interfaces down, through a base record's +interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, +`IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`), a +closed set through an interface of either assembly, and the cases that build: a `ToString()` seam, a `Uri`'s +missing ordering, an interface's `==` and an `INumberBase` value object. diff --git a/design/adr/README.md b/design/adr/README.md index 245695f..45b5700 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,3 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | +| [0121](0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse an explicit implementation of a generated interface's member wherever a value object or closed set reaches it: its own, a base record's or an interface's, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 that 2.3.1 ships as a patch (#191) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 3c1a23b..d25efc6 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -169,6 +169,14 @@ own: its `sealed` `ToString()` is the seam; an unsealed one, its `PrintMembers`, its explicit implementations of the generated interfaces and an `IConvertible` on it are CMTK1011, or CMTK1008 for comparison. Members the compiler generated on a record from another assembly do not count. + - **An interface's explicit implementation counts too (decided 2026-10-30, [decision 121](adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md)).** + It is the default every type declaring the interface reaches until it declares a public member, so + the generated one replaced it without a word. Found by the interface mapping + (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private + member of a type in another assembly, so a base record's from a referenced project was missed as + well. `IConvertible` declared through an interface, and a closed set's `IParsable` through + one, are CMTK1011 where they failed the aspect (LAMA0041, LAMA0512). Not refused: an interface's + `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and whatever the base declares. A base comparing by reference made two value objects holding 42 unequal diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 3b2ac82..ab9e31c 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -189,10 +189,17 @@ private static string WithoutComments(string text) /// overload of another signature is the set's own and is kept, as is ToString(), the seam. A /// member of the set with a generated name is CMTK1021's, which says to rename it. /// + /// + /// An explicit implementation of IClosedSet<TSelf> in an interface the set implements is the + /// set's too: the generated Name and TryParse replaced an interface's without a word (#191, + /// measured 2026-10-30). IParsable<TSelf> declared through an interface failed the aspect as + /// declaring it does (LAMA0512). + /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { var text = TypeFactory.GetType(SpecialType.String); var boolean = TypeFactory.GetType(SpecialType.Boolean); + var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); return @@ -201,9 +208,9 @@ .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(s .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe), .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe), .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe), - .. set.Properties.Where(property => property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe), - .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set))).Select(implemented => $"the interface {implemented.ToDisplayString()}"), + .. ValueObjectDeclaration.ExplicitImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), + .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), + .. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"), .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) || attribute.Type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))) || attribute.Type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)))) @@ -229,7 +236,4 @@ private static bool IsGeneratedMatch(IMethod method, int memberCount) => /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. public static bool DeclaresToString(INamedType set) => set.Methods.Any(method => method is { Name: "ToString", IsStatic: false, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 }); - - private static bool OfClosedSet(IMember implemented) => - implemented.DeclaringType.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>))); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 8d3818b..2342ee8 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -220,10 +220,20 @@ covers: - **A plain value object's members**: `From`, `Value`, `Parse` and `TryParse`, `MinValue`/`MaxValue`. - **The attributes**: `[JsonConverter]` and `[TypeConverter]`. -- **The interfaces the generators implement**, and `IConvertible` where they generate it. +- **The interfaces the generators implement**, and `IConvertible` where they generate it, declared + directly or through another interface. - **An explicit implementation of a member of an interface they implement**, such as - `IParsable.Parse` or `IFormattable.ToString`. Every caller through the interface would reach - it rather than the generated member, and a generic `T.Parse` would skip `Create`. + `IParsable.Parse` or `IFormattable.ToString`. On the value object or a base record, every + caller through the interface would reach it rather than the generated member, and a generic + `T.Parse` would skip `Create`. +- **The same explicit implementation in an interface the value object implements.** The generated + member would replace it, so it would never run. The error names the interface, in your project or + another. + +Two explicit implementations in an interface are fine, because a value object loses nothing by +replacing them. A record's own `==` and `!=` replace an interface's, generated or not. And .NET's +`INumberBase` formats and parses UTF-8 through the UTF-16 members, which every .NET number +replaces with its own, as the generated members do. Declare `Create` once, `public static`, and never as an explicit interface implementation. The generated code can't call an explicit one. And beside a public one, generic code calling `T.Create` @@ -569,7 +579,8 @@ The one place the input still appears is System.Text.Json's own: a rejected dict To order differently, declare `CompareTo(TSelf)` yourself. It's kept, and the object overload, the operators and the interfaces are built from it. Any other hand-written comparison member fails the build (CMTK1008), an explicit implementation of `IComparable`, `IComparable` or -`IComparisonOperators` included. So in memory, every comparison agrees with your `CompareTo`. +`IComparisonOperators` included, in an interface the value object implements too. So in memory, +every comparison agrees with your `CompareTo`. A database can't run your `CompareTo`, because it orders the column by the underlying value. So the type carries `[HandWrittenOrdering]`, and an EF Core query that compares or orders it, or takes its @@ -592,10 +603,10 @@ error ends with a link to this table. CMTK1023 is the one warning: | CMTK1005 | Generic or nested in a generic type (nesting in a non-generic type is fine), `file`-local or nested in a `file`-local type, named `Value` or as another member the generators add (`From`, `TryFrom`, `FromKnownGood`, `Revalidate`, `Parse`, `TryParse`, `TryFormat`, `MinValue`, `MaxValue`, `CompareTo`), derived from another value object, or wrapping `object`, `System.Enum`, an array, a pointer, a nullable type, a generic type (a tuple included) or a value object (itself included). Wrap what the other value object wraps instead. | | CMTK1006 | A record class not declared `sealed`. | | CMTK1007 | The `{TSelf}Extensions` companion's name is taken by a declared type or by another value object's companion. | -| CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones. | +| CMTK1008 | A hand-written comparison operator, object `CompareTo` or explicit implementation of a comparison interface beside the generated ones, on the value object, a base record or an interface it implements. | | CMTK1009 | A hand-written instance constructor, including a positional record's parameter list such as `OrderId(Guid Value)`. The constructor and `Value` are generated. A static constructor is fine. | | CMTK1010 | Declared in more than one `partial` part. Metalama 2026.1 writes the generated `ToString()` into every part, which doesn't compile. A part a source generator adds, such as a `[GeneratedRegex]` method's, doesn't count. | -| CMTK1011 | A hand-written member or attribute the generators add (see "Write your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, a hand-written equality (an `operator ==` or `!=` between two of the type included, which C# also reports as CS0111), a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | +| CMTK1011 | A hand-written member or attribute the generators add (see "Write your own members", and for a closed set every generated member but `ToString()`, by signature, `IParsable`, `[JsonConverter]` and `[TypeConverter]`), an explicit implementation of an interface they implement, on the value object, a base record or an interface it implements, a hand-written equality (an `operator ==` or `!=` between two of the type included, which C# also reports as CS0111), a `PrintMembers` without `ToString()`, or `Create` implemented explicitly. | | CMTK1012 | Instance state besides the underlying value: a field, an auto-property, a `required` member or a field-like event, declared or inherited. Compute it from `Value`, make it static, or wrap a type that holds all of it. | | CMTK1013 | A closed set not declared `partial`. | | CMTK1014 | A closed set not declared `sealed`. A set with subtypes isn't closed. | diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 6bcef68..36fc805 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -136,7 +136,8 @@ private static ValueCompareStrategy ResolveStrategy(INamedType valueType) /// the object overload, the four ordering operators, and an explicit implementation of any member /// of the comparison interfaces. The explicit one was kept beside the generated public one, which /// it hides from every caller through the interface: an explicit IComparable<Rank>.CompareTo - /// sorted a list descending while < kept ascending. + /// sorted a list descending while < kept ascending. A base record's and an interface's count + /// too; the generated one replaced an interface's without a word (#191). /// private static IEnumerable HandWrittenComparisonMembers(INamedType target) { diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 79a2770..b34de0b 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -268,12 +268,17 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // An explicit implementation of an interface the generators implement sat beside the generated // member, which it hides from every caller through the interface: a generic T.Parse bypassed // Create, and interpolation printed through a hand-written IFormattable while ToString() did not. + // An interface's was replaced by the generated member instead, as silently. found.AddRange(ExplicitImplementationsOf(target, generatedInterfaces)); // ValueObjectConvertibleAspect implements every member of IConvertible explicitly, so declaring // the interface at all, with public or explicit members, failed the aspect (LAMA0041: it cannot - // introduce explicit members for an interface it was told to ignore). + // introduce explicit members for an interface it was told to ignore). So did declaring an + // interface derived from it. var convertible = valueType.IsConvertibleTo(typeof(IConvertible)); + var iconvertible = TypeFactory.GetNamedType(typeof(IConvertible)); + + bool DerivesFromConvertible(INamedType implemented) => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(iconvertible)); // The interfaces implemented with OverrideStrategy.Fail, and IConvertible. The marker's own are implied by it. foreach (var implemented in target.ImplementedInterfaces) @@ -281,15 +286,18 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta if (implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObject<,>))) || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObjectMaterializer<,>))) || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IPlainValueObject<,>))) - || (convertible && implemented.Equals(TypeFactory.GetNamedType(typeof(IConvertible))))) + || (convertible && implemented.Equals(iconvertible))) found.Add($"the interface {implemented.ToDisplayString()}"); + else if (convertible && DerivesFromConvertible(implemented)) + found.Add($"the interface IConvertible in its interface {implemented.ToDisplayString()}"); } - // IConvertible implemented by a base record is implemented already, and the aspect failed the same way. + // IConvertible implemented by a base record, itself or through an interface, is implemented + // already, and the aspect failed the same way. if (convertible) { foreach (var type in SelfAndBases(target).Skip(1)) - if (type.ImplementedInterfaces.Any(implemented => implemented.Equals(TypeFactory.GetNamedType(typeof(IConvertible))))) + if (type.ImplementedInterfaces.Any(implemented => implemented.Equals(iconvertible) || DerivesFromConvertible(implemented))) found.Add($"the interface IConvertible in its base type {type.ToDisplayString()}"); } @@ -310,32 +318,57 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta } /// - /// The members of that explicitly implement a member of one of - /// , compared by definition, as names them. + /// The explicit implementations that reaches for a member of one of + /// , compared by definition, as names them: its own, a + /// base record's or an interface's, in this assembly or another. /// /// + /// /// A base record's count too. The generated member replaced one of IFormattable or /// IEqualityOperators without a word, and one of a static interface (IParsable, /// IMinMaxValue) or of IComparable kept answering the interface: a generic /// T.Parse reached a generic base's, skipping Create, and Comparer<T>.Default /// disagreed with < (measured 2026-09-28). + /// + /// + /// So does an interface's, the default every type that declares the interface reaches until it declares + /// a public member of its own. The generated one replaced it without a word: Max<Bounded>() + /// read the generated MaxValue where IBounded<TSelf>'s threw, and an IFormattable + /// call printed 1 where IFormats answered its own text (#191, measured 2026-10-30 for every + /// interface the generators implement). An interface's == is not one: the record's own replaces it, + /// generated or not, and that is the implementation found. + /// + /// + /// Found by the interface mapping, never by listing members: Metalama lists no private member of a type in + /// another assembly, and an explicit implementation is private, so a base record's and an interface's from + /// a referenced project were replaced or kept without a word (measured 2026-10-30). + /// + /// + /// An interface's explicit implementation of a member of an interface in the same library is not refused: + /// .NET's INumberBase<TSelf> bridges UTF-8 formatting and parsing to the UTF-16 members, which + /// every numeric type of .NET replaces with its own and the generators generate both of. They are the only + /// such members in .NET 10's interfaces (scanned 2026-10-30). + /// /// internal static IEnumerable ExplicitImplementationsOf(INamedType target, IReadOnlyList interfaces) { - bool OfOne(IMember implemented) => interfaces.Any(@interface => implemented.DeclaringType.Definition.Equals(@interface)); - - foreach (var type in SelfAndBases(target)) + foreach (var implemented in target.AllImplementedInterfaces) { - var where = type.Equals(target) ? "" : $" in its base type {type.ToDisplayString()}"; + if (!interfaces.Any(@interface => implemented.Definition.Equals(@interface))) continue; - foreach (var method in type.Methods) - if (method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(method) + where; + foreach (var member in implemented.Methods.Concat(implemented.Properties).Concat(implemented.Events)) + { + if (!target.TryFindImplementationForInterfaceMember(member, out var implementation) + || implementation is not { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true }) continue; - foreach (var property in type.Properties) - if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(property) + where; + var declaring = implementation.DeclaringType; - foreach (var @event in type.Events) - if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && @event.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(@event) + where; + if (declaring.TypeKind == TypeKind.Interface && declaring.DeclaringAssembly.Equals(implemented.DeclaringAssembly)) continue; + + yield return Describe(implementation) + (declaring.Equals(target) ? "" + : declaring.TypeKind == TypeKind.Interface ? $" in its interface {declaring.ToDisplayString()}" + : $" in its base type {declaring.ToDisplayString()}"); + } } } diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 252c6a7..e4c3c36 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -221,6 +221,50 @@ public abstract record ProbeIdentityRecord public Guid Identity => _identity; } +/// +/// An interface in another assembly that implements IFormattable explicitly, which a value object +/// declaring it in the build-outcome consumer must refuse (CMTK1011). Metalama lists no private member of a +/// type in another assembly, and an explicit implementation is private, so a check that listed members missed it. +/// +public interface IProbeFormats : IFormattable +{ + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => "***"; +} + +/// The same for IMinMaxValue, a static interface. +public interface IProbeBounded : System.Numerics.IMinMaxValue where TSelf : IProbeBounded +{ + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new NotSupportedException(); +} + +/// The same for IComparable, which CMTK1008 refuses. +public interface IProbeOrders : IComparable +{ + int IComparable.CompareTo(object? obj) => 0; +} + +/// The same for IClosedSet, on a closed set. +public interface IProbeSetNames : IClosedSet where TSelf : class, IProbeSetNames +{ + string IClosedSet.Name => "***"; +} + +/// A base record in another assembly that implements IFormattable explicitly, which a value object derived from it must refuse. +public abstract record ProbeFormattableRecord : IFormattable +{ + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) => "***"; +} + +/// The same for IMinMaxValue, which kept answering the interface. +public abstract record ProbeMinMaxRecord : System.Numerics.IMinMaxValue where TSelf : ProbeMinMaxRecord, System.Numerics.IMinMaxValue +{ + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new NotSupportedException(); +} + /// A closed set with no data and no constructor: the generators add a private one. public sealed partial class ProbeStatus : IClosedSet { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 858d93e..edd8ef6 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -169,10 +169,16 @@ public void A_member_name_the_generators_take_is_refused_with_the_reason(string /// /// What the generators implement and attach, written by hand, failed the aspect or the generated - /// code: IParsable twice, an attribute twice (CS0579). The error names each. + /// code: IParsable twice, an attribute twice (CS0579). The error names each. An interface's + /// explicit implementation of IClosedSet<TSelf> was replaced by the generated member without + /// a word, and IParsable declared through an interface failed the aspect (LAMA0512) (#191). /// [Theory] [InlineData("SetWithParsable", "the interface IParsable")] + [InlineData("SetWithParsableThroughInterface", "the interface IParsable in its interface ISetParsesItself")] + [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.Name in its interface ISetNamesItself")] + [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.TryParse(string?, out SetWithNameThroughInterface) in its interface ISetNamesItself")] + [InlineData("SetWithNameThroughLibraryInterface", "the explicit implementation of IClosedSet.Name in its interface IProbeSetNames")] [InlineData("SetWithTypeConverterAttribute", "the attribute TypeConverter")] [InlineData("SetWithJsonConverterAttribute", "the attribute JsonConverter")] public void What_the_generators_implement_or_attach_is_refused_by_name_on_a_set(string type, string declared) => @@ -365,6 +371,57 @@ public void A_base_records_equality_is_named(string type, string members) => public void An_explicit_implementation_of_a_generated_interface_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// An interface's explicit implementation of a generated interface's member is the default every type + /// declaring the interface reaches until it declares a public member of its own, so the generated one + /// replaced it without a word: Max<T>() read the generated MaxValue where the + /// interface's threw, and an IFormattable call printed the value where the interface answered its + /// own text (#191, measured 2026-10-30 for each interface here). It is refused as the value object's own + /// is, naming the interface, two interfaces down and through a base record too. IConvertible + /// declared through an interface failed the aspect (LAMA0041), as declaring it does. + /// + [Theory] + [InlineData("CMTK1011", "FormatsThroughInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "SpanFormatsThroughInterface", "the explicit implementation of ISpanFormattable.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its interface ISpanFormatsItself")] + [InlineData("CMTK1011", "Utf8FormatsThroughInterface", "the explicit implementation of IUtf8SpanFormattable.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its interface IUtf8FormatsItself")] + [InlineData("CMTK1011", "ParsesThroughInterface", "the explicit implementation of IParsable.Parse(string, IFormatProvider?) in its interface IParsesItself")] + [InlineData("CMTK1011", "SpanParsesThroughInterface", "the explicit implementation of ISpanParsable.Parse(ReadOnlySpan, IFormatProvider?) in its interface ISpanParsesItself")] + [InlineData("CMTK1011", "Utf8ParsesThroughInterface", "the explicit implementation of IUtf8SpanParsable.Parse(ReadOnlySpan, IFormatProvider?) in its interface IUtf8ParsesItself")] + [InlineData("CMTK1011", "BoundedThroughInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IBoundedItself")] + [InlineData("CMTK1011", "ConvertsThroughInterface", "the interface IConvertible in its interface IConvertsItself")] + [InlineData("CMTK1008", "OrdersThroughInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IOrdersItself")] + [InlineData("CMTK1008", "OrdersThroughGenericInterface", "the explicit implementation of IComparable.CompareTo(OrdersThroughGenericInterface) in its interface IOrdersItselfAs")] + [InlineData("CMTK1008", "ComparesThroughInterface", "the explicit implementation of IComparisonOperators.operator <(ComparesThroughInterface, ComparesThroughInterface) in its interface IComparesItself")] + [InlineData("CMTK1011", "FormatsThroughInterfaceOnClass", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1008", "OrdersThroughGenericInterfaceOnClass", "the explicit implementation of IComparable.CompareTo(OrdersThroughGenericInterfaceOnClass?) in its interface IOrdersItselfAs")] + [InlineData("CMTK1011", "FormatsTwoInterfacesDown","the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "ConvertsTwoInterfacesDown", "the interface IConvertible in its interface IConvertsTwoDown")] + [InlineData("CMTK1011", "FormatsThroughBaseInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IFormatsItself")] + [InlineData("CMTK1011", "BoundedThroughBaseInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IBoundedItself")] + [InlineData("CMTK1008", "OrdersThroughBaseInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IOrdersItself")] + [InlineData("CMTK1011", "ConvertsThroughBaseInterface", "the interface IConvertible in its base type ConvertsItselfBase")] + [InlineData("CMTK1011", "FormatsThroughLibraryInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IProbeFormats")] + [InlineData("CMTK1011", "BoundedThroughLibraryInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IProbeBounded")] + [InlineData("CMTK1008", "OrdersThroughLibraryInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IProbeOrders")] + public void An_interface_s_explicit_implementation_of_a_generated_interface_is_refused_by_name(string id, string type, string member) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + + /// + /// An interface's default the generators do not replace is no refusal: beside ToString(), the seam, + /// no formatting interface is generated, and a Uri has no ordering. The record's own == + /// replaces an interface's in every record, generated or not (measured 2026-10-30). .NET's + /// INumberBase<TSelf> implements IUtf8SpanFormattable and IUtf8SpanParsable + /// explicitly as bridges to the UTF-16 members, which every numeric type of .NET replaces with its own, as + /// the generated ones do: refused, no value object could implement it. + /// + [Theory] + [InlineData("FormatsThroughInterfaceWithToString")] + [InlineData("OrdersThroughInterfaceOverUri")] + [InlineData("EqualsThroughInterface")] + [InlineData("MetersAsNumber")] + public void An_interface_s_default_the_generators_do_not_replace_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// /// What a base record declares counts as the value object's own. Its unsealed ToString() and its /// PrintMembers were replaced by the generated ToString(), printing what they hid; an @@ -383,6 +440,8 @@ public void An_explicit_implementation_of_a_generated_interface_is_refused_by_na [InlineData("CMTK1011", "InheritsConvertible", "the interface IConvertible in its base type ConvertibleBase")] [InlineData("CMTK1008", "InheritsExplicitComparable", "the explicit implementation of IComparable.CompareTo(InheritsExplicitComparable?) in its base type ComparableBase")] [InlineData("CMTK1008", "InheritsExplicitObjectComparable", "the explicit implementation of IComparable.CompareTo(object?) in its base type ObjectComparableBase")] + [InlineData("CMTK1011", "InheritsLibraryFormattable", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its base type ProbeFormattableRecord")] + [InlineData("CMTK1011", "InheritsLibraryMinMax", "the explicit implementation of IMinMaxValue.MinValue in its base type ProbeMinMaxRecord")] public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); @@ -1107,6 +1166,239 @@ public abstract record ObjectComparableBase : System.IComparable public sealed partial record InheritsExplicitObjectComparable : ObjectComparableBase, IValue; + // An interface's explicit implementation of a generated interface's member: the default every + // type declaring the interface reaches until it declares a public member of its own, which the + // generated one replaced without a word (#191). Each interface implements every member of the + // generated one, so a refused value object lacks none (CS0535). + public interface IFormatsItself : System.IFormattable + { + string System.IFormattable.ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + } + + public readonly partial record struct FormatsThroughInterface : IValue, IFormatsItself; + + public interface ISpanFormatsItself : System.ISpanFormattable + { + string System.IFormattable.ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + + bool System.ISpanFormattable.TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct SpanFormatsThroughInterface : IValue, ISpanFormatsItself; + + public interface IUtf8FormatsItself : System.IUtf8SpanFormattable + { + bool System.IUtf8SpanFormattable.TryFormat(System.Span utf8Destination, out int bytesWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct Utf8FormatsThroughInterface : IValue, IUtf8FormatsItself; + + public interface IParsesItself : System.IParsable where TSelf : IParsesItself + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ParsesThroughInterface : IValidatedValue, IParsesItself + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + public interface ISpanParsesItself : System.ISpanParsable where TSelf : ISpanParsesItself + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + + static TSelf System.ISpanParsable.Parse(System.ReadOnlySpan s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.ISpanParsable.TryParse(System.ReadOnlySpan s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct SpanParsesThroughInterface : IValue, ISpanParsesItself; + + public interface IUtf8ParsesItself : System.IUtf8SpanParsable where TSelf : IUtf8ParsesItself + { + static TSelf System.IUtf8SpanParsable.Parse(System.ReadOnlySpan utf8Text, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IUtf8SpanParsable.TryParse(System.ReadOnlySpan utf8Text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct Utf8ParsesThroughInterface : IValue, IUtf8ParsesItself; + + public interface IBoundedItself : System.Numerics.IMinMaxValue where TSelf : IBoundedItself + { + static TSelf System.Numerics.IMinMaxValue.MinValue => throw new System.NotSupportedException(); + + static TSelf System.Numerics.IMinMaxValue.MaxValue => throw new System.NotSupportedException(); + } + + public readonly partial record struct BoundedThroughInterface : IValue, IBoundedItself; + + public interface IOrdersItself : System.IComparable + { + int System.IComparable.CompareTo(object? obj) => 0; + } + + public readonly partial record struct OrdersThroughInterface : IValue, IOrdersItself; + + public interface IOrdersItselfAs : System.IComparable + { + int System.IComparable.CompareTo(TSelf? other) => 0; + } + + public readonly partial record struct OrdersThroughGenericInterface : IValue, IOrdersItselfAs; + + public interface IComparesItself : System.Numerics.IComparisonOperators where TSelf : IComparesItself + { + static bool System.Numerics.IComparisonOperators.operator <(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator >(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator <=(TSelf left, TSelf right) => false; + + static bool System.Numerics.IComparisonOperators.operator >=(TSelf left, TSelf right) => false; + } + + public readonly partial record struct ComparesThroughInterface : IValue, IComparesItself; + + // IConvertible declared through an interface failed the aspect (LAMA0041), as declaring it does. + public interface IConvertsItself : System.IConvertible + { + System.TypeCode System.IConvertible.GetTypeCode() => System.TypeCode.Int32; + bool System.IConvertible.ToBoolean(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + byte System.IConvertible.ToByte(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + char System.IConvertible.ToChar(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + System.DateTime System.IConvertible.ToDateTime(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + decimal System.IConvertible.ToDecimal(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + double System.IConvertible.ToDouble(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + short System.IConvertible.ToInt16(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + int System.IConvertible.ToInt32(System.IFormatProvider? provider) => 0; + long System.IConvertible.ToInt64(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + sbyte System.IConvertible.ToSByte(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + float System.IConvertible.ToSingle(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + string System.IConvertible.ToString(System.IFormatProvider? provider) => "***"; + object System.IConvertible.ToType(System.Type conversionType, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + ushort System.IConvertible.ToUInt16(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + uint System.IConvertible.ToUInt32(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + ulong System.IConvertible.ToUInt64(System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ConvertsThroughInterface : IValue, IConvertsItself; + + // A record class's, over a reference type. + public sealed partial record FormatsThroughInterfaceOnClass : IValue, IFormatsItself; + + public sealed partial record OrdersThroughGenericInterfaceOnClass : IValue, IOrdersItselfAs; + + // Two interfaces down, and through an interface of a base record. + public interface IFormatsTwoDown : IFormatsItself; + + public readonly partial record struct FormatsTwoInterfacesDown : IValue, IFormatsTwoDown; + + public interface IConvertsTwoDown : IConvertsItself; + + public readonly partial record struct ConvertsTwoInterfacesDown : IValue, IConvertsTwoDown; + + public abstract record FormatsItselfBase : IFormatsItself; + + public sealed partial record FormatsThroughBaseInterface : FormatsItselfBase, IValue; + + public abstract record BoundedItselfBase : IBoundedItself where TSelf : BoundedItselfBase, IBoundedItself; + + public sealed partial record BoundedThroughBaseInterface : BoundedItselfBase, IValue; + + public abstract record OrdersItselfBase : IOrdersItself; + + public sealed partial record OrdersThroughBaseInterface : OrdersItselfBase, IValue; + + public abstract record ConvertsItselfBase : IConvertsItself; + + public sealed partial record ConvertsThroughBaseInterface : ConvertsItselfBase, IValue; + + // An interface's and a base record's of another assembly, where Metalama lists no private + // member, which an explicit implementation is: replaced or kept without a word (#191). + public readonly partial record struct FormatsThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeFormats; + + public readonly partial record struct BoundedThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeBounded; + + public readonly partial record struct OrdersThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeOrders; + + public sealed partial record InheritsLibraryFormattable : CodoMetis.TypeKit.Generators.Probes.ProbeFormattableRecord, IValue; + + public sealed partial record InheritsLibraryMinMax : CodoMetis.TypeKit.Generators.Probes.ProbeMinMaxRecord, IValue; + + // An interface's default the generators do not replace. Beside ToString(), the seam, no + // formatting interface is generated, and a Uri has no ordering. The record's own == replaces an + // interface's in every record, generated or not. INumberBase, .NET's, implements + // IUtf8SpanFormattable and IUtf8SpanParsable explicitly as bridges to the UTF-16 members, which + // every numeric type of .NET replaces with its own, as the generated ones do. + public readonly partial record struct FormatsThroughInterfaceWithToString : IValue, IFormatsItself + { + public override string ToString() => "***"; + } + + public sealed partial record OrdersThroughInterfaceOverUri : IValue, IOrdersItselfAs; + + public interface IEqualsItself : System.Numerics.IEqualityOperators where TSelf : IEqualsItself + { + static bool System.Numerics.IEqualityOperators.operator ==(TSelf? left, TSelf? right) => true; + + static bool System.Numerics.IEqualityOperators.operator !=(TSelf? left, TSelf? right) => false; + } + + public readonly partial record struct EqualsThroughInterface : IValue, IEqualsItself; + + public readonly partial record struct MetersAsNumber : IValue, System.Numerics.INumberBase + { + public static MetersAsNumber One => throw new System.NotSupportedException(); + public static int Radix => 2; + public static MetersAsNumber Zero => throw new System.NotSupportedException(); + public static MetersAsNumber AdditiveIdentity => throw new System.NotSupportedException(); + public static MetersAsNumber MultiplicativeIdentity => throw new System.NotSupportedException(); + public static MetersAsNumber Abs(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsCanonical(MetersAsNumber value) => true; + public static bool IsComplexNumber(MetersAsNumber value) => false; + public static bool IsEvenInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsFinite(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsImaginaryNumber(MetersAsNumber value) => false; + public static bool IsInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNaN(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNegative(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNegativeInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsNormal(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsOddInteger(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsPositive(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsPositiveInfinity(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsRealNumber(MetersAsNumber value) => true; + public static bool IsSubnormal(MetersAsNumber value) => throw new System.NotSupportedException(); + public static bool IsZero(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber MaxMagnitude(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MaxMagnitudeNumber(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MinMagnitude(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber MinMagnitudeNumber(MetersAsNumber x, MetersAsNumber y) => throw new System.NotSupportedException(); + public static MetersAsNumber Parse(System.ReadOnlySpan s, System.Globalization.NumberStyles style, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + public static MetersAsNumber Parse(string s, System.Globalization.NumberStyles style, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + public static bool TryParse(System.ReadOnlySpan s, System.Globalization.NumberStyles style, System.IFormatProvider? provider, out MetersAsNumber result) => throw new System.NotSupportedException(); + public static bool TryParse(string? s, System.Globalization.NumberStyles style, System.IFormatProvider? provider, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromChecked(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromSaturating(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertFromTruncating(TOther value, out MetersAsNumber result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToChecked(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToSaturating(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + static bool System.Numerics.INumberBase.TryConvertToTruncating(MetersAsNumber value, out TOther result) => throw new System.NotSupportedException(); + public static MetersAsNumber operator +(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator -(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator *(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator /(MetersAsNumber left, MetersAsNumber right) => throw new System.NotSupportedException(); + public static MetersAsNumber operator +(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator -(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator ++(MetersAsNumber value) => throw new System.NotSupportedException(); + public static MetersAsNumber operator --(MetersAsNumber value) => throw new System.NotSupportedException(); + } + // A base record's equality, which the derived record's synthesized one calls. public abstract record ReferenceEqualityBase { @@ -1415,6 +1707,24 @@ public abstract partial class SetAbstract : IClosedSet { } public sealed partial class SetWithParsable : IClosedSet, System.IParsable { public static readonly SetWithParsable A = new(); } + // An interface's explicit implementation of IClosedSet, which the generated Name and + // TryParse replaced without a word (#191), and IParsable declared through an interface, + // which failed the aspect (LAMA0512). + public interface ISetNamesItself : IClosedSet where TSelf : class, ISetNamesItself + { + string IClosedSet.Name => "***"; + + static bool IClosedSet.TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + } + + public sealed partial class SetWithNameThroughInterface : IClosedSet, ISetNamesItself { public static readonly SetWithNameThroughInterface A = new(); } + + public sealed partial class SetWithNameThroughLibraryInterface : IClosedSet, CodoMetis.TypeKit.Generators.Probes.IProbeSetNames { public static readonly SetWithNameThroughLibraryInterface A = new(); } + + public interface ISetParsesItself : System.IParsable where TSelf : ISetParsesItself; + + public sealed partial class SetWithParsableThroughInterface : IClosedSet, ISetParsesItself { public static readonly SetWithParsableThroughInterface A = new(); } + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 14f02da..53f2b5c 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1,4 +1,6 @@ # CodoMetis.TypeKit.Generators.Probes +IProbeSetNames + implements IClosedSet ProbeAmount CompareTo(Object) : Int32 CompareTo(ProbeAmount) : Int32 diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 47a89c5..dc4001e 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1255,6 +1255,21 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-explicit-implementation-in-an-interface", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "**The same explicit implementation in an interface the value object implements.** The generated member would replace it, so it would never run. The error names the interface, in your project or another.", + "claim": "An explicit implementation of a generated interface's member in an interface the value object implements, in this project or another, fails the build naming the interface.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*", + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-generic-wrapped", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", @@ -1270,6 +1285,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-interface-implementations-that-lose-nothing", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "A record's own `==` and `!=` replace an interface's, generated or not. And .NET's `INumberBase` formats and parses UTF-8 through the UTF-16 members, which every .NET number replaces with its own, as the generated members do.", + "claim": "An interface's `==` and `!=`, and `INumberBase`'s UTF-8 members, do not fail the build.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-named-as-generated-member", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From 72bb05cccd5ab4e948f9e54b51dceb069d6f8428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:17:39 +0100 Subject: [PATCH 03/30] fix: refuse a value object's Equals of a base record's type A value object over a base record could declare an Equals of a deeper base's type, such as Equals(DeepBase?). It implemented IEquatable, which the value object lists again through IReimplementedEquatable, so a HashSet compared by it, while == compared the wrapped value: two holding "a" were equal, and the set held both. EqualityDeclaredByHand refused Equals(TSelf) alone, and IntroduceEquality skipped its own new Equals(TBase) because the value object declared one. EqualityDeclaredByHand now refuses an Equals of the value object's type or of any of its base records' types, nullability aside, with the existing CMTK1011 and its remedy (normalise in Create). One of the direct base's type is C#'s own CS0111, beside the override the record synthesizes (measured). Input class (GTS): the parameter type of a hand-written Equals. Catalogue: the value object's own on a record struct and on a record class (nullable parameter, already refused), a deeper base's (refused now), and the direct base's (C#'s CS0111). The record class case had no fixture; it has one now, and no sibling was found. The test fails before the fix and with the base types dropped from the check (1 of 233 BuildOutcomeTests). Refs #196 Defect-Class: GTS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/CodoMetis.TypeKit.Generators/README.md | 4 +++- .../ValueObjectDeclaration.cs | 9 ++++++++- .../BuildOutcomeTests.cs | 19 +++++++++++++++++++ test/oracles/claims.json | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c7192..f1afb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ section is the GitHub release's notes. ### Changed - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. +- Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). ## 2.3.0 — 2026-10-29 diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 8d3818b..c3992ea 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -247,7 +247,9 @@ What a base record declares counts as the value object's own: Equality is the underlying value's, like the ordering, the JSON and the column. So a hand-written `Equals(TSelf)`, `GetHashCode()`, `IEquatable.Equals`, or `operator ==` or `!=` between two of -the type, fails the build (CMTK1011). It would make values equal that sort apart. +the type, fails the build (CMTK1011). It would make values equal that sort apart. So does an `Equals` +you write for one of its base records' types, such as `Equals(DeepBase?)`: a `HashSet` would +compare by it while `==` compares the underlying value. To make values equal that differ only in form, such as "abc" and "ABC", normalise them in `Create`. Equal values then hold the same underlying value. A plain value object becomes an `IValidatedValue` diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 79a2770..4c4c0c2 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -399,9 +399,16 @@ .. SelfAndBases(target).SelectMany(type => type.Methods /// a SortedSet two. An operator == or != between two of the type collides with /// the record's own, which C# reports as CS0111 naming only op_Equality; the error says why. /// + /// + /// An Equals of a base record's type counts too. It implemented IEquatable<TBase>, so a + /// HashSet of the base compared by it while == compared the wrapped value: two holding "a" were + /// equal, and the set held both (#196, measured 2026-10-30). One of the direct base's type is C#'s own CS0111, + /// beside the override the record synthesizes. + /// public static IReadOnlyList EqualityDeclaredByHand(INamedType target) { var equatable = TypeFactory.GetNamedType(typeof(IEquatable<>)); + var ownTypes = SelfAndBases(target).ToList(); return [ @@ -409,7 +416,7 @@ .. target.Methods .Where(method => method is { IsStatic: false, IsImplicitlyDeclared: false } && (method.IsExplicitInterfaceImplementation ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) - : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(target)) + : (method is { Name: nameof(Equals), Parameters: [{ } other] } && ownTypes.Any(type => other.Type.ToNonNullable().Equals(type))) || method is { Name: nameof(GetHashCode), Parameters.Count: 0 })) .Select(Describe), .. target.Methods diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 858d93e..6d7deff 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -325,6 +325,8 @@ public void A_hand_written_generated_member_is_refused_by_name(string type, stri [InlineData("CaseInsensitiveEquals", "CaseInsensitiveEquals.Equals(CaseInsensitiveEquals), CaseInsensitiveEquals.GetHashCode(), so", "(declare CaseInsensitiveEquals as IValidatedValue to get one)")] [InlineData("HashCodeOnly", "HashCodeOnly.GetHashCode(), so", "normalise them in Create, so")] [InlineData("ExplicitEquatable", "the explicit implementation of IEquatable.Equals(ExplicitEquatable), so", "(declare ExplicitEquatable as IValidatedValue to get one)")] + [InlineData("EqualsOnClass", "EqualsOnClass.Equals(EqualsOnClass?), so", "(declare EqualsOnClass as IValidatedValue to get one)")] + [InlineData("EqualsDeeperBase", "EqualsDeeperBase.Equals(DeepEqualityBase?), so", "(declare EqualsDeeperBase as IValidatedValue to get one)")] public void A_hand_written_equality_is_refused_by_name(string type, string members, string remedy) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(members) && error.Message.Contains("a value object's equality is its wrapped value's") && error.Message.Contains(remedy), consumer.Output); @@ -1207,6 +1209,23 @@ public sealed partial record HashCodeOnly : IValidatedValue.Equals(ExplicitEquatable other) => true; } + // A record class's, whose parameter is nullable. + public sealed partial record EqualsOnClass : IValue + { + public bool Equals(EqualsOnClass? other) => true; + } + + // An Equals of a deeper base record's type, which IEquatable and so a collection of the base + // reached, while == compared the wrapped value (#196). The direct base's is C#'s own CS0111. + public abstract record DeepEqualityBase; + + public abstract record MiddleEqualityBase : DeepEqualityBase; + + public sealed partial record EqualsDeeperBase : MiddleEqualityBase, IValue + { + public bool Equals(DeepEqualityBase? other) => ReferenceEquals(this, other); + } + // Instance state besides the wrapped value: JSON, parsing and the materializer carried the // wrapped value alone while the record's equality compared the rest, and a required member // failed inside the generated code (LAMA0611, CS9035). diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 47a89c5..0df6f8b 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1240,6 +1240,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-equals-of-a-base-record-s-type", + "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", + "quote": "So does an `Equals` you write for one of its base records' types, such as `Equals(DeepBase?)`: a `HashSet` would compare by it while `==` compares the underlying value.", + "claim": "An Equals a value object declares for one of its base records' types fails the build (CMTK1011).", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-every-error-links-the-table", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From e606d640068c76cb5d8a7363090e413e342cb11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:07:08 +0100 Subject: [PATCH 04/30] fix: refuse a base record's public generated member and name a public one as such A base record's public member the generators introduce counted for nothing. Its static MinValue and MaxValue, with IMinMaxValue or without, were the value object's, and no MinValue or MaxValue was generated; its public Value, From, Parse, TryParse, ToString(string?, IFormatProvider?), TryFormat or CompareTo(object) failed the aspect (LAMA0500), naming nothing the user wrote. From another project, a public static member that implements a static abstract one reads as an explicit implementation, since the compiler records every such implementation as one, so the error called it "the explicit implementation of ...". ImplementationsOf (was ExplicitImplementationsOf) now refuses any implementation the interface mapping finds outside the value object's own public members, public or explicit, and names a public one by its type and member. The names and signatures checked on the value object are checked on each base record too, for a member that implements no interface. MembersNamed leaves an explicit-looking member to the mapping, so a member of another assembly is named once. The build-outcome fixture builds a library that references the base package alone, beside its consumers, since a base type there that carries a marker or IClosedSet is no value object or set of its own. A closed set over its base class that implements IClosedSet explicitly, an outside review's report, is refused since 4a3d9a6; the test holds it now. Input class (GTS): a generated member declared in a base record, by member and by where the base is. Catalogue: MinValue/MaxValue through IMinMaxValue and by name alone, ToString(string?, IFormatProvider?), CompareTo(object), Parse/TryParse, Value, From and TryFormat, in this project, and a public static from the library. A base's TryFrom, a seam, stays with #194. Reverting this commit's source fails 12 of 274 BuildOutcomeTests; each guard's own seed fails only its rows (the mapping's public case, the names on base records, the public naming, the explicit-looking names). Refs #197, #191 Defect-Class: GTS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- ...ed-wherever-the-value-object-reaches-it.md | 112 +++++++++ ...ed-wherever-the-value-object-reaches-it.md | 86 ------- design/adr/README.md | 2 +- design/aspects.md | 18 +- .../ClosedSetTypes.cs | 2 +- src/CodoMetis.TypeKit.Generators/README.md | 3 + .../ValueObjectComparableAspect.cs | 2 +- .../ValueObjectDeclaration.cs | 104 ++++++--- .../BuildOutcomeTests.cs | 219 ++++++++++++++++-- test/oracles/claims.json | 14 ++ 11 files changed, 424 insertions(+), 140 deletions(-) create mode 100644 design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md delete mode 100644 design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a40191a..d03e162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ section is the GitHub release's notes. ### Changed - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. -- Generators: an explicit implementation of a generated member, such as `IFormattable.ToString` or `IMinMaxValue.MaxValue`, in an interface a value object or closed set implements, or in a base record from another project, now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade. The generated member replaced the interface's without a word, or the base's kept answering beside it: remove it, or don't implement that interface. `IConvertible` declared through an interface now fails with CMTK1011 rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md new file mode 100644 index 0000000..9866b1f --- /dev/null +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -0,0 +1,112 @@ +# 0121. A generated member written by hand is refused wherever the value object reaches it + +Date: 2026-10-30 +Status: accepted + +Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191), under the 2.3.1 epic +[#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while +[#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic +operators, where an interface's explicit implementation is kept, from an outside review of that pull request; +widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195) and +[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197), from a second outside review; refines +[decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to +[decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the +generators introduce written by hand, and an explicit implementation of an interface they implement, on the value +object and, since phase 11, on a base record (CMTK1011, CMTK1008 for the comparison interfaces). +`ValueObjectDeclaration` listed the value object's members for the first, and the value object's and its base +records' for the second, and `CreateDeclarations` the value object's own. + +## What was measured + +On `release/2.4.0` at `97c87c1`, 2026-10-30, with a consumer of the generators, the code these members are +generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: + +- **An interface's explicit implementation was replaced without a word.** An interface can implement a member + of a base interface explicitly, and every type that declares it reaches that default until it declares a + public member of its own. The generated member is one, for each of the eleven interfaces the generators + implement: `IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, + `IUtf8SpanParsable`, `IMinMaxValue`, `IComparable`, `IComparable`, `IComparisonOperators` (each answered + the generated member's result through a generic call, where the interface's threw or answered its own) and + `IConvertible` (below). The same held two interfaces down and through an interface of a base record. +- **Another assembly's were missed too, a base record's included.** Metalama lists no private member of a type + in another assembly, and an explicit implementation is private, so the listing saw none in a referenced + project. An interface there was replaced as above. A base record there was replaced or kept answering the + interface: an `IFormattable` call on a value object over a referenced record that implements it printed `1`, + where the base answered its own text, and `Max()` over a referenced `MinMaxBase` reached the base's, + which threw. A closed set over a referenced base class that implements `IClosedSet` explicitly listed + two members in `All` and none through `T.All`, named `Red` "base" through the interface, and EF Core stored + "base" and read no member's name back. The rule has had this gap since phase 11. + `INamedType.TryFindImplementationForInterfaceMember` finds every one of them. +- **A base record's public member counted for nothing** (#197). Its public static `MinValue` and `MaxValue`, with + `IMinMaxValue` or without, were the value object's, and no `MinValue` or `MaxValue` was generated. Its + public `Value`, `From`, `Parse`, `TryParse`, `ToString(string?, IFormatProvider?)`, `TryFormat` or + `CompareTo(object)` failed the aspect (LAMA0500), naming nothing the user wrote. From another assembly, a + public static member that implements a static abstract one reads as an explicit implementation, since the + compiler records every such implementation as one, so it was refused as "the explicit implementation of ...". +- **A base record's `Create` gave two rule sets** (#195). Where the value object reaches the marker only through + a base record of another project that implements `Create` explicitly, `TryFrom("x")` ran the value object's + `Create`, which accepted, and generic `T.Create("x")` the base's, which refused. Declaring the marker on the value + object as well re-implements it, and both agree. In the same project such a base carries the marker and is + refused already (CMTK1022). +- **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did + before CMTK1011 named it. So did a closed set's `IParsable` declared through an interface (LAMA0512). A + closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the + generated members without a word. +- **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record + as in a value object, so the generators change nothing there. For the same reason + `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized + `Equals(TBase)` implements `IEquatable` before any interface's default (a `HashSet` of the base held + both value objects). +- **.NET implements three such members itself**: `INumberBase` implements `IUtf8SpanFormattable.TryFormat` + and `IUtf8SpanParsable.Parse` and `TryParse` explicitly, as bridges to the UTF-16 members. A scan of the + interfaces of `System.Private.CoreLib` for explicit implementations of the eleven found no other; the reference + assembly carries the same three. + +## Decision + +**CMTK1011, and CMTK1008 for the comparison interfaces, refuse a member the generators introduce wherever the +value object reaches it**: written on it, or in a base record, public or explicit, or as an explicit implementation +in an interface it implements, directly or through another, in this assembly or another. The error names the +member and where it is, "in its base type `X`" or "in its interface `X`", and a public member by its own name, +never as an explicit implementation. A closed set's `IClosedSet` is held the same way. + +- **Found by the interface mapping, never by listing members.** For each member of a generated interface the + value object implements, `TryFindImplementationForInterfaceMember` gives the implementation C# maps it to; one + other than the value object's own public member is refused. The record's own `==` is what the mapping finds for + an interface's `==`, so that case needs no rule of its own. A base record's public member of a generated name + that implements no interface is found by the same names and signatures as on the value object. +- **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, + is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one + rule set, which the generated code calls too, and builds. +- **`IConvertible` and a set's `IParsable` declared through an interface** are CMTK1011, naming the + interface, where the aspect failed. +- **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That + is .NET's `INumberBase`: every .NET number replaces its bridges with its own UTF-8 members, as the + generated ones do, and refusing them would leave no value object able to implement `INumberBase`. The + test is by assembly symbol, never by name. +- **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared + through an interface of the user's still fails the aspect (LAMA0512, + [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A seam inherited from a base record, + `CompareTo(TSelf)` and `TryFrom` among them, is + [#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194)'s, and a hand-written `Equals(TBase)` + [#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s. + +**The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set +that reaches an interface's explicit implementation of a generated member, or a base record's in another +assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; +and one whose generic `T.Create` reached a base's `Create` beside its own. Each is code that never runs (an +interface's default the generated member replaced) or a second answer beside the generated one (a base's kept +member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in +2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, +and decision 119's reason, an error that names a defect in every build that contains it, holds after a tag as it +does before one. The changelog's Changed section says which +declarations fail and what to write instead. The forms that failed the aspect before fail the build with a CMTK +error now, which breaks no build. + +`BuildOutcomeTests` holds it: one value object per interface, two interfaces down, through a base record's +interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, +`IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a +base record's public member of each kind, in this project and in a library the generators do not run in, which +the fixture builds beside its consumers; a base record's `Create`; a closed set through an interface of either +assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing +ordering, an interface's `==`, an `INumberBase` value object, and one `Create` over a base record. diff --git a/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md deleted file mode 100644 index ff45c2e..0000000 --- a/design/adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md +++ /dev/null @@ -1,86 +0,0 @@ -# 0121. An explicit implementation is refused wherever the value object reaches it - -Date: 2026-10-30 -Status: accepted - -Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191), under the 2.3.1 epic -[#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while -[#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic -operators, where an interface's explicit implementation is kept, from an outside review of that pull request; -refines -[decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to -[decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses an explicit -implementation of an interface the generators implement, on the value object and, since phase 11, on a base -record (CMTK1011, CMTK1008 for the comparison interfaces). `ValueObjectDeclaration.ExplicitImplementationsOf` -listed the members of the value object and its base records, and nothing else. - -## What was measured - -On `release/2.4.0` at `97c87c1`, 2026-10-30, with a consumer of the generators, the code these members are -generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: - -- **An interface's explicit implementation was replaced without a word.** An interface can implement a member - of a base interface explicitly, and every type that declares it reaches that default until it declares a - public member of its own. The generated member is one, for each of the eleven interfaces the generators - implement: `IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, - `IUtf8SpanParsable`, `IMinMaxValue`, `IComparable`, `IComparable`, `IComparisonOperators` (each answered - the generated member's result through a generic call, where the interface's threw or answered its own) and - `IConvertible` (below). The same held two interfaces down and through an interface of a base record. -- **Another assembly's were missed too, a base record's included.** Metalama lists no private member of a type - in another assembly, and an explicit implementation is private, so the listing saw none in a referenced - project. An interface there was replaced as above. A base record there was replaced or kept answering the - interface: an `IFormattable` call on a value object over a referenced record that implements it printed `1`, - where the base answered its own text, and `Max()` over a referenced `MinMaxBase` reached the base's, - which threw. The rule has had this gap since phase 11. - `INamedType.TryFindImplementationForInterfaceMember` finds every one of them. -- **`IConvertible` declared through an interface** failed the aspect (LAMA0041, naming nothing the user wrote), - as declaring it directly did before CMTK1011 named it. So did a closed set's `IParsable` declared - through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or - `TryParse` explicitly was replaced by the generated members without a word. -- **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record - as in a value object, so the generators change nothing there. For the same reason - `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized - `Equals(TBase)` implements `IEquatable` before any interface's default (a `HashSet` of the base held - both value objects). -- **.NET implements three such members itself**: `INumberBase` implements `IUtf8SpanFormattable.TryFormat` - and `IUtf8SpanParsable.Parse` and `TryParse` explicitly, as bridges to the UTF-16 members. A scan of the - interfaces of `System.Private.CoreLib` for explicit implementations of the eleven found no other; the reference - assembly carries the same three. - -## Decision - -**CMTK1011, and CMTK1008 for the comparison interfaces, refuse an explicit implementation of a member of an -interface the generators implement wherever the value object reaches it**: its own, a base record's, or one in -an interface it implements, directly or through another, in this assembly or another. The error names the member -and where it is, "in its base type `X`" or "in its interface `X`". A closed set's `IClosedSet` is held the -same way. - -- **Found by the interface mapping, never by listing members.** For each member of a generated interface the - value object implements, `TryFindImplementationForInterfaceMember` gives the implementation C# maps it to; an - explicit one is refused. The record's own `==` is what the mapping finds for an interface's `==`, so that case - needs no rule of its own. -- **`IConvertible` and a set's `IParsable` declared through an interface** are CMTK1011, naming the - interface, where the aspect failed. -- **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That - is .NET's `INumberBase`: every .NET number replaces its bridges with its own UTF-8 members, as the - generated ones do, and refusing them would leave no value object able to implement `INumberBase`. The - test is by assembly symbol, never by name. -- **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared - through an interface of the user's (an `IEntityId : IValueObject`) still fails the aspect - (LAMA0512). Whether it should be accepted is a question of its own. - -**The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1, wherever a value object or a -closed set reaches an interface's explicit implementation of a generated member, or a base record's in another -assembly. Each is code that never runs (an interface's default the generated member replaced) or a second answer -beside the generated one (a base's kept implementation), in every build that contains it, so the error names a -defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: -waiting would keep a wrong answer in every 2.3 build, and decision 119's reason, an error that names a defect in -every build that contains it, holds after a tag as it does before one. -The changelog's Changed section says which declarations fail and what to write instead. The forms that failed the -aspect before fail the build with a CMTK error now, which breaks no build. - -`BuildOutcomeTests` holds it: one value object per interface, two interfaces down, through a base record's -interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, -`IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`), a -closed set through an interface of either assembly, and the cases that build: a `ToString()` seam, a `Uri`'s -missing ordering, an interface's `==` and an `INumberBase` value object. diff --git a/design/adr/README.md b/design/adr/README.md index 45b5700..b5bcfd1 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse an explicit implementation of a generated interface's member wherever a value object or closed set reaches it: its own, a base record's or an interface's, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 that 2.3.1 ships as a patch (#191) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own too (#195); `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #195, #197) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index d25efc6..408d788 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -169,14 +169,16 @@ own: its `sealed` `ToString()` is the seam; an unsealed one, its `PrintMembers`, its explicit implementations of the generated interfaces and an `IConvertible` on it are CMTK1011, or CMTK1008 for comparison. Members the compiler generated on a record from another assembly do not count. - - **An interface's explicit implementation counts too (decided 2026-10-30, [decision 121](adr/0121-an-explicit-implementation-is-refused-wherever-the-value-object-reaches-it.md)).** - It is the default every type declaring the interface reaches until it declares a public member, so - the generated one replaced it without a word. Found by the interface mapping - (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private - member of a type in another assembly, so a base record's from a referenced project was missed as - well. `IConvertible` declared through an interface, and a closed set's `IParsable` through - one, are CMTK1011 where they failed the aspect (LAMA0041, LAMA0512). Not refused: an interface's - `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. + - **Wherever the value object reaches it (decided 2026-10-30, [decision 121](adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md)).** + An interface's explicit implementation is the default every type declaring the interface reaches until it + declares a public member, so the generated one replaced it without a word; a base record's public member of + a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base + record's `Create` from another project was a second rule set for generic `T.Create`. Found by the interface + mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private + member of a type in another assembly, so a base record's from a referenced project was missed as well. + `IConvertible` declared through an interface, and a closed set's `IParsable` through one, are CMTK1011 + where they failed the aspect (LAMA0041, LAMA0512). Not refused: an interface's `==`, which the record's own + replaces anyway, and `INumberBase`'s UTF-8 bridges. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and whatever the base declares. A base comparing by reference made two value objects holding 42 unequal diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index ab9e31c..af0be48 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -208,7 +208,7 @@ .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(s .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe), .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe), .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe), - .. ValueObjectDeclaration.ExplicitImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), + .. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), .. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"), .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 2342ee8..8dca909 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -229,6 +229,9 @@ covers: - **The same explicit implementation in an interface the value object implements.** The generated member would replace it, so it would never run. The error names the interface, in your project or another. +- **Any of these members in a base record**, public or explicit, in your project or another. A base's + `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach + it. The error names the base. Two explicit implementations in an interface are fine, because a value object loses nothing by replacing them. A record's own `==` and `!=` replace an interface's, generated or not. And .NET's diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 36fc805..a9a02c2 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -153,7 +153,7 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return method.ToDisplayString(); } - foreach (var member in ValueObjectDeclaration.ExplicitImplementationsOf(target, + foreach (var member in ValueObjectDeclaration.ImplementationsOf(target, [typeof(IComparable<>).ToNamedType(), typeof(IComparable).ToNamedType(), typeof(IComparisonOperators<,,>).ToNamedType()])) yield return member; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index b34de0b..f485b24 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -198,13 +198,20 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // are refused below. ValueObjectContractAspect: the equality operators. List generatedInterfaces = [TypeFactory.GetNamedType(typeof(IEqualityOperators<,,>))]; + // A base record's count as the value object's own (#197). Beside a generated one, its public + // Value, From, Parse, TryParse, ToString(string?, IFormatProvider?) or TryFormat failed the aspect + // (LAMA0500), naming nothing the user wrote, and its static MinValue and MaxValue were kept as the + // value object's, with no MinValue or MaxValue generated. + IEnumerable Declared(Func> find) => + SelfAndBases(target).SelectMany(type => find(type).Select(member => type.Equals(target) ? member : $"{member} in its base type {type.ToDisplayString()}")); + // ValueObjectAspect: the field, the entry points; ValueObjectContractAspect: Value; the JSON aspect's nested converter. - found.AddRange(MembersNamed(target, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter")); + found.AddRange(Declared(type => MembersNamed(type, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter"))); if (kind == ValueObjectKind.Plain) { - found.AddRange(MethodsWithSignature(target, "From", valueType)); - found.AddRange(MembersNamed(target, methods: false, "From")); + found.AddRange(Declared(type => MethodsWithSignature(type, "From", valueType))); + found.AddRange(Declared(type => MembersNamed(type, methods: false, "From"))); } var stringType = TypeFactory.GetType(SpecialType.String); @@ -215,9 +222,9 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta if (parse != ValueParseStrategy.Unsupported) { - found.AddRange(MethodsWithSignature(target, "Parse", stringType, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", stringType, provider, target)); - found.AddRange(MembersNamed(target, $"{target.Name}TypeConverter")); + found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", stringType, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", stringType, provider, target))); + found.AddRange(Declared(type => MembersNamed(type, $"{target.Name}TypeConverter"))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IParsable<>))); if (ValueObjectParsableAspect.SupportsSpan(valueType)) @@ -225,8 +232,8 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanParsable<>))); var span = TypeFactory.GetType(typeof(ReadOnlySpan)); - found.AddRange(MethodsWithSignature(target, "Parse", span, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", span, provider, target)); + found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", span, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", span, provider, target))); } if (ValueObjectParsableAspect.SupportsUtf8(valueType)) @@ -234,26 +241,26 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanParsable<>))); var utf8 = TypeFactory.GetType(typeof(ReadOnlySpan)); - found.AddRange(MethodsWithSignature(target, "Parse", utf8, provider)); - found.AddRange(MethodsWithSignature(target, "TryParse", utf8, provider, target)); + found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", utf8, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", utf8, provider, target))); } } // ValueObjectFormattableAspect, unless ToString() is hand-written and it generates nothing. if (!DeclaresToString(target)) { - found.AddRange(MethodsWithSignature(target, nameof(IFormattable.ToString), stringType, provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IFormattable.ToString), stringType, provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IFormattable))); if (ValueObjectFormattableAspect.ResolveStrategy(valueType) == ValueFormatStrategy.SpanFormattable) { - found.AddRange(MethodsWithSignature(target, nameof(ISpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, nameof(ISpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanFormattable))); } if (ValueObjectFormattableAspect.SupportsUtf8(valueType)) { - found.AddRange(MethodsWithSignature(target, nameof(IUtf8SpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider)); + found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IUtf8SpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanFormattable))); } } @@ -261,7 +268,7 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // ValueObjectMinMaxValueAspect. if (kind == ValueObjectKind.Plain && ValueObjectMinMaxValueAspect.HasMinMaxValue(valueType)) { - found.AddRange(MembersNamed(target, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue))); + found.AddRange(Declared(type => MembersNamed(type, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue)))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IMinMaxValue<>))); } @@ -269,7 +276,7 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // member, which it hides from every caller through the interface: a generic T.Parse bypassed // Create, and interpolation printed through a hand-written IFormattable while ToString() did not. // An interface's was replaced by the generated member instead, as silently. - found.AddRange(ExplicitImplementationsOf(target, generatedInterfaces)); + found.AddRange(ImplementationsOf(target, generatedInterfaces)); // ValueObjectConvertibleAspect implements every member of IConvertible explicitly, so declaring // the interface at all, with public or explicit members, failed the aspect (LAMA0041: it cannot @@ -318,9 +325,10 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta } /// - /// The explicit implementations that reaches for a member of one of - /// , compared by definition, as names them: its own, a - /// base record's or an interface's, in this assembly or another. + /// The implementations that reaches for a member of one of + /// , compared by definition, other than its own public members, which the + /// checks for hand-written members answer: its own explicit implementation, and a base record's or an + /// interface's, explicit or public, in this assembly or another. /// /// /// @@ -344,13 +352,19 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta /// a referenced project were replaced or kept without a word (measured 2026-10-30). /// /// + /// A base record's public implementation counts as the value object's own. Its public static MaxValue + /// was the value object's, and no MaxValue was generated; its public ToString(string?, + /// IFormatProvider?), CompareTo(object) or Parse failed the aspect (LAMA0500), naming + /// nothing the user wrote (#197, measured 2026-10-30). + /// + /// /// An interface's explicit implementation of a member of an interface in the same library is not refused: /// .NET's INumberBase<TSelf> bridges UTF-8 formatting and parsing to the UTF-16 members, which /// every numeric type of .NET replaces with its own and the generators generate both of. They are the only /// such members in .NET 10's interfaces (scanned 2026-10-30). /// /// - internal static IEnumerable ExplicitImplementationsOf(INamedType target, IReadOnlyList interfaces) + internal static IEnumerable ImplementationsOf(INamedType target, IReadOnlyList interfaces) { foreach (var implemented in target.AllImplementedInterfaces) { @@ -358,20 +372,54 @@ internal static IEnumerable ExplicitImplementationsOf(INamedType target, foreach (var member in implemented.Methods.Concat(implemented.Properties).Concat(implemented.Events)) { - if (!target.TryFindImplementationForInterfaceMember(member, out var implementation) - || implementation is not { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true }) continue; + if (!target.TryFindImplementationForInterfaceMember(member, out var implementation) || !IsHandWritten(implementation)) continue; var declaring = implementation.DeclaringType; if (declaring.TypeKind == TypeKind.Interface && declaring.DeclaringAssembly.Equals(implemented.DeclaringAssembly)) continue; - yield return Describe(implementation) + (declaring.Equals(target) ? "" - : declaring.TypeKind == TypeKind.Interface ? $" in its interface {declaring.ToDisplayString()}" - : $" in its base type {declaring.ToDisplayString()}"); + if (Elsewhere(target, implementation) is { } where) + yield return (IsPublic(implementation) ? PublicName(implementation, member) : Describe(implementation)) + where; + else if (implementation.IsExplicitInterfaceImplementation) + yield return Describe(implementation); } } } + /// + /// Where is declared, as an error names it, when it is not + /// itself: " in its base type X" or " in its interface X". + /// + private static string? Elsewhere(INamedType target, IMember implementation) => + implementation.DeclaringType switch + { + var declaring when declaring.Equals(target) => null, + { TypeKind: TypeKind.Interface } declaring => $" in its interface {declaring.ToDisplayString()}", + var declaring => $" in its base type {declaring.ToDisplayString()}" + }; + + /// + /// A public member, which an explicit implementation never is. From another assembly, a public static member + /// that implements a static abstract one reads as an explicit implementation too, since the compiler records + /// every such implementation in metadata as one, so it is told apart by its accessibility. + /// + private static bool IsPublic(IMember member) => member.Accessibility == Accessibility.Public; + + /// + /// A public implementation as an error names it: BoundedBase<Level>.MaxValue. One from another + /// assembly that reads as an explicit implementation displays as BoundedBase<Level>.IMinMaxValue.MaxValue, + /// so it is named by its type and the member it implements. + /// + private static string PublicName(IMember implementation, IMember implemented) + { + if (!implementation.IsExplicitInterfaceImplementation) return implementation.ToDisplayString(); + + var shown = implemented.ToDisplayString(); + var at = shown.IndexOf("." + implemented.Name, StringComparison.Ordinal); + + return at < 0 ? implementation.ToDisplayString() : implementation.DeclaringType.ToDisplayString() + shown.Substring(at); + } + /// /// How a validated value object declares Create: as an explicit implementation of the /// marker's, and as a static method the generated code can call as {TSelf}.Create(value). @@ -551,16 +599,20 @@ private static bool IsHandWritten(IMember member) => /// Non-implicit members of with one of , of any kind. private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); + /// + /// An explicit implementation is 's, which names it, and so is a public static + /// member of another assembly that implements a static abstract one, which reads as explicit there. + /// internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) { foreach (var field in target.Fields) if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name)) yield return field.ToDisplayString(); foreach (var property in target.Properties) - if (!property.IsImplicitlyDeclared && Enumerable.Contains(names, property.Name)) yield return property.ToDisplayString(); + if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, property.Name)) yield return property.ToDisplayString(); foreach (var @event in target.Events) - if (!@event.IsImplicitlyDeclared && Enumerable.Contains(names, @event.Name)) yield return @event.ToDisplayString(); + if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, @event.Name)) yield return @event.ToDisplayString(); foreach (var nested in target.Types) if (Enumerable.Contains(names, nested.Name)) yield return $"the nested type {nested.ToDisplayString()}"; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index edd8ef6..0c512f7 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -178,6 +178,7 @@ public void A_member_name_the_generators_take_is_refused_with_the_reason(string [InlineData("SetWithParsableThroughInterface", "the interface IParsable in its interface ISetParsesItself")] [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.Name in its interface ISetNamesItself")] [InlineData("SetWithNameThroughInterface", "the explicit implementation of IClosedSet.TryParse(string?, out SetWithNameThroughInterface) in its interface ISetNamesItself")] + [InlineData("SetOverLibraryBase", "the explicit implementation of IClosedSet.Name in its base type ExplicitSetBase")] [InlineData("SetWithNameThroughLibraryInterface", "the explicit implementation of IClosedSet.Name in its interface IProbeSetNames")] [InlineData("SetWithTypeConverterAttribute", "the attribute TypeConverter")] [InlineData("SetWithJsonConverterAttribute", "the attribute JsonConverter")] @@ -422,6 +423,7 @@ public void An_interface_s_explicit_implementation_of_a_generated_interface_is_r public void An_interface_s_default_the_generators_do_not_replace_is_not_refused(string type) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// /// What a base record declares counts as the value object's own. Its unsealed ToString() and its /// PrintMembers were replaced by the generated ToString(), printing what they hid; an @@ -442,9 +444,37 @@ public void An_interface_s_default_the_generators_do_not_replace_is_not_refused( [InlineData("CMTK1008", "InheritsExplicitObjectComparable", "the explicit implementation of IComparable.CompareTo(object?) in its base type ObjectComparableBase")] [InlineData("CMTK1011", "InheritsLibraryFormattable", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its base type ProbeFormattableRecord")] [InlineData("CMTK1011", "InheritsLibraryMinMax", "the explicit implementation of IMinMaxValue.MinValue in its base type ProbeMinMaxRecord")] + [InlineData("CMTK1011", "InheritsPublicMinMax", "PublicMinMaxItselfBase.MinValue in its base type PublicMinMaxItselfBase")] + [InlineData("CMTK1011", "InheritsPublicFormatting", "PublicFormattingBase.ToString(string?, IFormatProvider?) in its base type PublicFormattingBase")] + [InlineData("CMTK1008", "InheritsPublicObjectOrder", "PublicObjectOrderBase.CompareTo(object?) in its base type PublicObjectOrderBase")] + [InlineData("CMTK1011", "InheritsPublicParsing", "PublicParsingBase.Parse(string, IFormatProvider?) in its base type PublicParsingBase")] + [InlineData("CMTK1011", "InheritsMinMaxNames", "MinMaxNamesBase.MinValue in its base type MinMaxNamesBase")] + [InlineData("CMTK1011", "InheritsValueName", "ValueNameBase.Value in its base type ValueNameBase")] + [InlineData("CMTK1011", "InheritsFromName", "FromNameBase.From(int) in its base type FromNameBase")] + [InlineData("CMTK1011", "InheritsTryFormatName", "TryFormatNameBase.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its base type TryFormatNameBase")] + [InlineData("CMTK1011", "InheritsLibraryPublicMinMax", "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase")] public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// A public static member of another project that implements a static abstract one reads as an explicit + /// implementation there, since the compiler records every such implementation as one, and the error called it + /// "the explicit implementation of IMinMaxValue..." (#197). Metalama displays it as + /// PublicMinMaxBase<T>.IMinMaxValue.MinValue, so it is named once, by its type and the member. + /// + [Fact] + public void A_public_member_of_another_project_is_named_once_as_public() + { + var declared = consumer.Errors.Where(error => error.Message.StartsWith("'InheritsLibraryPublicMinMax' declares ", StringComparison.Ordinal)) + .ShouldHaveSingleItem(consumer.Output) + .Message.Split(", so it is not generated")[0]["'InheritsLibraryPublicMinMax' declares ".Length..] + .Split(", "); + + declared.Order(StringComparer.Ordinal).ShouldBe( + ["PublicMinMaxBase.MaxValue in its base type PublicMinMaxBase", + "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase"], ignoreOrder: false, customMessage: consumer.Output); + } + /// /// A hand-written PrintMembers was replaced silently with the record's ToString(), which /// calls it, so what it hid was printed. The error points at ToString(), the seam. @@ -1329,6 +1359,75 @@ public sealed partial record InheritsLibraryFormattable : CodoMetis.TypeKit.Gene public sealed partial record InheritsLibraryMinMax : CodoMetis.TypeKit.Generators.Probes.ProbeMinMaxRecord, IValue; + // A base record's public implementation of a generated member, or a member of a generated name + // (#197): its static MinValue and MaxValue were the value object's, and no MinValue or MaxValue was + // generated; the others failed the aspect (LAMA0500). Every body throws, so nothing generated is touched. + public abstract record PublicMinMaxItselfBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxItselfBase, System.Numerics.IMinMaxValue + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsPublicMinMax : PublicMinMaxItselfBase, IValue; + + public abstract record PublicFormattingBase : System.IFormattable + { + public string ToString(string? format, System.IFormatProvider? formatProvider) => "***"; + } + + public sealed partial record InheritsPublicFormatting : PublicFormattingBase, IValue; + + public abstract record PublicObjectOrderBase : System.IComparable + { + public int CompareTo(object? obj) => 0; + } + + public sealed partial record InheritsPublicObjectOrder : PublicObjectOrderBase, IValue; + + public abstract record PublicParsingBase : System.IParsable where TSelf : PublicParsingBase, System.IParsable + { + public static TSelf Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + public static bool TryParse(string? s, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsPublicParsing : PublicParsingBase, IValue; + + public abstract record MinMaxNamesBase where TSelf : MinMaxNamesBase + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsMinMaxNames : MinMaxNamesBase, IValue; + + public abstract record ValueNameBase + { + public int Value => 0; + } + + public sealed partial record InheritsValueName : ValueNameBase, IValue; + + public abstract record FromNameBase where TSelf : FromNameBase + { + public static TSelf From(int value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsFromName : FromNameBase, IValue; + + public abstract record TryFormatNameBase + { + public bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryFormatName : TryFormatNameBase, IValue; + + // From a project the generators do not run in: read as an explicit implementation there, named as public. + public sealed partial record InheritsLibraryPublicMinMax : Library.PublicMinMaxBase, IValue; + + // An interface's default the generators do not replace. Beside ToString(), the seam, no // formatting interface is generated, and a Uri has no ordering. The record's own == replaces an // interface's in every record, generated or not. INumberBase, .NET's, implements @@ -1721,6 +1820,10 @@ public interface ISetNamesItself : IClosedSet where TSelf : class, public sealed partial class SetWithNameThroughLibraryInterface : IClosedSet, CodoMetis.TypeKit.Generators.Probes.IProbeSetNames { public static readonly SetWithNameThroughLibraryInterface A = new(); } + // A base class of another project that implements IClosedSet explicitly: All listed none through + // the interface, its Name was the base's, and EF Core stored that and read no member's name back (#191). + public sealed partial class SetOverLibraryBase : Library.ExplicitSetBase { public static readonly SetOverLibraryBase A = new(); } + public interface ISetParsesItself : System.IParsable where TSelf : ISetParsesItself; public sealed partial class SetWithParsableThroughInterface : IClosedSet, ISetParsesItself { public static readonly SetWithParsableThroughInterface A = new(); } @@ -1769,6 +1872,43 @@ public static class SetNames private readonly string _setsDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"consumer-{Guid.NewGuid():N}"); + private readonly string _libraryDirectory = Path.Combine(RepositoryRoot(), "artifacts", $"library-{Guid.NewGuid():N}"); + + /// + /// A library the generators do not run in, which references the base package alone, as a consumer's + /// shared project may. A base type there that carries a marker or IClosedSet<TSelf> is no + /// value object or set of its own, where in a project with the generators it would be refused + /// (CMTK1022, CMTK1014), and Metalama lists no private member of its types (#191, #195). + /// + private const string Library = + """ + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ClosedSets; + using CodoMetis.TypeKit.ValueObjects; + + namespace Library; + + // A public static implementation of a static abstract member reads as an explicit one from here. + public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxBase, System.Numerics.IMinMaxValue + { + public static TSelf MinValue => throw new System.NotSupportedException(); + + public static TSelf MaxValue => throw new System.NotSupportedException(); + } + + // The set's All listed none, its Name through the interface was the base's, and EF Core stored that. + public abstract class ExplicitSetBase : IClosedSet where TSelf : ExplicitSetBase + { + string IClosedSet.Name => "***"; + + string IClosedSet.QualifiedName => "***"; + + static System.Collections.Generic.IReadOnlyList IClosedSet.All => []; + + static bool IClosedSet.TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + } + """; + /// A second file, so a declaration can be split across files. private const string OtherFile = """ @@ -1795,8 +1935,9 @@ public readonly partial record struct SplitAcrossFiles public async ValueTask InitializeAsync() { - var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }); - var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }); + var library = await BuildLibrary(_libraryDirectory, Library); + var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }, library); + var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }, library); var (declarationOutput, declarationErrors) = await declarations; var (setOutput, setErrors) = await sets; @@ -1808,7 +1949,7 @@ public async ValueTask InitializeAsync() public ValueTask DisposeAsync() { - foreach (var directory in new[] { _directory, _setsDirectory }) + foreach (var directory in new[] { _directory, _setsDirectory, _libraryDirectory }) if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); return ValueTask.CompletedTask; @@ -1819,8 +1960,9 @@ public ValueTask DisposeAsync() /// , and reads its errors. A declaration whose build error is the /// compiler's own (CS0111) belongs in a consumer of its own: beside it, Metalama reported no error /// in the code it generated into a declaration's own file, which the other guards rely on. + /// , a project built, is referenced too. /// - internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources) + internal static async Task<(string Output, IReadOnlyList Errors)> Build(string directory, IReadOnlyDictionary sources, string? library = null) { Directory.CreateDirectory(directory); @@ -1838,6 +1980,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "Consumer.csproj"), + {(library is null ? "" : $"")} """); @@ -1859,18 +2002,66 @@ await File.WriteAllTextAsync(Path.Combine(directory, ".editorconfig"), dotnet_diagnostic.CMTK1023.severity = error """); - // Stop MSBuild's upward search here, so the repository's own build settings (warnings as - // errors, documentation, central package management) do not apply to the consumer. + await WriteBuildStoppers(directory); + + var (_, text) = await RunBuild(directory); + IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; + + found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); + return (text, found); + } + + /// + /// Builds a library of in that references the base + /// package alone, so the generators do not run in it, and returns its project file. It must build. + /// + internal static async Task BuildLibrary(string directory, string source) + { + Directory.CreateDirectory(directory); + + var project = Path.Combine(directory, "Library.csproj"); + await File.WriteAllTextAsync(project, + $""" + + + net10.0 + enable + enable + + + + + + """); + await File.WriteAllTextAsync(Path.Combine(directory, "Library.cs"), source); + await WriteBuildStoppers(directory); + + var (exitCode, text) = await RunBuild(directory); + exitCode.ShouldBe(0, $"The library the consumers reference did not build:{Environment.NewLine}{text}"); + return project; + } + + /// + /// Stops MSBuild's upward search in , so the repository's own build settings + /// (warnings as errors, documentation, central package management) do not apply there. + /// + private static async Task WriteBuildStoppers(string directory) + { await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.props"), ""); await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Build.targets"), ""); await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Packages.props"), "false"); + } - // --no-dependencies and RestoreRecursive=false: the referenced projects are already built - // by this test run, and restoring them again could rewrite restore output that other - // tests read concurrently. Already built in this run's configuration, which is therefore - // passed on: without it the consumer builds in Debug and, in a Release run such as CI's, - // finds no referenced assembly at all. + /// + /// Builds the project in . --no-dependencies and RestoreRecursive=false: the + /// referenced projects are already built by this test run (the library before its consumers), and + /// restoring them again could rewrite restore output that other tests read concurrently. Already built in + /// this run's configuration, which is therefore passed on: without it the consumer builds in Debug and, in + /// a Release run such as CI's, finds no referenced assembly at all. + /// + private static async Task<(int ExitCode, string Output)> RunBuild(string directory) + { using var process = Process.Start(new ProcessStartInfo( "dotnet", ["build", directory, "-c", Configuration, "--no-dependencies", "-p:RestoreRecursive=false", "-nodeReuse:false", "-clp:NoSummary"]) { @@ -1883,11 +2074,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "Directory.Packages.props") var errors = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - var text = await output + await errors; - IReadOnlyList found = [.. DiagnosticLine().Matches(text).Select(match => new Diagnostic(match.Groups["id"].Value, match.Groups["message"].Value)).Distinct()]; - - found.ShouldNotBeEmpty($"The consumer build reported no errors at all, so it did not run as intended:{Environment.NewLine}{text}"); - return (text, found); + return (process.ExitCode, await output + await errors); } /// The configuration this test assembly, and so the projects it references, was built in. diff --git a/test/oracles/claims.json b/test/oracles/claims.json index dc4001e..c6deb62 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1195,6 +1195,20 @@ ], "heldBy": [] }, + { + "id": "C22-a-base-record-s-members", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "**Any of these members in a base record**, public or explicit, in your project or another. A base's `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach it. The error names the base.", + "claim": "A generated member a base record declares, public or explicit, in this project or another, fails the build naming the base.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From 03e199bb850f715c46eef5f76165e33b271f715c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:12:08 +0100 Subject: [PATCH 05/30] fix: refuse a base record's Create that generic T.Create reaches beside the value object's Where a validated value object reaches the marker only through a base record of another project that implements Create explicitly, every generated way in called the value object's own Create, which accepted "x", and generic code calling T.Create reached the base's, which refused it. CreateDeclarations read the value object's own methods, and Metalama lists no private member of another assembly's type. CreateDeclarations asks the interface mapping where T.Create lands. An explicit implementation in a base record or an interface is CMTK1011, and so is a base's public Create beside the value object's own; a base's public Create alone is the one rule set the generated code calls too, and builds. The error says to declare the marker on the value object itself, which re-implements it, since the base cannot be changed from there. Input class (GTS): where the Create that T.Create reaches is declared. Catalogue: an explicit one in the library's base with and without the value object's own, a public one beside it, and, building, the marker declared again over the explicit base and a public base Create alone. In the same project such a base carries the marker and is CMTK1022. Reverting the CreateDeclarations mapping fails the three refusal rows; dropping the public-alone condition fails OnlyPublicBase. Refs #195 Defect-Class: GTS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- src/CodoMetis.TypeKit.Generators/README.md | 5 ++ .../ValueObjectAspect.cs | 18 +++++-- .../ValueObjectDeclaration.cs | 24 +++++++++- .../BuildOutcomeTests.cs | 48 +++++++++++++++++++ test/oracles/claims.json | 13 +++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 8dca909..273ac26 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -242,6 +242,11 @@ Declare `Create` once, `public static`, and never as an explicit interface imple generated code can't call an explicit one. And beside a public one, generic code calling `T.Create` would reach the explicit one's rules, not the ones every generated way of creating a value runs. +The same goes for a `Create` your value object inherits from a base record in another project. If +the value object only gets the marker through that base, generic code calling `T.Create` reaches the +base's `Create`, while everything generated calls yours, and the build fails (CMTK1011). Declare the +marker on the value object too, and your `Create` is the one both reach. + To change what a value object prints, declare `ToString()`. A record's `PrintMembers(StringBuilder)` without it fails the build (CMTK1011), since the generated `ToString()` prints the underlying value and never calls it. diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index db8d4e9..30f62e8 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -282,7 +282,11 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp // smaller change, and a public Create is what IValidatedValue documents. Beside a public one, an // explicit one is a second rule set: generic code calling T.Create reached it, and every // generated entry point the public one. One Create, public, implements the interface too. - var (createExplicitly, createCallable) = kind == ValueObjectKind.Validated ? ValueObjectDeclaration.CreateDeclarations(target) : (false, false); + // A base record's or an interface's counts (#195). The value object cannot remove it, and declaring the + // marker itself makes its own Create the one that implements it. + var createDeclaration = ""; + var createInherited = false; + var (createExplicitly, createCallable) = kind == ValueObjectKind.Validated ? ValueObjectDeclaration.CreateDeclarations(target, marker, out createDeclaration, out createInherited) : (false, false); if (createExplicitly) { @@ -290,11 +294,15 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp builder.Diagnostics.Report(HandWrittenGeneratedMember.WithArguments(createCallable ? (target, - $"Create both as a static method and as an explicit implementation of {marker.ToDisplayString()}", - $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the explicit one, so it would have two rule sets; remove the explicit implementation and keep one Create, {signature}, which implements the interface too") + $"Create both as a static method and as {createDeclaration}", + createInherited + ? $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the inherited one, so it would have two rule sets; declare {marker.ToDisplayString()} on {target.Name} itself, so that its own Create implements it" + : $"every generated way in calls {target.Name}.Create and generic code calling T.Create reaches the explicit one, so it would have two rule sets; remove the explicit implementation and keep one Create, {signature}, which implements the interface too") : (target, - $"Create only as an explicit implementation of {marker.ToDisplayString()}", - $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}"))); + $"Create only as {createDeclaration}", + createInherited + ? $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}, and {marker.ToDisplayString()} on {target.Name} itself, so that it implements it" + : $"the generated code calls {target.Name}.Create, which cannot reach an explicit implementation; declare it as {signature}"))); return false; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index f485b24..76c3d09 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -427,11 +427,22 @@ private static string PublicName(IMember implementation, IMember implemented) /// callable one it is a second rule set, which generic code calling T.Create reached while /// every generated entry point applied the other. /// - public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType target) + /// + /// A base record's or an interface's counts, found by the interface mapping: + /// names it, and says it is not the value object's own. Where the value object + /// lists the marker only through a base record of another project, generic T.Create reached the base's + /// explicit implementation, which refused what the value object's own Create accepted (#195, measured + /// 2026-10-30). A base's public one is one rule set alone, which the generated code reaches as + /// {TSelf}.Create, and a second beside the value object's own. + /// + public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType target, INamedType marker, out string declaration, out bool inherited) { var explicitly = false; var callable = false; + declaration = $"an explicit implementation of {marker.ToDisplayString()}"; + inherited = false; + foreach (var method in target.Methods) { if (!method.IsStatic || method.IsImplicitlyDeclared) continue; @@ -447,6 +458,17 @@ public static (bool Explicitly, bool Callable) CreateDeclarations(INamedType tar } } + if (!explicitly + && marker.Methods.FirstOrDefault(method => method.Name == nameof(IValidatedValue<,,>.Create)) is { } create + && target.TryFindImplementationForInterfaceMember(create, out var implementation) + && Elsewhere(target, implementation) is { } where + && (!IsPublic(implementation) || callable)) + { + explicitly = true; + inherited = true; + declaration = IsPublic(implementation) ? PublicName(implementation, create) + where : declaration + where; + } + return (explicitly, callable); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 0c512f7..53170f7 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -318,6 +318,9 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenOrdering", "the attribute HandWrittenOrdering")] [InlineData("HandWrittenInterface", "the interface IValueObject")] [InlineData("ExplicitCreate", "Create only as an explicit implementation of IValidatedValue")] + [InlineData("LenientOverStrictBase", "Create both as a static method and as an explicit implementation of IValidatedValue in its base type StrictCreateBase, so it is not generated: every generated way in calls LenientOverStrictBase.Create and generic code calling T.Create reaches the inherited one, so it would have two rule sets; declare IValidatedValue on LenientOverStrictBase itself, so that its own Create implements it")] + [InlineData("OnlyStrictBase", "Create only as an explicit implementation of IValidatedValue in its base type StrictCreateBase, so it is not generated: the generated code calls OnlyStrictBase.Create, which cannot reach an explicit implementation; declare it as 'public static Result Create(string value)', and IValidatedValue on OnlyStrictBase itself")] + [InlineData("LenientOverPublicBase", "Create both as a static method and as PublicCreateBase.Create(string) in its base type PublicCreateBase, so it is not generated: every generated way in calls LenientOverPublicBase.Create and generic code calling T.Create reaches the inherited one")] [InlineData("ExplicitCreateBesidePublic", "Create both as a static method and as an explicit implementation of IValidatedValue, so it is not generated: every generated way in calls ExplicitCreateBesidePublic.Create and generic code calling T.Create reaches the explicit one")] public void A_hand_written_generated_member_is_refused_by_name(string type, string member) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); @@ -423,6 +426,16 @@ public void An_interface_s_explicit_implementation_of_a_generated_interface_is_r public void An_interface_s_default_the_generators_do_not_replace_is_not_refused(string type) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); + /// + /// One Create that both the generated code and generic T.Create reach builds: the value object's + /// own, where it declares the marker again over a base that implements Create explicitly, and a base's + /// public one alone (#195). + /// + [Theory] + [InlineData("LenientListedOverStrictBase")] + [InlineData("OnlyPublicBase")] + public void One_Create_over_a_base_record_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); /// /// What a base record declares counts as the value object's own. Its unsealed ToString() and its @@ -1427,6 +1440,28 @@ public sealed partial record InheritsTryFormatName : TryFormatNameBase, IValue, IValue; + // Create in a base record of another project (#195): generic code calling T.Create reached the + // base's, which refused what the value object's own accepted. + public sealed partial record LenientOverStrictBase : Library.StrictCreateBase + { + public static Result Create(string value) => Result.Error(Library.LibraryFault.Refused); + } + + public sealed partial record OnlyStrictBase : Library.StrictCreateBase; + + public sealed partial record LenientOverPublicBase : Library.PublicCreateBase + { + public static Result Create(string value) => Result.Error(Library.LibraryFault.Refused); + } + + // One rule set each: the marker declared again, so the value object's own Create implements it, and a + // base's public Create alone, which the generated code calls too. + public sealed partial record LenientListedOverStrictBase : Library.StrictCreateBase, IValidatedValue + { + public static Result Create(string value) => new LenientListedOverStrictBase(value); + } + + public sealed partial record OnlyPublicBase : Library.PublicCreateBase; // An interface's default the generators do not replace. Beside ToString(), the seam, no // formatting interface is generated, and a Uri has no ordering. The record's own == replaces an @@ -1888,6 +1923,19 @@ public static class SetNames namespace Library; + public enum LibraryFault { Refused } + + // Generic code calling T.Create reached this, which refused what the value object's own accepted. + public abstract record StrictCreateBase : IValidatedValue where TSelf : StrictCreateBase, IValidatedValue + { + static Result IValidatedValue.Create(string value) => Result.Error(LibraryFault.Refused); + } + + public abstract record PublicCreateBase : IValidatedValue where TSelf : PublicCreateBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(LibraryFault.Refused); + } + // A public static implementation of a static abstract member reads as an explicit one from here. public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxBase, System.Numerics.IMinMaxValue { diff --git a/test/oracles/claims.json b/test/oracles/claims.json index c6deb62..a44cd2e 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1239,6 +1239,19 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-create-in-a-base-record", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "If the value object only gets the marker through that base, generic code calling `T.Create` reaches the base's `Create`, while everything generated calls yours, and the build fails (CMTK1011). Declare the marker on the value object too, and your `Create` is the one both reach.", + "claim": "A base record's Create that generic T.Create reaches beside the value object's own fails the build, and declaring the marker on the value object builds.", + "oracle": "exception", + "surfaces": [ + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-equality-operator", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From 5f5ad55e61eef46bcbe15933a79a0b2a746e3027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:39:50 +0100 Subject: [PATCH 06/30] fix: refuse a seam a base record declares in place of the value object A base record's CompareTo(TSelf) was not seen as the comparison seam: DeclaresCompareTo read the value object's own methods, so no [HandWrittenOrdering] was put on it and an EF Core query ordered by the column ([1, 2]) where memory ordered [2, 1]. The generated CompareTo(TSelf) was not introduced, since OverrideStrategy.Ignore saw the inherited one, and the generated operators and CompareTo(object) called left.CompareTo(right), which C# binds to the generated CompareTo(object), declared on the more derived type: < overflowed the stack and ended the process. A base's TryFrom(T) and Revalidate() were the value object's in the same way, and the generated ones, which run Create, were not introduced. A base's CompareTo(object) failed the aspect (LAMA0500). HandWrittenComparisonMembers reports a base record's CompareTo(TSelf) and CompareTo(object) (CMTK1008), named once where the interface mapping finds the second too. ValueObjectDeclaration.InheritedSeams reports a base's TryFrom(T) and Revalidate() on a validated value object (CMTK1011), whose remedy says to declare it on the value object. A base's FromKnownGood(T) is another signature than the generated one, which takes the caller's expression too and hides it, so it stays allowed. Decision 121 records the choice over taking a base's as the seam. Input class (GTS): a seam, by where it is declared. Catalogue: each seam in a base record (CompareTo(TSelf), CompareTo(object), TryFrom, Revalidate, FromKnownGood), beside the value object's own seams the suite already holds. A base's sealed ToString() stays the seam. Reverting the source to 03e199b fails 6 of 285 BuildOutcomeTests; each guard's own seed fails only its rows, the named-once check included. Refs #194 Defect-Class: GTS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 28 ++++++-- design/adr/README.md | 2 +- design/aspects.md | 4 +- src/CodoMetis.TypeKit.Generators/README.md | 4 ++ .../ValueObjectAspect.cs | 11 +++ .../ValueObjectComparableAspect.cs | 17 ++++- .../ValueObjectDeclaration.cs | 21 +++++- .../BuildOutcomeTests.cs | 67 ++++++++++++++++++- test/oracles/claims.json | 14 ++++ 10 files changed, 156 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d03e162..2ceeee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ section is the GitHub release's notes. - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md index 9866b1f..ccff4ec 100644 --- a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -7,8 +7,9 @@ Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191 [#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while [#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic operators, where an interface's explicit implementation is kept, from an outside review of that pull request; -widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195) and -[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197), from a second outside review; refines +widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), +[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review; refines [decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to [decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the generators introduce written by hand, and an explicit implementation of an interface they implement, on the value @@ -48,6 +49,14 @@ generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: `Create`, which accepted, and generic `T.Create("x")` the base's, which refused. Declaring the marker on the value object as well re-implements it, and both agree. In the same project such a base carries the marker and is refused already (CMTK1022). +- **A seam a base record declares stood in for the generated member** (#194). A base's `CompareTo(TSelf)` was not + seen as the seam: no `[HandWrittenOrdering]`, so an EF Core query ordered by the column (`[1, 2]`) where memory + ordered `[2, 1]`; the generated `CompareTo(TSelf)` was not introduced, since one existed, and the generated + operators and `CompareTo(object)` called `left.CompareTo(right)`, which C# binds to the generated + `CompareTo(object)`, declared on the more derived type, so `<` overflowed the stack and ended the process. A + base's `TryFrom(T)` and `Revalidate()` were the value object's, and the generated ones, which run `Create`, were + not introduced. A base's `CompareTo(object)` failed the aspect (LAMA0500). A base's `FromKnownGood(T)` is another + signature than the generated one, which takes the caller's expression too, and is hidden by it. - **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did before CMTK1011 named it. So did a closed set's `IParsable` declared through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the @@ -75,6 +84,12 @@ never as an explicit implementation. A closed set's `IClosedSet` is held other than the value object's own public member is refused. The record's own `==` is what the mapping finds for an interface's `==`, so that case needs no rule of its own. A base record's public member of a generated name that implements no interface is found by the same names and signatures as on the value object. +- **A seam counts where the value object declares it.** A base record's `CompareTo(TSelf)` or `CompareTo(object)` + is CMTK1008, and its `TryFrom(T)` or `Revalidate()` CMTK1011, which says to declare it on the value object, calling + the base's if that is what is meant. Taking a base's as the seam was the alternative: it would need the generated + operators to call it past C#'s overload resolution and the ordering marker to follow it, for a seam the value + object can declare in one line. A base record's `sealed` `ToString()` stays the seam, since C# keeps it in every + derived record and the value object cannot declare its own. - **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one rule set, which the generated code calls too, and builds. @@ -86,15 +101,14 @@ never as an explicit implementation. A closed set's `IClosedSet` is held test is by assembly symbol, never by name. - **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared through an interface of the user's still fails the aspect (LAMA0512, - [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A seam inherited from a base record, - `CompareTo(TSelf)` and `TryFrom` among them, is - [#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194)'s, and a hand-written `Equals(TBase)` + [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A hand-written `Equals(TBase)` is [#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s. **The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set that reaches an interface's explicit implementation of a generated member, or a base record's in another assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; -and one whose generic `T.Create` reached a base's `Create` beside its own. Each is code that never runs (an +one whose generic `T.Create` reached a base's `Create` beside its own; and one over a base record that declares +`CompareTo(TSelf)`, `TryFrom` or `Revalidate`. Each is code that never runs (an interface's default the generated member replaced) or a second answer beside the generated one (a base's kept member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, @@ -107,6 +121,6 @@ error now, which breaks no build. interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, `IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a base record's public member of each kind, in this project and in a library the generators do not run in, which -the fixture builds beside its consumers; a base record's `Create`; a closed set through an interface of either +the fixture builds beside its consumers; a base record's `Create`; a base record's seams, and its `FromKnownGood`, which builds; a closed set through an interface of either assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing ordering, an interface's `==`, an `INumberBase` value object, and one `Create` over a base record. diff --git a/design/adr/README.md b/design/adr/README.md index b5bcfd1..855e4e8 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own too (#195); `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #195, #197) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #194, #195, #197) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 408d788..4234827 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -173,7 +173,9 @@ An interface's explicit implementation is the default every type declaring the interface reaches until it declares a public member, so the generated one replaced it without a word; a base record's public member of a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base - record's `Create` from another project was a second rule set for generic `T.Create`. Found by the interface + record's `Create` from another project was a second rule set for generic `T.Create`. A seam counts where the + value object declares it: a base record's `CompareTo(TSelf)`, `TryFrom` or `Revalidate` stood in for the + generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). Found by the interface mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private member of a type in another assembly, so a base record's from a referenced project was missed as well. `IConvertible` declared through an interface, and a closed set's `IParsable` through one, are CMTK1011 diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 273ac26..875e5ff 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -215,6 +215,10 @@ method that calls `From` or `Create`. A value object may declare its own `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and `ToString()`. Each one is kept, and whatever depends on it is built from it. +Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails +the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member +without a word. A base record's `sealed` `ToString()` is the one exception, as described below. + Writing any other generated member or attribute by hand fails the build, naming it (CMTK1011). That covers: diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index 30f62e8..9e32c32 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -277,6 +277,17 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // A seam is kept where the value object declares it. A base record's stood in for the generated member + // without a word, so it is refused, naming what to declare instead (#194). + var inheritedSeams = ValueObjectDeclaration.InheritedSeams(target, namedValueType, kind); + + if (inheritedSeams.Count > 0) + { + builder.Diagnostics.Report(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", inheritedSeams), + $"a seam is kept only where the value object declares it, and a base record's would stand in for the generated member without a word; declare it on {target.Name} itself, calling the base's if that is what you mean, or remove it from the base"))); + return false; + } + // The generated code calls {TSelf}.Create(value), which cannot reach an explicit implementation // (LAMA0611, CS1929). Refused rather than called through a constrained type parameter: the // smaller change, and a public Create is what IValidatedValue documents. Beside a public one, an diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index a9a02c2..4427a63 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -44,7 +44,7 @@ public override void BuildAspect(IAspectBuilder builder) // factories. Any other hand-written comparison member would sit beside generated ones that // need not agree with it, so it is an error naming the seam. Before this, an operator failed // the aspect (LAMA0500, naming no fix) and an object overload was kept silently. - var besideTheSeam = HandWrittenComparisonMembers(builder.Target).ToList(); + var besideTheSeam = HandWrittenComparisonMembers(builder.Target).Distinct().ToList(); if (besideTheSeam.Count > 0) { @@ -153,6 +153,21 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return method.ToDisplayString(); } + // A base record's CompareTo(object) failed the aspect (LAMA0500), and its CompareTo(TSelf), the seam, was + // used without a word: no [HandWrittenOrdering], so EF Core ordered by the column, and the generated + // operators called the generated CompareTo(object), which C# prefers to the base's, until the stack + // overflowed (#194, measured 2026-10-30). The seam is kept where the value object declares it. + foreach (var type in ValueObjectDeclaration.SelfAndBases(target).Skip(1)) + { + foreach (var method in type.Methods) + { + if (method is { IsStatic: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo), Parameters: [{ } other] } + && ValueObjectDeclaration.IsHandWritten(method) + && (other.Type.SpecialType == SpecialType.Object || other.Type.ToNonNullable().Equals(target))) + yield return $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"; + } + } + foreach (var member in ValueObjectDeclaration.ImplementationsOf(target, [typeof(IComparable<>).ToNamedType(), typeof(IComparable).ToNamedType(), typeof(IComparisonOperators<,,>).ToNamedType()])) yield return member; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 76c3d09..0e238cf 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -60,7 +60,7 @@ private static bool IsHandWrittenToString(IMethod method) => /// A value object and the records it derives from, left out. A struct has /// none: its base is , whose ToString() is nobody's seam. /// - private static IEnumerable SelfAndBases(INamedType target) + internal static IEnumerable SelfAndBases(INamedType target) { yield return target; @@ -420,6 +420,23 @@ private static string PublicName(IMember implementation, IMember implemented) return at < 0 ? implementation.ToDisplayString() : implementation.DeclaringType.ToDisplayString() + shown.Substring(at); } + /// + /// The factory seams of a validated value object that a base record declares rather than the value object: + /// TryFrom(T) and Revalidate(). The generated one is introduced only where none exists, and a + /// base's counted, so it stood in for the value object's without a word: TryFrom answered whatever the + /// base did, which need not call Create, and Revalidate threw where the base's did (#194, + /// measured 2026-10-30). A base's FromKnownGood(T) is hidden by the generated one, whose caller + /// expression parameter makes it another signature, so it is not one. + /// + public static IReadOnlyList InheritedSeams(INamedType target, INamedType valueType, ValueObjectKind kind) => + kind != ValueObjectKind.Validated + ? [] + : [.. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods + .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation + && (method is { Name: "TryFrom", IsStatic: true, Parameters: [{ } value] } && value.Type.ToNonNullable().Equals(valueType.ToNonNullable()) + || method is { Name: "Revalidate", IsStatic: false, Parameters.Count: 0 })) + .Select(method => $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"))]; + /// /// How a validated value object declares Create: as an explicit implementation of the /// marker's, and as a static method the generated code can call as {TSelf}.Create(value). @@ -615,7 +632,7 @@ internal static string Describe(IMember member) /// Written by the user, not synthesized by the compiler: a record in a referenced assembly reports its /// synthesized members as declared, and marks them [CompilerGenerated]. /// - private static bool IsHandWritten(IMember member) => + internal static bool IsHandWritten(IMember member) => !member.IsImplicitlyDeclared && !member.Attributes.Any(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute)))); /// Non-implicit members of with one of , of any kind. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 53170f7..1849657 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -465,10 +465,24 @@ public void One_Create_over_a_base_record_is_not_refused(string type) => [InlineData("CMTK1011", "InheritsValueName", "ValueNameBase.Value in its base type ValueNameBase")] [InlineData("CMTK1011", "InheritsFromName", "FromNameBase.From(int) in its base type FromNameBase")] [InlineData("CMTK1011", "InheritsTryFormatName", "TryFormatNameBase.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its base type TryFormatNameBase")] + [InlineData("CMTK1008", "InheritsCompareTo", "CompareToSeamBase.CompareTo(InheritsCompareTo?) in its base type CompareToSeamBase, but its comparison is generated from CompareTo(InheritsCompareTo)")] + [InlineData("CMTK1008", "InheritsObjectCompareTo", "ObjectCompareToBase.CompareTo(object?) in its base type ObjectCompareToBase")] + [InlineData("CMTK1011", "InheritsTryFrom", "TryFromSeamBase.TryFrom(string) in its base type TryFromSeamBase, so it is not generated: a seam is kept only where the value object declares it")] + [InlineData("CMTK1011", "InheritsRevalidate", "RevalidateSeamBase.Revalidate() in its base type RevalidateSeamBase, so it is not generated: a seam is kept only where the value object declares it")] [InlineData("CMTK1011", "InheritsLibraryPublicMinMax", "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase")] public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// A base record's public CompareTo(object) that implements IComparable is found twice, by the + /// interface mapping and as an inherited comparison member (#194), and named once. + /// + [Fact] + public void A_base_member_found_twice_is_named_once() => + consumer.Errors.Where(error => error.Message.StartsWith("'InheritsPublicObjectOrder' declares ", StringComparison.Ordinal)) + .ShouldHaveSingleItem(consumer.Output) + .Message.Split("PublicObjectOrderBase.CompareTo(object?)").Length.ShouldBe(2, consumer.Output); + /// /// A public static member of another project that implements a static abstract one reads as an explicit /// implementation there, since the compiler records every such implementation as one, and the error called it @@ -519,7 +533,8 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t /// The seams stay allowed: Create, TryFrom, FromKnownGood, Revalidate, /// CompareTo(TSelf) and ToString(). A MinValue of another type, or one the value /// object cannot reach, is not the wrapped type's bound: no MinValue is generated, where it - /// failed inside the generated code (LAMA0611, CS1503 and CS0122). + /// failed inside the generated code (LAMA0611, CS1503 and CS0122). A base record's + /// FromKnownGood(T) is hidden by the generated one, so it is no inherited seam (#194). /// [Theory] [InlineData("WithEverySeam", "'WithEverySeam")] @@ -532,6 +547,7 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t [InlineData("InheritsSealedToString", "'InheritsSealedToString")] [InlineData("InheritsFormattableBesideSealedToString", "'InheritsFormattableBesideSealedToString")] [InlineData("WithStatelessBase", "Described.")] + [InlineData("InheritsFromKnownGoodName", "FromKnownGoodNameBase")] public void A_seam_or_a_foreign_bound_is_not_refused(string type, string alsoNotNamed) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'") || error.Message.Contains(alsoNotNamed), consumer.Output); @@ -1437,6 +1453,55 @@ public abstract record TryFormatNameBase public sealed partial record InheritsTryFormatName : TryFormatNameBase, IValue; + // A seam a base record declares rather than the value object (#194): its CompareTo(TSelf) left the + // ordering unmarked for EF Core and made < overflow the stack, its TryFrom and Revalidate stood in for + // the generated ones, and its CompareTo(object) failed the aspect (LAMA0500). + public abstract record CompareToSeamBase where TSelf : CompareToSeamBase + { + public int CompareTo(TSelf? other) => 0; + } + + public sealed partial record InheritsCompareTo : CompareToSeamBase, IValue; + + public abstract record ObjectCompareToBase + { + public int CompareTo(object? obj) => 0; + } + + public sealed partial record InheritsObjectCompareTo : ObjectCompareToBase, IValue; + + public abstract record TryFromSeamBase where TSelf : TryFromSeamBase + { + public static Option TryFrom(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryFrom : TryFromSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + public abstract record RevalidateSeamBase where TSelf : RevalidateSeamBase + { + public Result Revalidate() => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsRevalidate : RevalidateSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + // A base's FromKnownGood(T) is another signature than the generated one, which takes the caller's + // expression too and hides it, so it stands in for nothing. Generated as usual. + public abstract record FromKnownGoodNameBase where TSelf : FromKnownGoodNameBase + { + public static TSelf FromKnownGood(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsFromKnownGoodName : FromKnownGoodNameBase, IValidatedValue + { + public static Result Create(string value) => new InheritsFromKnownGoodName(value); + } + // From a project the generators do not run in: read as an explicit implementation there, named as public. public sealed partial record InheritsLibraryPublicMinMax : Library.PublicMinMaxBase, IValue; diff --git a/test/oracles/claims.json b/test/oracles/claims.json index a44cd2e..18c6113 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1209,6 +1209,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-seam-in-a-base-record", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member without a word.", + "claim": "A TryFrom, Revalidate or CompareTo a base record declares fails the build, CMTK1008 for CompareTo.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From f714c5d9fa7681ea17f5a19d553d81a4fde90ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 20:49:12 +0100 Subject: [PATCH 07/30] fix: refuse a value-object contract declared through an interface or a base record IValueObject<,>, IValueObjectMaterializer<,> and IPlainValueObject<,> are implemented by the generators with OverrideStrategy.Fail, and declaring one on the value object is CMTK1011. Declared through an interface of the user's, as an IEntityId : IValueObject written to constrain generic code, or by a base record, the aspect failed instead (LAMA0512, and LAMA0041 for the materializer), naming nothing the user wrote: GeneratedMembersDeclaredByHand read the value object's directly declared interfaces alone. It now reads each declared interface's own interfaces and each base record's, and names where the contract comes from ("the interface IValueObject in its interface IEntityId", "... in its base type X"), with IConvertible folded into the same check. Neither marker derives from a contract, so an interface derived from a marker stays allowed. Only builds that failed before fail, now with a CMTK error. Decision 121 records refusing over accepting the contract through an interface, which would add behaviour to a patch. Input class (GTS): a contract the generators implement, by where it is declared. Catalogue: IValueObject one and two interfaces down, through a base record's interface and through a library interface; IValueObjectMaterializer and IPlainValueObject through an interface; and an interface derived from the marker, which builds. IConvertible's rows hold the folded check. Reverting the source fails the 6 new rows and the two LAMA guards (8 of 294 BuildOutcomeTests); dropping the interface or the base read fails its rows; counting the marker as a contract refuses every value object. Refs #192 Defect-Class: GTS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 19 +++-- design/adr/README.md | 2 +- design/aspects.md | 5 +- src/CodoMetis.TypeKit.Generators/README.md | 6 +- .../ValueObjectDeclaration.cs | 32 +++++---- .../BuildOutcomeTests.cs | 69 +++++++++++++++++++ test/oracles/claims.json | 15 ++++ 8 files changed, 123 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d516ee..983987c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ section is the GitHub release's notes. - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md index ccff4ec..1f918c8 100644 --- a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -9,7 +9,8 @@ Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191 operators, where an interface's explicit implementation is kept, from an outside review of that pull request; widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), [#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and -[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review; refines +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, and by +[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192) before 2.3.1 was tagged; refines [decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to [decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the generators introduce written by hand, and an explicit implementation of an interface they implement, on the value @@ -58,7 +59,10 @@ generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: not introduced. A base's `CompareTo(object)` failed the aspect (LAMA0500). A base's `FromKnownGood(T)` is another signature than the generated one, which takes the caller's expression too, and is hidden by it. - **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did - before CMTK1011 named it. So did a closed set's `IParsable` declared through an interface (LAMA0512). A + before CMTK1011 named it. So did `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` + declared through an interface of the user's, one interface down or two, of this project or another, or by a base + record (LAMA0512, and LAMA0041 for the materializer), the shape of an `IEntityId : IValueObject` written to constrain generic code (#192). So did a closed set's `IParsable` declared through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the generated members without a word. - **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record @@ -93,15 +97,16 @@ never as an explicit implementation. A closed set's `IClosedSet` is held - **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one rule set, which the generated code calls too, and builds. -- **`IConvertible` and a set's `IParsable` declared through an interface** are CMTK1011, naming the - interface, where the aspect failed. +- **`IConvertible`, the three contracts and a set's `IParsable` declared through an interface or by a base + record** are CMTK1011, naming the interface or the base, where the aspect failed. Accepting the contracts through + an interface, which the generated `Value` would implement, was the alternative; it would add behaviour to a patch + for a constraint that `IValueObject` itself serves. An interface derived from a marker is how a value + object may be found, and stays allowed: neither marker derives from a contract. - **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That is .NET's `INumberBase`: every .NET number replaces its bridges with its own UTF-8 members, as the generated ones do, and refusing them would leave no value object able to implement `INumberBase`. The test is by assembly symbol, never by name. -- **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared - through an interface of the user's still fails the aspect (LAMA0512, - [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A hand-written `Equals(TBase)` is +- **Not this decision:** a hand-written `Equals(TBase)` is [#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s. **The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set diff --git a/design/adr/README.md b/design/adr/README.md index 855e4e8..fd60f6d 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #194, #195, #197) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 4234827..9ee0a45 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -178,8 +178,9 @@ generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). Found by the interface mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private member of a type in another assembly, so a base record's from a referenced project was missed as well. - `IConvertible` declared through an interface, and a closed set's `IParsable` through one, are CMTK1011 - where they failed the aspect (LAMA0041, LAMA0512). Not refused: an interface's `==`, which the record's own + `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` declared through an + interface or by a base record, and a closed set's `IParsable` through an interface, are CMTK1011 where + they failed the aspect (LAMA0041, LAMA0512, #192). Not refused: an interface's `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 7ca9db0..06cd614 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -224,8 +224,10 @@ covers: - **A plain value object's members**: `From`, `Value`, `Parse` and `TryParse`, `MinValue`/`MaxValue`. - **The attributes**: `[JsonConverter]` and `[TypeConverter]`. -- **The interfaces the generators implement**, and `IConvertible` where they generate it, declared - directly or through another interface. +- **The contracts the generators implement**: `IValueObject`, `IValueObjectMaterializer`, + `IPlainValueObject`, and `IConvertible` where they generate it. That holds whether you declare one + directly, through an interface of your own or in a base record. To constrain generic code, use + `IValueObject` itself, which every value object implements. - **An explicit implementation of a member of an interface they implement**, such as `IParsable.Parse` or `IFormattable.ToString`. On the value object or a base record, every caller through the interface would reach it rather than the generated member, and a generic diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 658732f..7dbd478 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -285,27 +285,31 @@ IEnumerable Declared(Func> find) => var convertible = valueType.IsConvertibleTo(typeof(IConvertible)); var iconvertible = TypeFactory.GetNamedType(typeof(IConvertible)); - bool DerivesFromConvertible(INamedType implemented) => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(iconvertible)); - // The interfaces implemented with OverrideStrategy.Fail, and IConvertible. The marker's own are implied by it. + // Declared through an interface of the user's, as an IEntityId : IValueObject declares + // IValueObject, each failed the aspect as declaring it does (LAMA0512, LAMA0041), naming nothing the user + // wrote (#192). Neither marker derives from one of them, so an interface derived from a marker is not one. + bool Implemented(INamedType @interface) => + @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObject<,>))) + || @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObjectMaterializer<,>))) + || @interface.Definition.Equals(TypeFactory.GetNamedType(typeof(IPlainValueObject<,>))) + || (convertible && @interface.Equals(iconvertible)); + foreach (var implemented in target.ImplementedInterfaces) { - if (implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObject<,>))) - || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IValueObjectMaterializer<,>))) - || implemented.Definition.Equals(TypeFactory.GetNamedType(typeof(IPlainValueObject<,>))) - || (convertible && implemented.Equals(iconvertible))) + if (Implemented(implemented)) found.Add($"the interface {implemented.ToDisplayString()}"); - else if (convertible && DerivesFromConvertible(implemented)) - found.Add($"the interface IConvertible in its interface {implemented.ToDisplayString()}"); + else + found.AddRange(implemented.AllImplementedInterfaces.Where(Implemented).Select(inherited => $"the interface {inherited.ToDisplayString()} in its interface {implemented.ToDisplayString()}")); } - // IConvertible implemented by a base record, itself or through an interface, is implemented - // already, and the aspect failed the same way. - if (convertible) + // One a base record implements, itself or through an interface, is implemented already, and the aspect + // failed the same way. + foreach (var type in SelfAndBases(target).Skip(1)) { - foreach (var type in SelfAndBases(target).Skip(1)) - if (type.ImplementedInterfaces.Any(implemented => implemented.Equals(iconvertible) || DerivesFromConvertible(implemented))) - found.Add($"the interface IConvertible in its base type {type.ToDisplayString()}"); + found.AddRange(type.ImplementedInterfaces.SelectMany(implemented => implemented.AllImplementedInterfaces.Prepend(implemented).Where(Implemented)) + .Distinct() + .Select(inherited => $"the interface {inherited.ToDisplayString()} in its base type {type.ToDisplayString()}")); } // The attributes the aspects put on the type. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index dd8ae0d..21ca890 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -406,6 +406,12 @@ public void An_explicit_implementation_of_a_generated_interface_is_refused_by_na [InlineData("CMTK1011", "BoundedThroughBaseInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IBoundedItself")] [InlineData("CMTK1008", "OrdersThroughBaseInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IOrdersItself")] [InlineData("CMTK1011", "ConvertsThroughBaseInterface", "the interface IConvertible in its base type ConvertsItselfBase")] + [InlineData("CMTK1011", "ContractThroughInterface", "the interface IValueObject in its interface IEntityIdentifier")] + [InlineData("CMTK1011", "ContractTwoInterfacesDown", "the interface IValueObject in its interface IAggregateIdentifier")] + [InlineData("CMTK1011", "MaterializerThroughInterface", "the interface IValueObjectMaterializer in its interface IReadsBackItself")] + [InlineData("CMTK1011", "PlainContractThroughInterface", "the interface IPlainValueObject in its interface IFromableItself")] + [InlineData("CMTK1011", "ContractThroughBaseInterface", "the interface IValueObject in its base type EntityIdentifierBase")] + [InlineData("CMTK1011", "ContractThroughLibraryInterface", "the interface IValueObject in its interface ILibraryEntityIdentifier")] [InlineData("CMTK1011", "FormatsThroughLibraryInterface", "the explicit implementation of IFormattable.ToString(string?, IFormatProvider?) in its interface IProbeFormats")] [InlineData("CMTK1011", "BoundedThroughLibraryInterface", "the explicit implementation of IMinMaxValue.MinValue in its interface IProbeBounded")] [InlineData("CMTK1008", "OrdersThroughLibraryInterface", "the explicit implementation of IComparable.CompareTo(object?) in its interface IProbeOrders")] @@ -475,6 +481,14 @@ public void One_Create_over_a_base_record_is_not_refused(string type) => public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// An interface derived from a marker is a way to find a value object, not a contract the generators + /// implement, so it is generated as usual (#192). + /// + [Fact] + public void A_marker_declared_through_an_interface_is_generated() => + consumer.Errors.ShouldNotContain(error => error.Message.Contains("'ThroughMarkerInterface'") || error.Message.Contains("IIdentifierMarker"), consumer.Output); + /// /// A base record's public CompareTo(object) that implements IComparable is found twice, by the /// interface mapping and as an inherited comparison member (#194), and named once. @@ -1378,6 +1392,51 @@ public abstract record ConvertsItselfBase : IConvertsItself; public sealed partial record ConvertsThroughBaseInterface : ConvertsItselfBase, IValue; + // A contract the generators implement, declared through an interface of the user's (#192): the + // aspect failed (LAMA0512, LAMA0041), naming nothing the user wrote. Each interface implements the + // contract's members, so a refused value object lacks none (CS0535). + public interface IEntityIdentifier : IValueObject where TSelf : IEntityIdentifier + { + System.Guid IValueObject.Value => System.Guid.Empty; + + static bool IValueObject.operator ==(TSelf left, TSelf right) => false; + + static bool IValueObject.operator !=(TSelf left, TSelf right) => true; + } + + public readonly partial record struct ContractThroughInterface : IValue, IEntityIdentifier; + + public interface IAggregateIdentifier : IEntityIdentifier where TSelf : IAggregateIdentifier; + + public readonly partial record struct ContractTwoInterfacesDown : IValue, IAggregateIdentifier; + + public interface IReadsBackItself : IValueObjectMaterializer where TSelf : IReadsBackItself + { + static TSelf IValueObjectMaterializer.Materialize(int value) => throw new System.NotSupportedException(); + } + + public readonly partial record struct MaterializerThroughInterface : IValue, IReadsBackItself; + + public interface IFromableItself : IPlainValueObject, IValueObject where TSelf : IFromableItself + { + static TSelf IPlainValueObject.From(int value) => throw new System.NotSupportedException(); + + int IValueObject.Value => 0; + } + + public readonly partial record struct PlainContractThroughInterface : IValue, IFromableItself; + + public abstract record EntityIdentifierBase : IEntityIdentifier where TSelf : EntityIdentifierBase; + + public sealed partial record ContractThroughBaseInterface : EntityIdentifierBase, IValue; + + public readonly partial record struct ContractThroughLibraryInterface : IValue, Library.ILibraryEntityIdentifier; + + // An interface derived from the marker is how a value object may be found: generated as usual. + public interface IIdentifierMarker : IValue; + + public readonly partial record struct ThroughMarkerInterface : IIdentifierMarker; + // An interface's and a base record's of another assembly, where Metalama lists no private // member, which an explicit implementation is: replaced or kept without a word (#191). public readonly partial record struct FormatsThroughLibraryInterface : IValue, CodoMetis.TypeKit.Generators.Probes.IProbeFormats; @@ -2020,6 +2079,16 @@ public abstract record PublicCreateBase : IValidatedValue Create(string value) => Result.Error(LibraryFault.Refused); } + // A value-object contract declared through an interface of another project (#192). + public interface ILibraryEntityIdentifier : IValueObject where TSelf : ILibraryEntityIdentifier + { + System.Guid IValueObject.Value => System.Guid.Empty; + + static bool IValueObject.operator ==(TSelf left, TSelf right) => false; + + static bool IValueObject.operator !=(TSelf left, TSelf right) => true; + } + // A public static implementation of a static abstract member reads as an explicit one from here. public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue where TSelf : PublicMinMaxBase, System.Numerics.IMinMaxValue { diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 77724e5..f5c1df1 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1209,6 +1209,21 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-contract-through-an-interface", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "That holds whether you declare one directly, through an interface of your own or in a base record.", + "claim": "IValueObject, IValueObjectMaterializer, IPlainValueObject and a generated IConvertible fail the build whether declared directly, through an interface or by a base record.", + "oracle": "exception", + "surfaces": [ + "IValueObject.*", + "IValueObjectMaterializer.*", + "IPlainValueObject.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-a-seam-in-a-base-record", "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", From a3f72f5bff0256fd20a7b19d49a6b3689f07ce35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 23:07:09 +0100 Subject: [PATCH 08/30] fix: CMTK0005 and CMTK0006 report a new()-constrained value-object parameter, as CMTK0001 does CMTK0005 and CMTK0006 decided "no default" with IsNoDefaultStruct, which admitted a type parameter only beside struct. CMTK0001 and CMTK0009 use DefaultRestriction, which also admits a parameter bound by the marker beside new() and no class bound: a generated class has no public parameterless constructor, so such a T is a struct value object. So for where T : IEntityId, new(), new T() was CMTK0001 while new T[3] and an unassigned T field reported nothing, although the README defines both rules over CMTK0001's list. IsNoDefaultStruct is now DefaultRestriction(type) is not null, the predicate CMTK0001 reads. Both rules are Warnings, which the README lets learn forms in a minor version. Input class (AIS): what the judged type is. Catalogue: TypeArgumentKinds, 80 kinds with the one added here (a new() parameter bound by an interface deriving from IValue, the reported shape). NoDefaultKindTests runs CMTK0001, CMTK0005 and CMTK0006 over every kind against one oracle, the README's list; CMTK0001 agreed on all 80 before the fix, and the two new() kinds were the only rows where the other two did not. No other sibling: only these two rules called the narrow predicate. Reverting the source fails the 4 new() rows of 240. Refs #207 Defect-Class: AIS Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../TypeKitSymbols.cs | 8 ++- .../NoDefaultKindTests.cs | 64 +++++++++++++++++++ .../Variants/TypeArgumentKinds.cs | 1 + 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..09d3033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ section is the GitHub release's notes. ### Changed - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. +- Analyzers: CMTK0005 and CMTK0006 now warn for a type parameter bound by a value object beside `new()`, as in `where T : IValue, new()`, which CMTK0001 already reported: `new T[n]` and a `T` field nothing sets. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs index 6952b38..505d68b 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs @@ -296,8 +296,12 @@ private static bool IsDeclaredBy(IMethodSymbol method, ImmutableArray - public bool IsNoDefaultStruct(ITypeSymbol type) => - (type.IsValueType || type is ITypeParameterSymbol { HasValueTypeConstraint: true }) && DefaultRestriction(type) is not null; + /// + /// The types CMTK0001 judges, so CMTK0005 and CMTK0006 cover "such a type" as the README says: a + /// parameter bound by a value object beside new() is one too. Reading struct alone left + /// where T : IValue<int>, new() silent here while CMTK0001 reported it (#207). + /// + public bool IsNoDefaultStruct(ITypeSymbol type) => DefaultRestriction(type) is not null; /// /// Why a default is not a valid instance, as CMTK0001 says it, or diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs new file mode 100644 index 0000000..b4b3576 --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/NoDefaultKindTests.cs @@ -0,0 +1,64 @@ +using CodoMetis.TypeKit.Analyzers.Tests.Variants; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace CodoMetis.TypeKit.Analyzers.Tests; + +/// +/// CMTK0001, CMTK0005 and CMTK0006 across the variant catalogue of what a type is (): +/// each reports a kind exactly when the others do. +/// +/// +/// The Analyzers README defines CMTK0005 and CMTK0006 over "such a type", CMTK0001's list, which takes in a type +/// parameter whose constraints make it a value object. CMTK0005 and CMTK0006 read a narrower predicate of their own +/// and were silent for where T : IValue<int>, new(), which CMTK0001 reports (#207). The oracle is that +/// list: a struct value object, or a parameter bound by the marker beside struct, unmanaged or +/// new() and no class bound, since a generated class has no public parameterless constructor. +/// +public sealed class NoDefaultKindTests +{ + private static bool HasNoDefault(TypeArgumentKind kind) => kind switch + { + KindOfType type => type.Is.HasFlag(Is.Struct) && type.Is.HasFlag(Is.ValueObjectMarker), + KindOfParameter parameter => parameter.Facts.Declares.HasFlag(Declares.ValueObjectBound) + && (parameter.Facts.Declares & (Declares.Struct | Declares.Unmanaged | Declares.New)) != 0 + && (parameter.Facts.Declares & (Declares.Class | Declares.ClassBound)) == 0, + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null), + }; + + public static TheoryData Kinds() => [.. TypeArgumentKinds.All.Where(kind => kind.Mentions.All(Declared)).Select(kind => kind.Name)]; + + private static bool Declared(string attribute) => RealTypeKit.Assembly.GetType($"CodoMetis.TypeKit.{attribute}Attribute") is not null; + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0001_reports_a_default_of_the_kind_as_the_list_says(string kindName) => + Run(kindName, "CMTK0001", DiagnosticSeverity.Error, + (kind, at) => $$"""public static class Use{{kind.Parameters}} {{kind.Where}} { public static {{kind.Argument}} M() => {{at("default(" + kind.Argument + ")")}}; }"""); + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0005_reports_an_array_of_the_kind_as_CMTK0001_does(string kindName) => + Run(kindName, "CMTK0005", DiagnosticSeverity.Warning, + (kind, at) => $$"""public static class Use{{kind.Parameters}} {{kind.Where}} { public static {{kind.Argument}}[] M(int n) => {{at($"GC.AllocateArray<{kind.Argument}>(n)")}}; }"""); + + [Theory] + [MemberData(nameof(Kinds))] + public Task CMTK0006_reports_an_unassigned_field_of_the_kind_as_CMTK0001_does(string kindName) => + Run(kindName, "CMTK0006", DiagnosticSeverity.Warning, + (kind, at) => $$"""public sealed class Holder{{kind.Parameters}} {{kind.Where}} { public {{kind.Argument}} {{at("Field")}}; }"""); + + private static Task Run(string kindName, string id, DiagnosticSeverity severity, Func, string> use) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var kind = TypeArgumentKinds.All.Single(candidate => candidate.Name == kindName); + var reporting = HasNoDefault(kind); + var test = new AnalyzerTest("#nullable enable\n" + use(kind, code => reporting ? $"{{|#0:{code}|}}" : code)); + + test.TestState.Sources.Add(("Candidates.cs", TypeArgumentKinds.Candidates)); + if (reporting) test.ExpectedDiagnostics.Add(new DiagnosticResult(id, severity).WithLocation(0)); + + return test.RunAsync(TestContext.Current.CancellationToken); + } +} diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs index 2abbded..2412498 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentKinds.cs @@ -260,6 +260,7 @@ public static class Use{{parameters}} {{where}} new KindOfParameter("an unmanaged parameter bound by IValue", "", $"where T : unmanaged, {ValueMarker}", new(Declares.Unmanaged | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a class parameter bound by IValue", "", $"where T : class, {ValueMarker}", new(Declares.Class | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a new() parameter bound by IValue", "", $"where T : {ValueMarker}, new()", new(Declares.New | Declares.InterfaceBound | Declares.ValueObjectBound)), + new KindOfParameter("a new() parameter bound by an interface deriving from IValue", "", "where T : IDerivedValue, new()", new(Declares.New | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a parameter bound by a class and IValue", "", $"where T : Handler, {ValueMarker}", new(Declares.ClassBound | Declares.InterfaceBound | Declares.ValueObjectBound)), new KindOfParameter("a parameter bound by IClosedSet", "", "where T : class, CodoMetis.TypeKit.ClosedSets.IClosedSet", new(Declares.Class | Declares.InterfaceBound | Declares.ClosedSetBound)), new KindOfParameter("T : U, U carrying [Sealed]", "<[Sealed] U, T>", "where T : U", new(Declares.Nothing) { BoundBy = new(Declares.Nothing, "Sealed") }), From 3d2abfd8a9c533ac8c559b788009cfff1be12f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 23:16:00 +0100 Subject: [PATCH 09/30] fix: refuse ordering a value object with its own CompareTo that a projection boxed first Select(r => (object)r.Rank).Max(), .Min(), .Order(), .OrderDescending() and .OrderBy(x => x) over a value object whose ordering is its own CompareTo were not refused: EF dropped the cast and answered the column's order, Max 3 and Order 1, 2, 3 where memory answered 1 and 3, 2, 1 for a descending CompareTo (measured on SQLite). The Finder judged an ordering operator by its declared type, object here, and looked under a conversion only in a key selector's body, which 8f6af8b added for OrderBy(r => (object)r.Rank). The Finder now reads the types a sequence's elements have under their boxing: a Select's body unconverted, followed through the operators that pass elements on (Where, Take, Distinct, the orderings, Cast and OfType, Concat and its kind). An operator without a key, or whose key is the element itself, is refused by those types. Query filters go through the same Finder, so a filter that does this is refused when the model is built. The README says the cast changes nothing. Input class (EFT): an ordering operator over a value object, by where the conversion sits. Catalogue: QueryShapes' Op axis gains OrderBoxed, OrderByItselfBoxed, MinBoxed and MaxBoxed over every wrapped kind, converter and accessor of the column and nullable sites (green on SQLite, PostgreSQL's translation and SQL Server). Explicit rows hold OrderDescending, a record class, a navigation's Enumerable.Max, a Where and Take between the box and the operator, and a query filter. Sibling found: over a navigation, the same query threw a NullReferenceException inside EF rather than answering; it is refused before EF now. Reverting the source fails 33 of 11095 (24 catalogue rows, 9 explicit). Refs #206 Defect-Class: EFT Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../HandWrittenOrderingInterceptor.cs | 46 +++++++++++++++++-- .../README.md | 3 ++ .../HandWrittenOrderingTests.cs | 24 +++++++++- .../Variants/QueryShapes.cs | 21 ++++++++- test/oracles/claims.json | 14 ++++++ 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..18b2cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ section is the GitHub release's notes. - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). ## 2.3.0 — 2026-10-29 diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index 09a9e8c..7aa6869 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -110,13 +110,21 @@ protected override Expression VisitMethodCall(MethodCallExpression node) foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); } } - else if ((method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable)) && OrderedType(method) is { } ordered) + else if (IsLinq(method) && OrderedType(method) is { } ordered) { Refuse(ordered, $"ordered by {method.Name}"); // A key the selector converts (to object, IComparable) is still ordered in memory by the - // value object's CompareTo, while EF drops the conversion and orders by the column. - if (KeySelector(node) is { } key) Refuse(Unconverted(key.Body).Type, $"ordered by {method.Name}"); + // value object's CompareTo, while EF drops the conversion and orders by the column. So are + // elements converted before the operator, Select(r => (object)r.Rank).Max(), whose key is the + // element itself or which has no key (#206). + var key = KeySelector(node); + var keyBody = key is null ? null : Unconverted(key.Body); + if (keyBody is not null && keyBody != key!.Parameters[0]) + Refuse(keyBody.Type, $"ordered by {method.Name}"); + else if (method.IsGenericMethod) + foreach (var element in ElementTypes(node.Arguments[0], method.GetGenericArguments()[0])) + Refuse(element, $"ordered by {method.Name}"); } else if (IsDbFunction(method) && !RunsInMemory(node)) { @@ -160,6 +168,38 @@ private static IEnumerable TupleElements(Type tuple) private static Expression StripQuotes(Expression expression) => expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? StripQuotes(quote.Operand) : expression; + /// + /// The types a sequence's elements have under their boxing and reference conversions: a Select's + /// body unconverted, followed through the operators that pass the elements on. + /// is the element type the consuming operator reads, for a sequence that is no such operator. + /// + private static IEnumerable ElementTypes(Expression sequence, Type declared) + { + if (sequence is not MethodCallExpression { Method: { IsGenericMethod: true } method } call || !IsLinq(method)) return [declared]; + + var source = method.GetGenericArguments()[0]; + switch (method.Name) + { + case nameof(Queryable.Select) when KeySelector(call) is { } selector: + var body = Unconverted(selector.Body); + return body == selector.Parameters[0] ? ElementTypes(call.Arguments[0], source) : [body.Type]; + case nameof(Queryable.Cast) or nameof(Queryable.OfType): + return ElementTypes(call.Arguments[0], typeof(object)).Where(element => declared.IsAssignableFrom(element)); + case nameof(Queryable.AsQueryable) or nameof(Enumerable.AsEnumerable) or nameof(Queryable.Where) or nameof(Queryable.Distinct) + or nameof(Queryable.Skip) or nameof(Queryable.Take) or nameof(Queryable.SkipWhile) or nameof(Queryable.TakeWhile) + or nameof(Queryable.SkipLast) or nameof(Queryable.TakeLast) or nameof(Queryable.Reverse) or nameof(Queryable.DefaultIfEmpty) + or nameof(Queryable.DistinctBy) or nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) + or nameof(Queryable.ThenByDescending) or nameof(Queryable.Order) or nameof(Queryable.OrderDescending): + return ElementTypes(call.Arguments[0], source); + case nameof(Queryable.Concat) or nameof(Queryable.Union) or nameof(Queryable.Intersect) or nameof(Queryable.Except): + return [.. ElementTypes(call.Arguments[0], source), .. ElementTypes(call.Arguments[1], source)]; + default: + return [declared]; + } + } + + private static bool IsLinq(MethodInfo method) => method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable); + /// The expression under its boxing and reference conversions; a conversion operator, which changes the value, is kept. private static Expression Unconverted(Expression expression) => expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.TypeAs, Method: null } conversion diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 47641fc..5553bff 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -211,6 +211,9 @@ instead, which works everywhere and may add a cast: such as Npgsql's `EF.Functions.GreaterThan((o.Priority, o.Id), (priority, id))`; - `Min` or `Max`. + That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: + EF drops the cast, while memory still orders by your `CompareTo`. + Compare or order by `.Value` where the underlying value's order is what you mean (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. Equality isn't ordering, and stays allowed. A query filter (`HasQueryFilter`, named or not) that does the same fails when diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 0d662ff..bed0b52 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -43,6 +43,17 @@ public sealed class HandWrittenOrderingTests : IDisposable ["OrderBy a key cast to object"] = db => db.Ranked.OrderBy(r => (object)r.Rank).ToList(), ["ThenBy a key cast to object"] = db => db.Ranked.OrderBy(r => r.Id % 1).ThenBy(r => (object)r.Rank).ToList(), + // Boxed before the operator, which then reads object: Max answered 3 and Order 1, 2, 3 where memory answers 1 + // and 3, 2, 1 (#206). + ["Max of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Max()!, + ["Min of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Min()!, + ["Order of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).Order().ToList(), + ["OrderDescending of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).OrderDescending().ToList(), + ["OrderBy itself of values cast to object"] = db => db.Ranked.Select(r => (object)r.Rank).OrderBy(x => x).ToList(), + ["Max of values cast to object through Where"] = db => db.Ranked.Select(r => (object)r.Rank).Where(x => x != null).Take(5).Max()!, + ["Max of a record class cast to object"] = db => db.Ranked.Select(r => (object)r.Label).Max()!, + ["Max of values cast to object over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => (object)c.Rank).Max()).ToList(), + // The database's own GREATEST and LEAST (SQLite's max and min) order by the column: the first answered // 2, 2, 3 where memory's order answers 1, 1, 2 (measured on PostgreSQL 17 before the refusal). ["Greatest"] = db => db.Ranked.Select(r => EF.Functions.Greatest(r.Rank, Two)).ToList(), @@ -77,7 +88,7 @@ public void A_query_that_orders_a_value_object_by_its_own_CompareTo_is_refused(s var refusal = Should.Throw(() => Refused[query](db)).Message; refusal.ShouldContain("its ordering is its own CompareTo, which the database cannot run"); - refusal.ShouldContain(query is "a record class" or "Least of a record class" ? "ProbeReversedLabel" : "ProbeDescending"); + refusal.ShouldContain(query is "a record class" or "Least of a record class" or "Max of a record class cast to object" ? "ProbeReversedLabel" : "ProbeDescending"); refusal.ShouldContain("Compare or order by .Value explicitly"); } @@ -156,6 +167,7 @@ public void A_comparison_EF_evaluates_in_memory_is_not_refused() [InlineData(typeof(NamedFilterByRankDb), "compared with >", "the query filter \"AboveTwo\" of FilteredRank")] [InlineData(typeof(FilteredByChildRankDb), "compared with >", "the query filter of RankParent")] [InlineData(typeof(FilteredByGreatestDb), "compared by Greatest", "the query filter of FilteredRank")] + [InlineData(typeof(FilteredByBoxedChildRankDb), "ordered by Max", "the query filter of RankParent")] public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string how, string filter) { _connection.Open(); @@ -294,6 +306,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// A query filter over the greatest of the children's ranks, boxed before Max (#206). +public sealed class FilteredByBoxedChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => (ProbeDescending)p.Children.Select(c => (object)c.Rank).Max()! == ProbeDescending.From(2)); + } +} + /// A query filter that keeps the rows whose rank is the greater of it and 2, by the database's GREATEST. public sealed class FilteredByGreatestDb(DbContextOptions options) : DbContext(options) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs index 55c4fdd..5853b2f 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs @@ -94,6 +94,15 @@ public enum Op ThenByDescending, OrderByBoxed, ThenByBoxed, + + /// Select(r => (object)r.P).Order(): boxed before the operator, which then reads object (#206). + OrderBoxed, + + /// Select(r => (object)r.P).OrderBy(x => x): a key that is the element itself. + OrderByItselfBoxed, + + MinBoxed, + MaxBoxed, Order, OrderDescending, Min, @@ -140,7 +149,8 @@ public sealed record QueryCase(WrappedKind Kind, Conversion Conversion, Site Sit /// A comparison, an ordering, or a minimum or maximum: what a value object's own CompareTo decides in memory. public bool Orders => Op is Op.GreaterOrEqual or Op.LessThan or Op.LessOrEqual or Op.LessThanOrNull or Op.CompareTo or Op.OrderBy or Op.OrderByDescending or Op.ThenBy or Op.ThenByDescending - or Op.OrderByBoxed or Op.ThenByBoxed or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy + or Op.OrderByBoxed or Op.ThenByBoxed or Op.OrderBoxed or Op.OrderByItselfBoxed or Op.MinBoxed or Op.MaxBoxed + or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy or Op.AnyGreaterOrEqual or Op.CountGreaterOrEqual or Op.ElementMax or Op.ElementOrderFirst or Op.NavigationMin or Op.NavigationMax or Op.NavigationOrderFirst or Op.NavigationOrderDescendingFirst or Op.Greatest or Op.Least or Op.RowGreaterThan or Op.RowLessThan or Op.RowGreaterOrEqual or Op.RowLessOrEqual; @@ -163,7 +173,8 @@ public static class QueryShapes private static readonly Op[] ColumnOps = [ Op.EqualsLiteral, Op.EqualsCaptured, Op.NotEquals, Op.GreaterOrEqual, Op.LessThan, Op.LessOrEqual, Op.CompareTo, - Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, Op.Order, Op.OrderDescending, + Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, + Op.OrderBoxed, Op.OrderByItselfBoxed, Op.MinBoxed, Op.MaxBoxed, Op.Order, Op.OrderDescending, Op.Min, Op.Max, Op.MinBy, Op.MaxBy, Op.Contains, Op.GroupBy, Op.Select, Op.Distinct, .. FunctionOps, ]; @@ -439,6 +450,8 @@ private static Expression CompareTo(Expression left, Expression right) => Expression Values(Expression ordered) => Q("Select", [Source, type], ordered, key); Expression Unordered() => Q("OrderBy", [Source, typeof(int)], source, Expression.Lambda(Expression.Modulo(Expression.Property(row, "Id"), Expression.Constant(1)), row)); var boxed = Expression.Lambda(Expression.Convert(read, typeof(object)), row); + var boxedValues = Q("Select", [Source, typeof(object)], source, boxed); + var itself = Expression.Parameter(typeof(object), "x"); return @case.Op switch { @@ -456,6 +469,10 @@ private static Expression CompareTo(Expression left, Expression right) => Op.ThenByDescending => (Values(Q("ThenByDescending", [Source, type], Unordered(), key)), true), Op.OrderByBoxed => (Values(Q("OrderBy", [Source, typeof(object)], source, boxed)), true), Op.ThenByBoxed => (Values(Q("ThenBy", [Source, typeof(object)], Unordered(), boxed)), true), + Op.OrderBoxed => (Q("Order", [typeof(object)], boxedValues), true), + Op.OrderByItselfBoxed => (Q("OrderBy", [typeof(object), typeof(object)], boxedValues, Expression.Lambda(itself, itself)), true), + Op.MinBoxed => (Q("Min", [typeof(object)], boxedValues), true), + Op.MaxBoxed => (Q("Max", [typeof(object)], boxedValues), true), Op.Order => (Q("Order", [type], Values(source)), true), Op.OrderDescending => (Q("OrderDescending", [type], Values(source)), true), Op.Min => (Q("Min", [Source, type], source, key), true), diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..02451e2 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1962,6 +1962,20 @@ "HandWrittenOrderingTests" ] }, + { + "id": "E05-ordering-boxed", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: EF drops the cast, while memory still orders by your `CompareTo`.", + "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: EF drops the cast, while memory still orders by your `CompareTo`.", + "oracle": "exception", + "surfaces": [ + "HandWrittenOrderingAttribute.*" + ], + "heldBy": [ + "HandWrittenOrderingTests", + "QueryTranslationVariantTests" + ] + }, { "id": "E05-query-filter", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", From 9f749a488578d272b3cf87e167d438f1bdb216fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 23:22:54 +0100 Subject: [PATCH 10/30] fix: refuse a closed set's base-class member the generators introduce, and keep its sealed ToString() ClosedSetTypes read the set's own members alone. A base class's Parse(string), Parse(string, bool), TryParse(string?, bool, out TSelf), Match, a static All, a protected _name, an IParsable it implements, and its sealed ToString() failed the aspect (LAMA0500, LAMA0502, LAMA0503, LAMA0512), naming nothing the user wrote, in this project and from another. A base class's ToString() that is not sealed was replaced by the generated one without a word, where a value object's base record's has been CMTK1011 since phase 11. The checks of names and signatures now read each base class, private members aside (a base's private _name and _all built and still do), and an IParsable a base implements is named with its base. A base's sealed ToString() is the seam, through ValueObjectDeclaration's own check; one not sealed is CMTK1011 with the value objects' remedy. Decision 121 records refusing it over taking it as the seam, which would change what such a set prints in a patch, and the exception to decision 34: such a set built on 2.3.0. CS0738 joins CS0535 as what a refused set may leave, where a base's All has another type. Input class (GTS): a generated closed-set member, by where it is declared. Catalogue in BuildOutcomeTests: each member above in a base class of this project, Parse, IParsable and both ToString() forms in a library base, QualifiedName (refused before, through the interface mapping), and four that build: a sealed ToString() of either assembly, private fields, a [JsonConverter] on the base. InheritedMembersTests holds what a set over a sealed ToString() prints. Siblings found by the catalogue: All, Match, Parse(string, bool), TryParse(string?, bool, out) and a protected _name, beyond the three the report named. Removing the base reads and the ToString() refusal fails the 11 new refusal rows and the two LAMA guards (13 of 310 BuildOutcomeTests); reverting the seam fails the test project's build on MaskedSet. Refs #205 Defect-Class: GTS Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 29 +++++-- design/adr/README.md | 2 +- design/aspects.md | 4 +- design/closed-sets.md | 2 +- .../ClosedSetAspect.cs | 8 ++ .../ClosedSetTypes.cs | 43 ++++++++-- src/CodoMetis.TypeKit.Generators/README.md | 6 +- .../BuildOutcomeTests.cs | 84 ++++++++++++++++++- .../GeneratedSurface.verified.txt | 17 ++++ .../InheritedMembersTests.cs | 33 ++++++++ test/oracles/claims.json | 27 ++++++ 12 files changed, 235 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..59a6066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ section is the GitHub release's notes. - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md index 1f918c8..e9771b5 100644 --- a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -9,8 +9,9 @@ Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191 operators, where an interface's explicit implementation is kept, from an outside review of that pull request; widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), [#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and -[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, and by -[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192) before 2.3.1 was tagged; refines +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, by +[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192), and by +[#205](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/205) from a sweep, before 2.3.1 was tagged; refines [decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to [decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the generators introduce written by hand, and an explicit implementation of an interface they implement, on the value @@ -65,6 +66,13 @@ generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: Guid>` written to constrain generic code (#192). So did a closed set's `IParsable` declared through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the generated members without a word. +- **A closed set's base class counted for nothing** (#205, measured on `release/2.3.1` at `dbdd44e`). + `ClosedSetTypes` read the set's own members. A base class's `Parse(string)`, `Parse(string, bool)`, + `TryParse(string?, bool, out TSelf)`, `Match`, a static `All`, a protected `_name`, an `IParsable` it + implements, and its `sealed` `ToString()` failed the aspect (LAMA0500, LAMA0502, LAMA0503, LAMA0512), in this + project and from another. Its `ToString()` that is not sealed was replaced by the generated one without a word, + where a value object's base record's is CMTK1011. A base's private `_name` and `_all`, and a `[JsonConverter]` on + it, built and still do. - **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record as in a value object, so the generators change nothing there. For the same reason `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized @@ -97,6 +105,12 @@ never as an explicit implementation. A closed set's `IClosedSet` is held - **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one rule set, which the generated code calls too, and builds. +- **A closed set's base class counts as a value object's base record does** (#205). Its members of a generated + name or signature, other than private ones, and an `IParsable` it implements are CMTK1011, naming the + base. Its `sealed` `ToString()` is the seam; one that is not sealed is CMTK1011, which says to declare + `ToString()` in the set or seal the base's. Taking an unsealed one as the seam was the alternative: C# would keep + it in a derived class, but the set has printed its member's name over it since closed sets shipped, and taking it + would change what such a set prints in a patch, without a word. - **`IConvertible`, the three contracts and a set's `IParsable` declared through an interface or by a base record** are CMTK1011, naming the interface or the base, where the aspect failed. Accepting the contracts through an interface, which the generated `Value` would implement, was the alternative; it would add behaviour to a patch @@ -112,8 +126,9 @@ never as an explicit implementation. A closed set's `IClosedSet` is held **The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set that reaches an interface's explicit implementation of a generated member, or a base record's in another assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; -one whose generic `T.Create` reached a base's `Create` beside its own; and one over a base record that declares -`CompareTo(TSelf)`, `TryFrom` or `Revalidate`. Each is code that never runs (an +one whose generic `T.Create` reached a base's `Create` beside its own; one over a base record that declares +`CompareTo(TSelf)`, `TryFrom` or `Revalidate`; and a closed set over a base class whose `ToString()` is not sealed, +whose generated `ToString()` replaced the base's. Each is code that never runs (an interface's default the generated member replaced) or a second answer beside the generated one (a base's kept member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, @@ -127,5 +142,7 @@ interface, a record class over a reference type, and from another assembly (`IPr `IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a base record's public member of each kind, in this project and in a library the generators do not run in, which the fixture builds beside its consumers; a base record's `Create`; a base record's seams, and its `FromKnownGood`, which builds; a closed set through an interface of either -assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing -ordering, an interface's `==`, an `INumberBase` value object, and one `Create` over a base record. +assembly and over the library's base class; a closed set over a base class of either assembly that declares each +generated member (#205); and the cases that build: a `ToString()` seam, a `Uri`'s missing +ordering, an interface's `==`, an `INumberBase` value object, one `Create` over a base record, and a closed set over a base class's +`sealed` `ToString()`, private fields or `[JsonConverter]`. diff --git a/design/adr/README.md b/design/adr/README.md index fd60f6d..9abd10b 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 9ee0a45..dc7ef6a 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -180,7 +180,9 @@ member of a type in another assembly, so a base record's from a referenced project was missed as well. `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` declared through an interface or by a base record, and a closed set's `IParsable` through an interface, are CMTK1011 where - they failed the aspect (LAMA0041, LAMA0512, #192). Not refused: an interface's `==`, which the record's own + they failed the aspect (LAMA0041, LAMA0512, #192). A closed set's base class counts as a base record does: its + members of a generated name or signature and its `IParsable` are CMTK1011, its `sealed` `ToString()` is + the seam and one not sealed is CMTK1011 (#205). Not refused: an interface's `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and diff --git a/design/closed-sets.md b/design/closed-sets.md index 09f9766..2ff3e87 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -12,7 +12,7 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). - **Generated:** `Name`, `QualifiedName` (the nesting chain with dots, `Order.Status.Draft`), `All` in declaration order, `Match` with one required delegate per member, `TryParse`/`Parse` (ordinal, each with an `ignoreCase` overload), `IParsable` explicitly, `ToString()` unless written by - hand (the one seam), a nested JSON converter and type converter, `GeneratedClosedSetAttribute` + hand (the one seam; a base class's `sealed` one counts, one not sealed is refused), a nested JSON converter and type converter, `GeneratedClosedSetAttribute` for run-time discovery, and a private constructor where none is declared. - **Members are singletons.** Equality is reference equality, `Parse` answers the member itself, and nothing outside the type can make an instance (CMTK1016, CMTK1017). A record is refused (CMTK1020): diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index bd675fb..cef3051 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -79,6 +79,14 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var (member, earlier, reason) in ClosedSetTypes.CaseCollisions(members)) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, reason.Replace("{0}", $"'{target.Name}.{earlier.Name}'")))); + // A base class's ToString() that is not sealed was replaced by the generated one without a word (#205), + // as a value object's base record's was. A sealed one is the seam (DeclaresToString). + if (ValueObjectDeclaration.UnsealedInheritedToString(target) is { } inheritedToString) + problems.Add(HandWrittenGeneratedMember.WithArguments((target, + $"{inheritedToString.ToDisplayString()} in its base type, which is not sealed", + $"its generated ToString() replaces it and prints the member's name; declare ToString() in {target.Name} (it may return base.ToString()), " + + "or seal the base's, which C# keeps in every derived class and the generators then keep too"))); + // A hand-written member the generators introduce failed the aspect with a Metalama error that // names no fix; a hand-written ToString() is the seam and is kept instead. var declaredByHand = ClosedSetTypes.GeneratedMembersDeclaredByHand(target, members); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index af0be48..6deb354 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -194,6 +194,12 @@ private static string WithoutComments(string text) /// set's too: the generated Name and TryParse replaced an interface's without a word (#191, /// measured 2026-10-30). IParsable<TSelf> declared through an interface failed the aspect as /// declaring it does (LAMA0512). + /// + /// A base class's count as the set's own (#205): its Parse, TryParse, Match or All, + /// and an IParsable<TSelf> it implements, failed the aspect (LAMA0500, LAMA0503, LAMA0512) naming + /// nothing the user wrote, in this project and from another (measured 2026-10-30). A private member of a base, + /// such as a _name field, is out of the set's sight and builds. + /// /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { @@ -201,16 +207,33 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType se var boolean = TypeFactory.GetType(SpecialType.Boolean); var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); + var generatedNames = GeneratedNames(set); + var bases = ValueObjectDeclaration.SelfAndBases(set).Skip(1).ToList(); - return + IEnumerable Declared(Func> find) => + ValueObjectDeclaration.SelfAndBases(set).SelectMany(type => find(type).Select(method => type.Equals(set) + ? ValueObjectDeclaration.Describe(method) + : $"{ValueObjectDeclaration.Describe(method)} in its base type {type.ToDisplayString()}")); + + List found = [ - .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(set)]).Where(declared => !memberNames.Contains(declared)), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe), + .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. generatedNames]).Where(declared => !memberNames.Contains(declared)), + .. bases.SelectMany(type => type.Fields.Concat(type.Properties).Concat(type.Events) + .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.Accessibility != Accessibility.Private && generatedNames.Contains(member.Name)) + .Select(member => $"{member.ToDisplayString()} in its base type {type.ToDisplayString()}")), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count))), .. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), .. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"), + .. bases.Where(type => type.ImplementedInterfaces.SelectMany(implemented => implemented.AllImplementedInterfaces.Prepend(implemented)).Any(inherited => inherited.Equals(parsable))) + .Select(type => $"the interface {parsable.ToDisplayString()} in its base type {type.ToDisplayString()}"), + ]; + + return + [ + .. found.Distinct(), .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) || attribute.Type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))) || attribute.Type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)))) @@ -233,7 +256,11 @@ private static bool IsGeneratedMatch(IMethod method, int memberCount) => && func.Definition.Equals(TypeFactory.GetNamedType(typeof(Func<>))) && func.TypeArguments[0].Equals(method.TypeParameters[0])); - /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. - public static bool DeclaresToString(INamedType set) => - set.Methods.Any(method => method is { Name: "ToString", IsStatic: false, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 }); + /// + /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. A base class's + /// sealed one is the seam too, as for a value object: C# keeps it in every derived class, and generating + /// one failed the aspect (LAMA0502, #205). A base class's one that is not sealed is refused instead + /// (). + /// + public static bool DeclaresToString(INamedType set) => ValueObjectDeclaration.DeclaresToString(set); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 06cd614..3a248e8 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -463,7 +463,11 @@ public sealed partial class OrderStatus : IClosedSet, IParsable.TryParse(string?, out SetWithNameThroughInterface) in its interface ISetNamesItself")] [InlineData("SetOverLibraryBase", "the explicit implementation of IClosedSet.Name in its base type ExplicitSetBase")] [InlineData("SetWithNameThroughLibraryInterface", "the explicit implementation of IClosedSet.Name in its interface IProbeSetNames")] + [InlineData("SetOverParsingBase", "ParsingSetBase.Parse(string) in its base type ParsingSetBase")] + [InlineData("SetOverIgnoreCaseParsingBase", "IgnoreCaseParsingSetBase.Parse(string, bool) in its base type IgnoreCaseParsingSetBase")] + [InlineData("SetOverTryParsingBase", "TryParsingSetBase.TryParse(string?, bool, out SetOverTryParsingBase) in its base type TryParsingSetBase")] + [InlineData("SetOverMatchingBase", "MatchingSetBase.Match(Func) in its base type MatchingSetBase")] + [InlineData("SetOverParsableBase", "the interface IParsable in its base type ParsableSetBase")] + [InlineData("SetOverAllListingBase", "AllListingSetBase.All in its base type AllListingSetBase")] + [InlineData("SetOverProtectedFieldBase", "ProtectedFieldSetBase._name in its base type ProtectedFieldSetBase")] + [InlineData("SetOverQualifiedBase", "QualifiedSetBase.QualifiedName in its base type QualifiedSetBase")] + [InlineData("SetOverOpenToStringBase", "OpenToStringSetBase.ToString() in its base type, which is not sealed, so it is not generated: its generated ToString() replaces it")] + [InlineData("SetOverLibraryParsingBase", "LibraryParsingSetBase.Parse(string) in its base type LibraryParsingSetBase")] + [InlineData("SetOverLibraryParsableBase", "the interface IParsable in its base type LibraryParsableSetBase")] + [InlineData("SetOverLibraryOpenToString", "LibraryOpenToStringSetBase.ToString() in its base type, which is not sealed")] [InlineData("SetWithTypeConverterAttribute", "the attribute TypeConverter")] [InlineData("SetWithJsonConverterAttribute", "the attribute JsonConverter")] public void What_the_generators_implement_or_attach_is_refused_by_name_on_a_set(string type, string declared) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(declared), consumer.Output); + /// + /// What a base class declares that the set cannot reach, or that the generators keep, is no refusal: its + /// sealed ToString() is the seam, as for a value object, where it failed the aspect (LAMA0502, + /// #205); its private _name and _all, and a [JsonConverter] on it, which the set's own + /// replaces, built before and still do (measured 2026-10-30). + /// + [Theory] + [InlineData("SetOverSealedToStringBase")] + [InlineData("SetOverLibrarySealedToString")] + [InlineData("SetOverPrivateFieldsBase")] + [InlineData("SetOverAttributedBase")] + public void What_a_set_s_base_class_declares_out_of_the_generators_way_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}"), consumer.Output); + /// /// A member whose name starts with the Kelvin sign (U+212A) and one that starts with K are two names /// to TryParse(name, ignoreCase: true), which ignores case ordinally, and one to Match, @@ -607,11 +633,12 @@ public void A_static_constructor_is_allowed() => [Fact] public void Every_error_is_one_of_the_intended_ones() => consumer.Errors.Select(error => error.Id).Distinct().Order() - .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK0013", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CMTK1023", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK0013", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CMTK1023", "CS0535", "CS0738"], ignoreOrder: false, customMessage: consumer.Output); /// /// CS0535 is allowed above only for a refused closed set, whose interface members the generators - /// did not implement. A value object or a generated set that lost a member the same way is a + /// did not implement, and CS0738 where a base class's member of a generated name has another type + /// (a static int All, #205). A value object or a generated set that lost a member the same way is a /// regression the list of ids alone would let through. /// /// @@ -625,7 +652,7 @@ public void The_declarations_build_without_a_compiler_error() => [Fact] public void CS0535_names_only_a_refused_closed_set() => - consumer.Errors.Where(error => error.Id == "CS0535") + consumer.Errors.Where(error => error.Id is "CS0535" or "CS0738") .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:\.\w+)?(?:)?' does not implement interface member ")] @@ -2002,6 +2029,43 @@ public interface ISetNamesItself : IClosedSet where TSelf : class, // the interface, its Name was the base's, and EF Core stored that and read no member's name back (#191). public sealed partial class SetOverLibraryBase : Library.ExplicitSetBase { public static readonly SetOverLibraryBase A = new(); } + // A base class that declares a member the generators introduce into a set (#205): each failed the aspect + // (LAMA0500, LAMA0502, LAMA0512) naming nothing the user wrote, and an unsealed ToString() was replaced + // without a word. A sealed ToString() is the seam, as C# keeps it in every derived class. + public abstract class SealedToStringSetBase { public sealed override string ToString() => "***"; } + public sealed partial class SetOverSealedToStringBase : SealedToStringSetBase, IClosedSet { public static readonly SetOverSealedToStringBase A = new(); } + public abstract class OpenToStringSetBase { public override string ToString() => "***"; } + public sealed partial class SetOverOpenToStringBase : OpenToStringSetBase, IClosedSet { public static readonly SetOverOpenToStringBase A = new(); } + public abstract class ParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + public sealed partial class SetOverParsingBase : ParsingSetBase, IClosedSet { public static readonly SetOverParsingBase A = new(); } + public abstract class IgnoreCaseParsingSetBase { public static TSelf Parse(string name, bool ignoreCase) => throw new System.NotSupportedException(); } + public sealed partial class SetOverIgnoreCaseParsingBase : IgnoreCaseParsingSetBase, IClosedSet { public static readonly SetOverIgnoreCaseParsingBase A = new(); } + public abstract class TryParsingSetBase { public static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException(); } + public sealed partial class SetOverTryParsingBase : TryParsingSetBase, IClosedSet { public static readonly SetOverTryParsingBase A = new(); } + public abstract class MatchingSetBase { public TResult Match(System.Func only) => only(); } + public sealed partial class SetOverMatchingBase : MatchingSetBase, IClosedSet { public static readonly SetOverMatchingBase A = new(); } + public abstract class ParsableSetBase : System.IParsable where TSelf : ParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + public sealed partial class SetOverParsableBase : ParsableSetBase, IClosedSet { public static readonly SetOverParsableBase A = new(); } + public abstract class PrivateFieldsSetBase { private string _name = ""; private object? _all; public override int GetHashCode() => _name.Length + (_all is null ? 0 : 1); } + public sealed partial class SetOverPrivateFieldsBase : PrivateFieldsSetBase, IClosedSet { public static readonly SetOverPrivateFieldsBase A = new(); } + public abstract class QualifiedSetBase { public string QualifiedName => "***"; } + public abstract class AllListingSetBase { public static int All => 0; } + public abstract class ProtectedFieldSetBase { protected string _name = ""; } + public sealed partial class SetOverProtectedFieldBase : ProtectedFieldSetBase, IClosedSet { public static readonly SetOverProtectedFieldBase A = new(); } + public sealed partial class SetOverAllListingBase : AllListingSetBase, IClosedSet { public static readonly SetOverAllListingBase A = new(); } + public sealed partial class SetOverQualifiedBase : QualifiedSetBase, IClosedSet { public static readonly SetOverQualifiedBase A = new(); } + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public abstract class AttributedSetBase { } + public sealed partial class SetOverAttributedBase : AttributedSetBase, IClosedSet { public static readonly SetOverAttributedBase A = new(); } + public sealed partial class SetOverLibrarySealedToString : Library.LibrarySealedToStringSetBase, IClosedSet { public static readonly SetOverLibrarySealedToString A = new(); } + public sealed partial class SetOverLibraryOpenToString : Library.LibraryOpenToStringSetBase, IClosedSet { public static readonly SetOverLibraryOpenToString A = new(); } + public sealed partial class SetOverLibraryParsingBase : Library.LibraryParsingSetBase, IClosedSet { public static readonly SetOverLibraryParsingBase A = new(); } + public sealed partial class SetOverLibraryParsableBase : Library.LibraryParsableSetBase, IClosedSet { public static readonly SetOverLibraryParsableBase A = new(); } + public interface ISetParsesItself : System.IParsable where TSelf : ISetParsesItself; public sealed partial class SetWithParsableThroughInterface : IClosedSet, ISetParsesItself { public static readonly SetWithParsableThroughInterface A = new(); } @@ -2097,6 +2161,20 @@ public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue throw new System.NotSupportedException(); } + // Base classes of a closed set that declare a member the generators introduce into it (#205). + public abstract class LibrarySealedToStringSetBase { public sealed override string ToString() => "***"; } + + public abstract class LibraryOpenToStringSetBase { public override string ToString() => "***"; } + + public abstract class LibraryParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + + public abstract class LibraryParsableSetBase : System.IParsable where TSelf : LibraryParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + // The set's All listed none, its Name through the interface was the base's, and EF Core stored that. public abstract class ExplicitSetBase : IClosedSet where TSelf : ExplicitSetBase { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 53f2b5c..67b03ee 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1951,6 +1951,23 @@ MaskedOverALocalRecord static op_LessThanOrEqual(MaskedOverALocalRecord, MaskedOverALocalRecord) : Boolean extension static MaskedOverALocalRecordExtensions.GetValue(MaskedOverALocalRecord) : String extension static MaskedOverALocalRecordExtensions.ValueOrNull(MaskedOverALocalRecord) : String +MaskedSet + Match(Func, Func) : TResult + Name : String + QualifiedName : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested MaskedSetJsonConverter : JsonConverter + nested MaskedSetTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : MaskedSet + static Parse(String, Boolean) : MaskedSet + static TryParse(String, Boolean, out MaskedSet) : Boolean + static TryParse(String, out MaskedSet) : Boolean OverABetweenExplicitEqualityRecord $() : OverABetweenExplicitEqualityRecord CompareTo(Object) : Int32 diff --git a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs index 30d797e..3ab3237 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs @@ -71,6 +71,23 @@ public sealed partial record MaskedOverALocalRecord : LocalMaskingRecord, IValue /// Over a sealed ToString() in another assembly. public sealed partial record MaskedOverALibraryRecord : ProbeMaskingRecord, IValue; +/// A base class in this assembly whose ToString() is sealed. +public abstract class LocalMaskingSetBase +{ + /// What every derived class prints. + public sealed override string ToString() => "***"; +} + +/// A closed set over a sealed ToString(), which failed the aspect (LAMA0502, #205). +public sealed partial class MaskedSet : LocalMaskingSetBase, CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + /// The first member. + public static readonly MaskedSet Open = new(); + + /// The second member. + public static readonly MaskedSet Closed = new(); +} + /// /// What a value object inherits from a base record. Refusals are build errors and live in /// ; this is what is generated. @@ -185,4 +202,20 @@ public void A_sealed_ToString_in_a_base_record_is_the_seam(object value) JsonSerializer.Serialize(value, value.GetType()).ShouldBe("\"SECRET\""); TypeDescriptor.GetConverter(value.GetType()).ConvertToInvariantString(value).ShouldBe("SECRET"); } + + /// + /// A base class's sealed ToString() is a closed set's seam too (#205): it is what the set prints, while + /// its name, parsing and JSON are generated as usual. + /// + [Fact] + public void A_sealed_ToString_in_a_closed_set_s_base_class_is_the_seam() + { + MaskedSet.Closed.ToString().ShouldBe("***"); + $"{MaskedSet.Closed}".ShouldBe("***"); + + MaskedSet.Closed.Name.ShouldBe("Closed"); + MaskedSet.All.ShouldBe([MaskedSet.Open, MaskedSet.Closed]); + MaskedSet.Parse("Closed").ShouldBeSameAs(MaskedSet.Closed); + JsonSerializer.Serialize(MaskedSet.Closed).ShouldBe("\"Closed\""); + } } diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..7e87aad 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1238,6 +1238,33 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-set-s-base-class-members", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "Any other generated member written by hand fails the build (CMTK1011), in the set or in a class it derives from.", + "claim": "A generated member of a closed set written in a base class fails the build with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-set-s-base-class-tostring", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "A `sealed` one is kept, since C# keeps it in every derived class. One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace it.", + "claim": "A closed set over a base class's sealed ToString() prints it; over one not sealed, the build fails with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From d573eef07f820651fedf464baca2d0c4cf058fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 07:49:28 +0100 Subject: [PATCH 11/30] fix: refuse ordering a boxed value object read back through ?? or a projection's member OrderBy(r => (object?)r.Optional ?? r.Rank) and Select(r => new { Key = (object)r.Rank }).OrderBy(a => a.Key) over a value object whose ordering is its own CompareTo were not refused: EF dropped the cast and answered 1, 2, 3 where memory answered 3, 2, 1 for a descending CompareTo (measured on SQLite at 3d2abfd). The Finder looked under a conversion only at the top of a key's body or a Select's, so a ?? over the box, or a member of a projection that holds it, read as object. The Finder now reads the types a key or selector body yields under its boxing: both sides of a ?? and a ?:, the elements for the lambda's parameter (as before), and for a member of a projection, the expression its new or member initializer set it from, found in the same lambda or in the Select that built the elements, through nested projections and the operators that pass elements on. The README, its claim and the changelog line name both shapes. Input class (EFT): an ordering operator over a value object, by where the boxing sits and how the key reads it back. Siblings found by the same probe and fixed here (VA): Max(a => a.Key) and Select(a => a.Key).Max() over the projection, a nested anonymous member, a member-init type, a Where between the projection and the ordering, a ?? inside the projection; each answered the column's order. A ?: key failed translation on SQLite and is refused before it now. Over a navigation the projected Max threw a NullReferenceException inside EF; it is refused before EF. Catalogue: QueryShapes' Op axis gains OrderByCoalescedBoxed, OrderByProjectedBoxed and MaxProjectedBoxed over every kind, converter and accessor of the column and nullable sites. Explicit rows hold the anonymous shapes, the navigation and a query filter; controls over .Value and a generated ordering still translate. Reverting the source fails 29: 18 catalogue rows, 11 explicit. Refs #206 Defect-Class: EFT Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../HandWrittenOrderingInterceptor.cs | 103 ++++++++++++++---- .../README.md | 6 +- .../HandWrittenOrderingTests.cs | 36 ++++++ .../Variants/QueryShapes.cs | 27 ++++- test/oracles/claims.json | 4 +- 6 files changed, 150 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b2cee..d45b95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ section is the GitHub release's notes. - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). ## 2.3.0 — 2026-10-29 diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index 7aa6869..d1ac512 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -117,14 +117,13 @@ protected override Expression VisitMethodCall(MethodCallExpression node) // A key the selector converts (to object, IComparable) is still ordered in memory by the // value object's CompareTo, while EF drops the conversion and orders by the column. So are // elements converted before the operator, Select(r => (object)r.Rank).Max(), whose key is the - // element itself or which has no key (#206). + // element itself or which has no key (#206), and a key that reads the boxed value through a ??, a ?: + // or a member of a projection, OrderBy(a => a.Key) after Select(r => new { Key = (object)r.Rank }). var key = KeySelector(node); - var keyBody = key is null ? null : Unconverted(key.Body); - if (keyBody is not null && keyBody != key!.Parameters[0]) - Refuse(keyBody.Type, $"ordered by {method.Name}"); - else if (method.IsGenericMethod) - foreach (var element in ElementTypes(node.Arguments[0], method.GetGenericArguments()[0])) - Refuse(element, $"ordered by {method.Name}"); + IEnumerable read = key is not null ? ReadTypes(key.Body, key.Parameters[0], node.Arguments[0]) + : method.IsGenericMethod ? ElementTypes(node.Arguments[0], method.GetGenericArguments()[0]) + : []; + foreach (var type in read) Refuse(type, $"ordered by {method.Name}"); } else if (IsDbFunction(method) && !RunsInMemory(node)) { @@ -178,26 +177,86 @@ private static IEnumerable ElementTypes(Expression sequence, Type declared if (sequence is not MethodCallExpression { Method: { IsGenericMethod: true } method } call || !IsLinq(method)) return [declared]; var source = method.GetGenericArguments()[0]; - switch (method.Name) + if (method.Name is nameof(Queryable.Select) && KeySelector(call) is { } selector) + return ReadTypes(selector.Body, selector.Parameters[0], call.Arguments[0]); + if (method.Name is nameof(Queryable.Cast) or nameof(Queryable.OfType)) + return ElementTypes(call.Arguments[0], typeof(object)).Where(element => declared.IsAssignableFrom(element)); + + return Upstream(call) is [_, ..] upstream ? upstream.SelectMany(sequence => ElementTypes(sequence, source)) : [declared]; + } + + /// + /// The types an expression of a lambda over 's elements reads under its boxing and + /// reference conversions: both sides of a ?? and a ?:, the elements themselves for the parameter, + /// and for a member of a projection, the expression its new or member initializer set it from. + /// + private static IEnumerable ReadTypes(Expression expression, ParameterExpression parameter, Expression sequence) + { + switch (Unconverted(expression)) + { + case BinaryExpression { NodeType: ExpressionType.Coalesce } coalesce: + return [.. ReadTypes(coalesce.Left, parameter, sequence), .. ReadTypes(coalesce.Right, parameter, sequence)]; + case ConditionalExpression conditional: + return [.. ReadTypes(conditional.IfTrue, parameter, sequence), .. ReadTypes(conditional.IfFalse, parameter, sequence)]; + case var read when read == parameter: + return ElementTypes(sequence, parameter.Type); + case MemberExpression { Expression: { } instance } member when Initializers(instance, member.Member, parameter, sequence).ToList() is [_, ..] initializers: + return initializers.SelectMany(initializer => ReadTypes(initializer.Expression, initializer.Parameter, initializer.Sequence)); + case var read: + return [read.Type]; + } + } + + /// + /// The expressions a new or a member initializer set of + /// from, each with the parameter and the sequence its lambda reads: in the same lambda, or in the Select + /// that built the elements the parameter ranges over; none where nothing in the query set it. + /// + private static IEnumerable<(Expression Expression, ParameterExpression Parameter, Expression Sequence)> Initializers( + Expression instance, MemberInfo member, ParameterExpression parameter, Expression sequence) + { + switch (Unconverted(instance)) { - case nameof(Queryable.Select) when KeySelector(call) is { } selector: - var body = Unconverted(selector.Body); - return body == selector.Parameters[0] ? ElementTypes(call.Arguments[0], source) : [body.Type]; - case nameof(Queryable.Cast) or nameof(Queryable.OfType): - return ElementTypes(call.Arguments[0], typeof(object)).Where(element => declared.IsAssignableFrom(element)); - case nameof(Queryable.AsQueryable) or nameof(Enumerable.AsEnumerable) or nameof(Queryable.Where) or nameof(Queryable.Distinct) - or nameof(Queryable.Skip) or nameof(Queryable.Take) or nameof(Queryable.SkipWhile) or nameof(Queryable.TakeWhile) - or nameof(Queryable.SkipLast) or nameof(Queryable.TakeLast) or nameof(Queryable.Reverse) or nameof(Queryable.DefaultIfEmpty) - or nameof(Queryable.DistinctBy) or nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) - or nameof(Queryable.ThenByDescending) or nameof(Queryable.Order) or nameof(Queryable.OrderDescending): - return ElementTypes(call.Arguments[0], source); - case nameof(Queryable.Concat) or nameof(Queryable.Union) or nameof(Queryable.Intersect) or nameof(Queryable.Except): - return [.. ElementTypes(call.Arguments[0], source), .. ElementTypes(call.Arguments[1], source)]; + case NewExpression { Members: { } members } created: + return members.Select((declared, index) => (declared, index)) + .Where(pair => pair.declared.HasSameMetadataDefinitionAs(member)) + .Select(pair => (created.Arguments[pair.index], parameter, sequence)); + case MemberInitExpression initialized: + return initialized.Bindings.OfType() + .Where(binding => binding.Member.HasSameMetadataDefinitionAs(member)) + .Select(binding => (binding.Expression, parameter, sequence)); + case var read when read == parameter: + return Projections(sequence).SelectMany(projection => Initializers(projection.Selector.Body, member, projection.Selector.Parameters[0], projection.Source)); + case MemberExpression { Expression: { } outer } nested: + return Initializers(outer, nested.Member, parameter, sequence) + .SelectMany(initializer => Initializers(initializer.Expression, member, initializer.Parameter, initializer.Sequence)); default: - return [declared]; + return []; } } + /// The Select selectors that built a sequence's elements, each with its source, followed through the operators that pass the elements on. + private static IEnumerable<(LambdaExpression Selector, Expression Source)> Projections(Expression sequence) + { + if (sequence is not MethodCallExpression { Method: { IsGenericMethod: true } method } call || !IsLinq(method)) return []; + + return method.Name is nameof(Queryable.Select) && KeySelector(call) is { } selector + ? [(selector, call.Arguments[0])] + : Upstream(call).SelectMany(Projections); + } + + /// The sequences whose elements an operator passes on as they are: its source, both for Concat and its kind; none for any other. + private static Expression[] Upstream(MethodCallExpression call) => call.Method.Name switch + { + nameof(Queryable.AsQueryable) or nameof(Enumerable.AsEnumerable) or nameof(Queryable.Where) or nameof(Queryable.Distinct) + or nameof(Queryable.Skip) or nameof(Queryable.Take) or nameof(Queryable.SkipWhile) or nameof(Queryable.TakeWhile) + or nameof(Queryable.SkipLast) or nameof(Queryable.TakeLast) or nameof(Queryable.Reverse) or nameof(Queryable.DefaultIfEmpty) + or nameof(Queryable.DistinctBy) or nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) or nameof(Queryable.ThenBy) + or nameof(Queryable.ThenByDescending) or nameof(Queryable.Order) or nameof(Queryable.OrderDescending) => [call.Arguments[0]], + nameof(Queryable.Concat) or nameof(Queryable.Union) or nameof(Queryable.Intersect) or nameof(Queryable.Except) => [call.Arguments[0], call.Arguments[1]], + _ => [], + }; + private static bool IsLinq(MethodInfo method) => method.DeclaringType == typeof(Queryable) || method.DeclaringType == typeof(Enumerable); /// The expression under its boxing and reference conversions; a conversion operator, which changes the value, is kept. diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 5553bff..d30100d 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -211,8 +211,10 @@ instead, which works everywhere and may add a cast: such as Npgsql's `EF.Functions.GreaterThan((o.Priority, o.Id), (priority, id))`; - `Min` or `Max`. - That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: - EF drops the cast, while memory still orders by your `CompareTo`. + That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, + and when the cast value reaches the ordering through `??` or a member of a projection, as in + `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory + still orders by your `CompareTo`. Compare or order by `.Value` where the underlying value's order is what you mean (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. Equality isn't ordering, diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index bed0b52..0231cce 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -54,6 +54,19 @@ public sealed class HandWrittenOrderingTests : IDisposable ["Max of a record class cast to object"] = db => db.Ranked.Select(r => (object)r.Label).Max()!, ["Max of values cast to object over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => (object)c.Rank).Max()).ToList(), + // The boxed value read back through a ?? or a member of a projection: each answered 1, 2, 3 (Max 3) where memory + // answers 3, 2, 1 (Max 1). A ?: failed translation on SQLite, and is refused before it, as on any provider. + ["OrderBy a coalesced key cast to object"] = db => db.Ranked.OrderBy(r => (object?)r.Optional ?? r.Rank).ToList(), + ["OrderBy a conditional key cast to object"] = db => db.Ranked.OrderBy(r => r.Optional != null ? (object)r.Optional : r.Rank).ToList(), + ["OrderBy a boxed member of a projection"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).OrderBy(a => a.Key).ToList(), + ["Max of a boxed member of a projection"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Max(a => a.Key)!, + ["Max of a boxed member read back"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Select(a => a.Key).Max()!, + ["OrderBy a boxed member through Where"] = db => db.Ranked.Select(r => new { Key = (object)r.Rank }).Where(a => a.Key != null).OrderBy(a => a.Key).ToList(), + ["OrderBy a nested boxed member"] = db => db.Ranked.Select(r => new { Inner = new { Key = (object)r.Rank } }).OrderBy(a => a.Inner.Key).ToList(), + ["OrderBy a boxed member of a named type"] = db => db.Ranked.Select(r => new BoxedRank { Key = r.Rank }).OrderBy(b => b.Key).ToList(), + ["OrderBy a coalesced boxed member"] = db => db.Ranked.Select(r => new { Key = (object?)r.Optional ?? r.Rank }).OrderBy(a => a.Key).ToList(), + ["Max of a boxed member over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => new { Key = (object)c.Rank }).Max(a => a.Key)).ToList(), + // The database's own GREATEST and LEAST (SQLite's max and min) order by the column: the first answered // 2, 2, 3 where memory's order answers 1, 1, 2 (measured on PostgreSQL 17 before the refusal). ["Greatest"] = db => db.Ranked.Select(r => EF.Functions.Greatest(r.Rank, Two)).ToList(), @@ -143,6 +156,12 @@ public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate( db.Ranked.Where(r => r.Count > ProbeCount.From(1)).OrderBy(r => r.Count).Select(r => r.Count.Value).ToList().ShouldBe([2, 3]); db.Ranked.Max(r => r.Count).ShouldBe(ProbeCount.From(3)); + // Boxed through a projection or a ??, .Value and a generated ordering are still the column's order. + db.Ranked.Select(r => new { Key = (object)r.Rank.Value }).OrderBy(a => a.Key).Select(a => a.Key).ToList().ShouldBe([1, 2, 3]); + db.Ranked.Select(r => new { Key = (object)r.Count }).OrderBy(a => a.Key).Select(a => a.Key).ToList() + .ShouldBe([ProbeCount.From(1), ProbeCount.From(2), ProbeCount.From(3)]); + db.Ranked.OrderBy(r => (object?)r.Count ?? r.Rank.Value).Select(r => r.Rank.Value).ToList().ShouldBe([1, 2, 3]); + db.Parents.Select(p => p.Children.OrderByDescending(c => c.Rank.Value).Select(c => c.Rank.Value).First()).ToList().ShouldBe([3]); db.Parents.Select(p => p.Children.Max(c => c.Rank.Value)).ToList().ShouldBe([3]); } @@ -168,6 +187,7 @@ public void A_comparison_EF_evaluates_in_memory_is_not_refused() [InlineData(typeof(FilteredByChildRankDb), "compared with >", "the query filter of RankParent")] [InlineData(typeof(FilteredByGreatestDb), "compared by Greatest", "the query filter of FilteredRank")] [InlineData(typeof(FilteredByBoxedChildRankDb), "ordered by Max", "the query filter of RankParent")] + [InlineData(typeof(FilteredByProjectedChildRankDb), "ordered by Max", "the query filter of RankParent")] public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string how, string filter) { _connection.Open(); @@ -316,6 +336,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// The same, the rank boxed in a projection and read back by its member. +public sealed class FilteredByProjectedChildRankDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity().HasQueryFilter(p => (ProbeDescending)p.Children.Select(c => new { Key = (object)c.Rank }).Max(a => a.Key)! == ProbeDescending.From(2)); + } +} + +/// A projection that holds a rank boxed. +public sealed class BoxedRank +{ + public object? Key { get; set; } +} + /// A query filter that keeps the rows whose rank is the greater of it and 2, by the database's GREATEST. public sealed class FilteredByGreatestDb(DbContextOptions options) : DbContext(options) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs index 5853b2f..578b12c 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs @@ -103,6 +103,15 @@ public enum Op MinBoxed, MaxBoxed, + + /// OrderBy(r => (object)r.P ?? (object)r.P): the boxed value read through a ??. + OrderByCoalescedBoxed, + + /// Select(r => new BoxedKey { Key = r.P }).OrderBy(b => b.Key): a boxed member of a projection. + OrderByProjectedBoxed, + + /// Select(r => new BoxedKey { Key = r.P }).Max(b => b.Key). + MaxProjectedBoxed, Order, OrderDescending, Min, @@ -150,6 +159,7 @@ public sealed record QueryCase(WrappedKind Kind, Conversion Conversion, Site Sit /// A comparison, an ordering, or a minimum or maximum: what a value object's own CompareTo decides in memory. public bool Orders => Op is Op.GreaterOrEqual or Op.LessThan or Op.LessOrEqual or Op.LessThanOrNull or Op.CompareTo or Op.OrderBy or Op.OrderByDescending or Op.ThenBy or Op.ThenByDescending or Op.OrderByBoxed or Op.ThenByBoxed or Op.OrderBoxed or Op.OrderByItselfBoxed or Op.MinBoxed or Op.MaxBoxed + or Op.OrderByCoalescedBoxed or Op.OrderByProjectedBoxed or Op.MaxProjectedBoxed or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy or Op.AnyGreaterOrEqual or Op.CountGreaterOrEqual or Op.ElementMax or Op.ElementOrderFirst or Op.NavigationMin or Op.NavigationMax or Op.NavigationOrderFirst or Op.NavigationOrderDescendingFirst @@ -174,7 +184,8 @@ public static class QueryShapes [ Op.EqualsLiteral, Op.EqualsCaptured, Op.NotEquals, Op.GreaterOrEqual, Op.LessThan, Op.LessOrEqual, Op.CompareTo, Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, - Op.OrderBoxed, Op.OrderByItselfBoxed, Op.MinBoxed, Op.MaxBoxed, Op.Order, Op.OrderDescending, + Op.OrderBoxed, Op.OrderByItselfBoxed, Op.MinBoxed, Op.MaxBoxed, Op.OrderByCoalescedBoxed, Op.OrderByProjectedBoxed, Op.MaxProjectedBoxed, + Op.Order, Op.OrderDescending, Op.Min, Op.Max, Op.MinBy, Op.MaxBy, Op.Contains, Op.GroupBy, Op.Select, Op.Distinct, .. FunctionOps, ]; @@ -231,6 +242,12 @@ private static IEnumerable Enumerate() } } +/// A projection that holds the value read boxed, for the orderings by its member. +public sealed class BoxedKey +{ + public object? Key { get; set; } +} + /// Holds a captured value, so that a query reads it as a parameter, as a closure does. public sealed class Box(T value) { @@ -452,6 +469,11 @@ private static Expression CompareTo(Expression left, Expression right) => var boxed = Expression.Lambda(Expression.Convert(read, typeof(object)), row); var boxedValues = Q("Select", [Source, typeof(object)], source, boxed); var itself = Expression.Parameter(typeof(object), "x"); + var coalesced = Expression.Lambda(Expression.Coalesce(Expression.Convert(read, typeof(object)), Expression.Convert(read, typeof(object))), row); + var holder = Expression.Parameter(typeof(BoxedKey), "b"); + var projected = Q("Select", [Source, typeof(BoxedKey)], source, + Expression.Lambda(Expression.MemberInit(Expression.New(typeof(BoxedKey)), Expression.Bind(typeof(BoxedKey).GetProperty(nameof(BoxedKey.Key))!, Expression.Convert(read, typeof(object)))), row)); + var heldKey = Expression.Lambda(Expression.Property(holder, nameof(BoxedKey.Key)), holder); return @case.Op switch { @@ -473,6 +495,9 @@ private static Expression CompareTo(Expression left, Expression right) => Op.OrderByItselfBoxed => (Q("OrderBy", [typeof(object), typeof(object)], boxedValues, Expression.Lambda(itself, itself)), true), Op.MinBoxed => (Q("Min", [typeof(object)], boxedValues), true), Op.MaxBoxed => (Q("Max", [typeof(object)], boxedValues), true), + Op.OrderByCoalescedBoxed => (Values(Q("OrderBy", [Source, typeof(object)], source, coalesced)), true), + Op.OrderByProjectedBoxed => (Q("Select", [typeof(BoxedKey), typeof(object)], Q("OrderBy", [typeof(BoxedKey), typeof(object)], projected, heldKey), heldKey), true), + Op.MaxProjectedBoxed => (Q("Max", [typeof(BoxedKey), typeof(object)], projected, heldKey), true), Op.Order => (Q("Order", [type], Values(source)), true), Op.OrderDescending => (Q("OrderDescending", [type], Values(source)), true), Op.Min => (Q("Min", [Source, type], source, key), true), diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 02451e2..ee7b7f3 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1965,8 +1965,8 @@ { "id": "E05-ordering-boxed", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", - "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: EF drops the cast, while memory still orders by your `CompareTo`.", - "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`: EF drops the cast, while memory still orders by your `CompareTo`.", + "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", + "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", "oracle": "exception", "surfaces": [ "HandWrittenOrderingAttribute.*" From 8562de6753fc50cdc67d40f1967ab9921f874c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:09:37 +0100 Subject: [PATCH 12/30] fix: refuse ordering a boxed value object a grouping, a join or a subquery's First carries Eight shapes still answered the column's order at d573eef, 1, 2, 3 (Max 3) where memory answers 3, 2, 1 (Max 1) for a descending CompareTo, measured on SQLite: a grouping's boxed key under OrderBy(g => g.Key), a grouping's boxed elements under g.Max(), GroupBy's, Join's and SelectMany's result selectors holding the boxed value, SelectMany of boxed values under Max(), and a subquery's or a navigation's boxed First() as a key. The Finder followed a lambda's parameter only through Select and the operators that pass elements on, and visited a nested operator, g.Max(), knowing nothing of its source. The Finder now knows what each lambda parameter stands for. Every LINQ operator binds its lambdas' parameters: to the source's elements for a selector, key or predicate; to each side's elements for Join and GroupJoin (the inner side whole for GroupJoin); to the collection's elements for SelectMany's result selector; to the key and the grouped elements for GroupBy's. A grouping's Key reads its key selector, and First, Single, Last, ElementAt and their OrDefault forms read the element of their source. The visitor carries these bindings into nested lambdas, so an operator inside one reads what its source was built from. The README, its claim and the changelog line name the new shapes. Input class (EFT): an ordering operator over a value object, by the operator whose lambda carries the boxed value to it. Sibling found by the same rows (VA): a GroupJoin result reading cs.First().Rank, refused before EF now. Loud and left as it is: a Tuple.Create projection, which EF does not translate. Catalogue: QueryShapes' Op axis gains OrderByGroupKeyBoxed and OrderByJoinedBoxed over every kind, converter and accessor of the column and nullable sites. Controls over .Value and a generated ordering, through a grouping, a join and a subquery, still translate. Reverting the source fails 21: 12 catalogue rows, 9 explicit. Refs #206 Defect-Class: EFT Found-By: RS Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../HandWrittenOrderingInterceptor.cs | 209 ++++++++++++++---- .../README.md | 3 +- .../HandWrittenOrderingTests.cs | 16 ++ .../Variants/QueryShapes.cs | 18 +- test/oracles/claims.json | 4 +- 6 files changed, 199 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d45b95d..0d55fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ section is the GitHub release's notes. - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). ## 2.3.0 — 2026-10-29 diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index d1ac512..3b922fd 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; @@ -117,11 +118,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node) // A key the selector converts (to object, IComparable) is still ordered in memory by the // value object's CompareTo, while EF drops the conversion and orders by the column. So are // elements converted before the operator, Select(r => (object)r.Rank).Max(), whose key is the - // element itself or which has no key (#206), and a key that reads the boxed value through a ??, a ?: - // or a member of a projection, OrderBy(a => a.Key) after Select(r => new { Key = (object)r.Rank }). + // element itself or which has no key (#206), and a key that reads the boxed value back: through a ??, + // a ?:, a member of a projection, a grouping's key or elements, a join, or a subquery's First. var key = KeySelector(node); - IEnumerable read = key is not null ? ReadTypes(key.Body, key.Parameters[0], node.Arguments[0]) - : method.IsGenericMethod ? ElementTypes(node.Arguments[0], method.GetGenericArguments()[0]) + IEnumerable read = key is not null ? ReadTypes(key.Body, Bound(node, _scope)) + : method.IsGenericMethod ? ElementTypes(node.Arguments[0], _scope, method.GetGenericArguments()[0]) : []; foreach (var type in read) Refuse(type, $"ordered by {method.Name}"); } @@ -141,9 +142,23 @@ protected override Expression VisitMethodCall(MethodCallExpression node) Refuse(element, $"compared in a row value by {method.Name}"); } - return base.VisitMethodCall(node); + // The operator's lambdas are visited knowing what their parameters stand for, so that an operator inside + // one, g.Max() in Select(g => g.Max()), reads the elements its source was built from. + var outer = _scope; + _scope = Bound(node, outer); + try + { + return base.VisitMethodCall(node); + } + finally + { + _scope = outer; + } } + /// What the parameters of the lambdas around the node being visited stand for. + private Scope _scope = Scope.Empty; + /// A function of EF.Functions: an extension of , whichever provider declares it. private static bool IsDbFunction(MethodInfo method) => method.IsStatic && method.GetParameters() is [{ ParameterType: var functions }, ..] && functions == typeof(DbFunctions); @@ -167,83 +182,181 @@ private static IEnumerable TupleElements(Type tuple) private static Expression StripQuotes(Expression expression) => expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? StripQuotes(quote.Operand) : expression; + /// What an expression of the query stands for, as far as the query says: a value, or a sequence. + private abstract record Meaning; + + /// An expression, read where its lambdas' parameters stand for what says. + private sealed record Value(Expression Expression, Scope Scope) : Meaning; + + /// A grouping (its keys, and its elements), or with no keys, a sequence a lambda is given whole. + private sealed record Group(IReadOnlyList Keys, IReadOnlyList Elements) : Meaning; + + /// What each lambda parameter in reach stands for; a parameter it does not name is read as its own type. + private sealed class Scope(ImmutableDictionary> bound) + { + public static readonly Scope Empty = new(ImmutableDictionary>.Empty); + + public Scope With(ParameterExpression parameter, IReadOnlyList meanings) => new(bound.SetItem(parameter, meanings)); + + public IReadOnlyList Of(ParameterExpression parameter) => bound.TryGetValue(parameter, out var meanings) ? meanings : []; + } + /// - /// The types a sequence's elements have under their boxing and reference conversions: a Select's - /// body unconverted, followed through the operators that pass the elements on. - /// is the element type the consuming operator reads, for a sequence that is no such operator. + /// with the parameters of a LINQ operator's lambdas bound: to the elements of the source + /// for a selector, key or predicate; for Join and GroupJoin, each side's to its sequence; for + /// SelectMany's result selector, the collection's elements; for GroupBy's, the key and the elements. /// - private static IEnumerable ElementTypes(Expression sequence, Type declared) + private static Scope Bound(MethodCallExpression call, Scope scope) { - if (sequence is not MethodCallExpression { Method: { IsGenericMethod: true } method } call || !IsLinq(method)) return [declared]; + if (!IsLinq(call.Method) || !call.Method.IsGenericMethod || call.Method.Name is nameof(Enumerable.Aggregate)) return scope; - var source = method.GetGenericArguments()[0]; - if (method.Name is nameof(Queryable.Select) && KeySelector(call) is { } selector) - return ReadTypes(selector.Body, selector.Parameters[0], call.Arguments[0]); - if (method.Name is nameof(Queryable.Cast) or nameof(Queryable.OfType)) - return ElementTypes(call.Arguments[0], typeof(object)).Where(element => declared.IsAssignableFrom(element)); + var lambdas = Lambdas(call); + if (lambdas.Count == 0) return scope; - return Upstream(call) is [_, ..] upstream ? upstream.SelectMany(sequence => ElementTypes(sequence, source)) : [declared]; + var source = Elements(call.Arguments[0], scope); + switch (call.Method.Name) + { + case nameof(Queryable.Join) or nameof(Queryable.GroupJoin) when lambdas is [var outerKey, var innerKey, var result]: + var inner = Elements(call.Arguments[1], scope); + return scope.With(outerKey.Parameters[0], source).With(innerKey.Parameters[0], inner).With(result.Parameters[0], source) + .With(result.Parameters[1], call.Method.Name is nameof(Queryable.Join) ? inner : [new Group([], inner)]); + case nameof(Queryable.SelectMany): + var collection = scope.With(lambdas[0].Parameters[0], source); + return lambdas is [var selector, var resultSelector] + ? collection.With(resultSelector.Parameters[0], source).With(resultSelector.Parameters[1], Elements(selector.Body, collection)) + : collection; + case nameof(Queryable.GroupBy): + var (keys, elements) = Grouping(lambdas, scope, source); + var grouped = lambdas.Where(lambda => lambda.Parameters.Count == 1).Aggregate(scope, (bound, lambda) => bound.With(lambda.Parameters[0], source)); + return lambdas.FirstOrDefault(lambda => lambda.Parameters.Count == 2) is { } resultOf + ? grouped.With(resultOf.Parameters[0], keys).With(resultOf.Parameters[1], [new Group([], elements)]) + : grouped; + default: + return lambdas.Aggregate(scope, (bound, lambda) => bound.With(lambda.Parameters[0], source)); + } + } + + /// A GroupBy's keys, its key selector's body, and its elements, its element selector's body or the source's. + private static (IReadOnlyList Keys, IReadOnlyList Elements) Grouping(IReadOnlyList lambdas, Scope scope, IReadOnlyList source) + { + var selectors = lambdas.Where(lambda => lambda.Parameters.Count == 1).ToList(); + Meaning Read(LambdaExpression selector) => new Value(selector.Body, scope.With(selector.Parameters[0], source)); + + return ([Read(selectors[0])], selectors is [_, var element, ..] ? [Read(element)] : source); + } + + private static List Lambdas(MethodCallExpression call) => [.. call.Arguments.Select(StripQuotes).OfType()]; + + /// + /// What a sequence's elements are, as far as the query says: a Select's body, a join's or a GroupBy's + /// result, a grouping, SelectMany's collection, followed through the operators that pass the elements on, + /// and the elements of a grouping or a sequence a lambda was given; none for a sequence the query does not build. + /// + private static IReadOnlyList Elements(Expression sequence, Scope scope) + { + switch (Unconverted(sequence)) + { + case ParameterExpression parameter: + return [.. scope.Of(parameter).OfType().SelectMany(group => group.Elements)]; + case MethodCallExpression { Method: { IsGenericMethod: true } method } call when IsLinq(method): + var lambdas = Lambdas(call); + switch (method.Name) + { + case nameof(Queryable.Select) when lambdas is [var selector]: + return [new Value(selector.Body, Bound(call, scope))]; + case nameof(Queryable.SelectMany) when lambdas is [var selector]: + return Elements(selector.Body, Bound(call, scope)); + case nameof(Queryable.SelectMany) or nameof(Queryable.Join) or nameof(Queryable.GroupJoin) when lambdas is [.., { Parameters.Count: 2 } result]: + return [new Value(result.Body, Bound(call, scope))]; + case nameof(Queryable.GroupBy) when lambdas is [_, ..]: + if (lambdas.FirstOrDefault(lambda => lambda.Parameters.Count == 2) is { } resultOf) return [new Value(resultOf.Body, Bound(call, scope))]; + var (keys, elements) = Grouping(lambdas, scope, Elements(call.Arguments[0], scope)); + return [new Group(keys, elements)]; + case nameof(Queryable.Cast) or nameof(Queryable.OfType): + var target = method.GetGenericArguments()[0]; + return [.. Elements(call.Arguments[0], scope).Where(element => element is not Value value || ReadTypes(value.Expression, value.Scope).Any(target.IsAssignableFrom))]; + default: + return [.. Upstream(call).SelectMany(upstream => Elements(upstream, scope))]; + } + default: + return []; + } } /// - /// The types an expression of a lambda over 's elements reads under its boxing and - /// reference conversions: both sides of a ?? and a ?:, the elements themselves for the parameter, - /// and for a member of a projection, the expression its new or member initializer set it from. + /// The types a sequence's elements have under their boxing and reference conversions. + /// is the element type the consuming operator reads, for a sequence the query does not build. /// - private static IEnumerable ReadTypes(Expression expression, ParameterExpression parameter, Expression sequence) + private static IEnumerable ElementTypes(Expression sequence, Scope scope, Type declared) => + Elements(sequence, scope) is [_, ..] elements ? elements.SelectMany(element => TypesOf(element, declared)) : [declared]; + + private static IEnumerable TypesOf(Meaning meaning, Type declared) => + meaning is Value value ? ReadTypes(value.Expression, value.Scope) : [declared]; + + /// + /// The types an expression reads under its boxing and reference conversions: both sides of a ?? and a + /// ?:, what a parameter stands for, the expression a member of a projection or a grouping's key was set + /// from, and the element a First and its kind take. + /// + private static IEnumerable ReadTypes(Expression expression, Scope scope) { switch (Unconverted(expression)) { case BinaryExpression { NodeType: ExpressionType.Coalesce } coalesce: - return [.. ReadTypes(coalesce.Left, parameter, sequence), .. ReadTypes(coalesce.Right, parameter, sequence)]; + return [.. ReadTypes(coalesce.Left, scope), .. ReadTypes(coalesce.Right, scope)]; case ConditionalExpression conditional: - return [.. ReadTypes(conditional.IfTrue, parameter, sequence), .. ReadTypes(conditional.IfFalse, parameter, sequence)]; - case var read when read == parameter: - return ElementTypes(sequence, parameter.Type); - case MemberExpression { Expression: { } instance } member when Initializers(instance, member.Member, parameter, sequence).ToList() is [_, ..] initializers: - return initializers.SelectMany(initializer => ReadTypes(initializer.Expression, initializer.Parameter, initializer.Sequence)); + return [.. ReadTypes(conditional.IfTrue, scope), .. ReadTypes(conditional.IfFalse, scope)]; + case ParameterExpression parameter when scope.Of(parameter) is [_, ..] meanings: + return meanings.SelectMany(meaning => TypesOf(meaning, parameter.Type)); + case MemberExpression { Expression: { } instance } member when Members(instance, member.Member, scope) is [_, ..] meanings: + return meanings.SelectMany(meaning => TypesOf(meaning, member.Type)); + case MethodCallExpression call when TakesAnElement(call) && Elements(call.Arguments[0], scope) is [_, ..] elements: + return elements.SelectMany(element => TypesOf(element, call.Type)); case var read: return [read.Type]; } } /// - /// The expressions a new or a member initializer set of - /// from, each with the parameter and the sequence its lambda reads: in the same lambda, or in the Select - /// that built the elements the parameter ranges over; none where nothing in the query set it. + /// What of was set from: an argument of a new, a + /// binding of a member initializer, a grouping's key, found in the expression itself or in what it stands for. /// - private static IEnumerable<(Expression Expression, ParameterExpression Parameter, Expression Sequence)> Initializers( - Expression instance, MemberInfo member, ParameterExpression parameter, Expression sequence) + private static IReadOnlyList Members(Expression instance, MemberInfo member, Scope scope) { switch (Unconverted(instance)) { case NewExpression { Members: { } members } created: - return members.Select((declared, index) => (declared, index)) - .Where(pair => pair.declared.HasSameMetadataDefinitionAs(member)) - .Select(pair => (created.Arguments[pair.index], parameter, sequence)); + return [.. members.Select((declared, index) => (declared, index)) + .Where(pair => pair.declared.HasSameMetadataDefinitionAs(member)) + .Select(pair => new Value(created.Arguments[pair.index], scope))]; case MemberInitExpression initialized: - return initialized.Bindings.OfType() - .Where(binding => binding.Member.HasSameMetadataDefinitionAs(member)) - .Select(binding => (binding.Expression, parameter, sequence)); - case var read when read == parameter: - return Projections(sequence).SelectMany(projection => Initializers(projection.Selector.Body, member, projection.Selector.Parameters[0], projection.Source)); + return [.. initialized.Bindings.OfType() + .Where(binding => binding.Member.HasSameMetadataDefinitionAs(member)) + .Select(binding => new Value(binding.Expression, scope))]; + case ParameterExpression parameter: + return [.. scope.Of(parameter).SelectMany(meaning => MembersOf(meaning, member))]; case MemberExpression { Expression: { } outer } nested: - return Initializers(outer, nested.Member, parameter, sequence) - .SelectMany(initializer => Initializers(initializer.Expression, member, initializer.Parameter, initializer.Sequence)); + return [.. Members(outer, nested.Member, scope).SelectMany(meaning => MembersOf(meaning, member))]; + case MethodCallExpression call when TakesAnElement(call): + return [.. Elements(call.Arguments[0], scope).SelectMany(element => MembersOf(element, member))]; default: return []; } } - /// The Select selectors that built a sequence's elements, each with its source, followed through the operators that pass the elements on. - private static IEnumerable<(LambdaExpression Selector, Expression Source)> Projections(Expression sequence) + private static IReadOnlyList MembersOf(Meaning meaning, MemberInfo member) => meaning switch { - if (sequence is not MethodCallExpression { Method: { IsGenericMethod: true } method } call || !IsLinq(method)) return []; + Value value => Members(value.Expression, member, value.Scope), + Group group when member is { Name: nameof(IGrouping<,>.Key), DeclaringType: { IsGenericType: true } declaring } + && declaring.GetGenericTypeDefinition() == typeof(IGrouping<,>) => group.Keys, + _ => [], + }; - return method.Name is nameof(Queryable.Select) && KeySelector(call) is { } selector - ? [(selector, call.Arguments[0])] - : Upstream(call).SelectMany(Projections); - } + /// An operator that answers one of its source's elements: First, Single, Last, ElementAt and their OrDefault forms. + private static bool TakesAnElement(MethodCallExpression call) => + IsLinq(call.Method) && call.Method.IsGenericMethod + && call.Method.Name is nameof(Queryable.First) or nameof(Queryable.FirstOrDefault) or nameof(Queryable.Single) or nameof(Queryable.SingleOrDefault) + or nameof(Queryable.Last) or nameof(Queryable.LastOrDefault) or nameof(Queryable.ElementAt) or nameof(Queryable.ElementAtOrDefault); /// The sequences whose elements an operator passes on as they are: its source, both for Concat and its kind; none for any other. private static Expression[] Upstream(MethodCallExpression call) => call.Method.Name switch diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index d30100d..e9d01ac 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -212,7 +212,8 @@ instead, which works everywhere and may add a cast: - `Min` or `Max`. That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, - and when the cast value reaches the ordering through `??` or a member of a projection, as in + and when the cast value reaches the ordering another way, through `??`, a member of a projection, a + grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`. diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 0231cce..941f1b3 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -67,6 +67,18 @@ public sealed class HandWrittenOrderingTests : IDisposable ["OrderBy a coalesced boxed member"] = db => db.Ranked.Select(r => new { Key = (object?)r.Optional ?? r.Rank }).OrderBy(a => a.Key).ToList(), ["Max of a boxed member over a navigation"] = db => db.Parents.Select(p => p.Children.Select(c => new { Key = (object)c.Rank }).Max(a => a.Key)).ToList(), + // The boxed value reaching the ordering through another operator's lambda: each answered 1, 2, 3 (Max 3) where + // memory answers 3, 2, 1 (Max 1), measured at d573eef. + ["OrderBy a grouping's boxed key"] = db => db.Ranked.GroupBy(r => (object)r.Rank).OrderBy(g => g.Key).Select(g => g.Key).ToList(), + ["Max of a grouping's boxed elements"] = db => db.Ranked.GroupBy(r => r.Id % 1, r => (object)r.Rank).Select(g => g.Max()).ToList(), + ["OrderBy a GroupBy result's boxed key"] = db => db.Ranked.GroupBy(r => r.Rank, (k, g) => new { Key = (object)k }).OrderBy(a => a.Key).ToList(), + ["OrderBy a Join result's boxed member"] = db => db.Ranked.Join(db.Ranked, a => a.Id, b => b.Id, (a, b) => new { Key = (object)b.Rank }).OrderBy(x => x.Key).ToList(), + ["OrderBy a GroupJoin result's boxed member"] = db => db.Parents.GroupJoin(db.Set(), p => p.Id, c => c.ParentId, (p, cs) => new { Key = (object)cs.First().Rank }).OrderBy(x => x.Key).ToList(), + ["OrderBy a SelectMany result's boxed member"] = db => db.Parents.SelectMany(p => p.Children, (p, c) => new { Key = (object)c.Rank }).OrderBy(x => x.Key).ToList(), + ["Max of boxed values through SelectMany"] = db => db.Parents.SelectMany(p => p.Children.Select(c => (object)c.Rank)).Max()!, + ["OrderBy a subquery's boxed First"] = db => db.Ranked.OrderBy(r => db.Ranked.Where(x => x.Id == r.Id).Select(x => (object)x.Rank).First()).ToList(), + ["OrderBy a navigation's boxed FirstOrDefault"] = db => db.Ranked.OrderBy(r => db.Parents.SelectMany(p => p.Children).Where(c => c.Id == r.Id).Select(c => (object)c.Rank).FirstOrDefault()).ToList(), + // The database's own GREATEST and LEAST (SQLite's max and min) order by the column: the first answered // 2, 2, 3 where memory's order answers 1, 1, 2 (measured on PostgreSQL 17 before the refusal). ["Greatest"] = db => db.Ranked.Select(r => EF.Functions.Greatest(r.Rank, Two)).ToList(), @@ -161,6 +173,10 @@ public void Ordering_by_Value_equality_and_a_generated_ordering_still_translate( db.Ranked.Select(r => new { Key = (object)r.Count }).OrderBy(a => a.Key).Select(a => a.Key).ToList() .ShouldBe([ProbeCount.From(1), ProbeCount.From(2), ProbeCount.From(3)]); db.Ranked.OrderBy(r => (object?)r.Count ?? r.Rank.Value).Select(r => r.Rank.Value).ToList().ShouldBe([1, 2, 3]); + db.Ranked.GroupBy(r => (object)r.Rank.Value).OrderBy(g => g.Key).Select(g => g.Key).ToList().ShouldBe([1, 2, 3]); + db.Ranked.Join(db.Ranked, a => a.Id, b => b.Id, (a, b) => new { Key = (object)b.Count }).OrderBy(x => x.Key).Select(x => x.Key).ToList() + .ShouldBe([ProbeCount.From(1), ProbeCount.From(2), ProbeCount.From(3)]); + db.Ranked.OrderBy(r => db.Ranked.Where(x => x.Id == r.Id).Select(x => (object)x.Rank.Value).First()).Select(r => r.Rank.Value).ToList().ShouldBe([1, 2, 3]); db.Parents.Select(p => p.Children.OrderByDescending(c => c.Rank.Value).Select(c => c.Rank.Value).First()).ToList().ShouldBe([3]); db.Parents.Select(p => p.Children.Max(c => c.Rank.Value)).ToList().ShouldBe([3]); diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs index 578b12c..5f96b0d 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs @@ -112,6 +112,12 @@ public enum Op /// Select(r => new BoxedKey { Key = r.P }).Max(b => b.Key). MaxProjectedBoxed, + + /// GroupBy(r => (object)r.P).OrderBy(g => g.Key): a grouping's boxed key. + OrderByGroupKeyBoxed, + + /// Join(rows, a => a.Id, b => b.Id, (a, b) => new BoxedKey { Key = a.P }).OrderBy(b => b.Key): a join's boxed result. + OrderByJoinedBoxed, Order, OrderDescending, Min, @@ -159,7 +165,7 @@ public sealed record QueryCase(WrappedKind Kind, Conversion Conversion, Site Sit /// A comparison, an ordering, or a minimum or maximum: what a value object's own CompareTo decides in memory. public bool Orders => Op is Op.GreaterOrEqual or Op.LessThan or Op.LessOrEqual or Op.LessThanOrNull or Op.CompareTo or Op.OrderBy or Op.OrderByDescending or Op.ThenBy or Op.ThenByDescending or Op.OrderByBoxed or Op.ThenByBoxed or Op.OrderBoxed or Op.OrderByItselfBoxed or Op.MinBoxed or Op.MaxBoxed - or Op.OrderByCoalescedBoxed or Op.OrderByProjectedBoxed or Op.MaxProjectedBoxed + or Op.OrderByCoalescedBoxed or Op.OrderByProjectedBoxed or Op.MaxProjectedBoxed or Op.OrderByGroupKeyBoxed or Op.OrderByJoinedBoxed or Op.Order or Op.OrderDescending or Op.Min or Op.Max or Op.MinBy or Op.MaxBy or Op.AnyGreaterOrEqual or Op.CountGreaterOrEqual or Op.ElementMax or Op.ElementOrderFirst or Op.NavigationMin or Op.NavigationMax or Op.NavigationOrderFirst or Op.NavigationOrderDescendingFirst @@ -185,6 +191,7 @@ public static class QueryShapes Op.EqualsLiteral, Op.EqualsCaptured, Op.NotEquals, Op.GreaterOrEqual, Op.LessThan, Op.LessOrEqual, Op.CompareTo, Op.OrderBy, Op.OrderByDescending, Op.ThenBy, Op.ThenByDescending, Op.OrderByBoxed, Op.ThenByBoxed, Op.OrderBoxed, Op.OrderByItselfBoxed, Op.MinBoxed, Op.MaxBoxed, Op.OrderByCoalescedBoxed, Op.OrderByProjectedBoxed, Op.MaxProjectedBoxed, + Op.OrderByGroupKeyBoxed, Op.OrderByJoinedBoxed, Op.Order, Op.OrderDescending, Op.Min, Op.Max, Op.MinBy, Op.MaxBy, Op.Contains, Op.GroupBy, Op.Select, Op.Distinct, .. FunctionOps, ]; @@ -474,6 +481,13 @@ private static Expression CompareTo(Expression left, Expression right) => var projected = Q("Select", [Source, typeof(BoxedKey)], source, Expression.Lambda(Expression.MemberInit(Expression.New(typeof(BoxedKey)), Expression.Bind(typeof(BoxedKey).GetProperty(nameof(BoxedKey.Key))!, Expression.Convert(read, typeof(object)))), row)); var heldKey = Expression.Lambda(Expression.Property(holder, nameof(BoxedKey.Key)), holder); + var grouping = typeof(IGrouping<,>).MakeGenericType(typeof(object), Source); + var group = Expression.Parameter(grouping, "g"); + var groupKey = Expression.Lambda(Expression.Property(group, "Key"), group); + var other = Expression.Parameter(Source, "o"); + var joined = Q("Join", [Source, Source, typeof(int), typeof(BoxedKey)], source, source, + Expression.Lambda(Expression.Property(row, "Id"), row), Expression.Lambda(Expression.Property(other, "Id"), other), + Expression.Lambda(Expression.MemberInit(Expression.New(typeof(BoxedKey)), Expression.Bind(typeof(BoxedKey).GetProperty(nameof(BoxedKey.Key))!, Expression.Convert(read, typeof(object)))), row, other)); return @case.Op switch { @@ -498,6 +512,8 @@ private static Expression CompareTo(Expression left, Expression right) => Op.OrderByCoalescedBoxed => (Values(Q("OrderBy", [Source, typeof(object)], source, coalesced)), true), Op.OrderByProjectedBoxed => (Q("Select", [typeof(BoxedKey), typeof(object)], Q("OrderBy", [typeof(BoxedKey), typeof(object)], projected, heldKey), heldKey), true), Op.MaxProjectedBoxed => (Q("Max", [typeof(BoxedKey), typeof(object)], projected, heldKey), true), + Op.OrderByGroupKeyBoxed => (Q("Select", [grouping, typeof(object)], Q("OrderBy", [grouping, typeof(object)], Q("GroupBy", [Source, typeof(object)], source, boxed), groupKey), groupKey), true), + Op.OrderByJoinedBoxed => (Q("Select", [typeof(BoxedKey), typeof(object)], Q("OrderBy", [typeof(BoxedKey), typeof(object)], joined, heldKey), heldKey), true), Op.Order => (Q("Order", [type], Values(source)), true), Op.OrderDescending => (Q("OrderDescending", [type], Values(source)), true), Op.Min => (Q("Min", [Source, type], source, key), true), diff --git a/test/oracles/claims.json b/test/oracles/claims.json index ee7b7f3..24979d5 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1965,8 +1965,8 @@ { "id": "E05-ordering-boxed", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", - "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", - "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering through `??` or a member of a projection, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", + "quote": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering another way, through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", + "claim": "That holds when the value object is cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, and when the cast value reaches the ordering another way, through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`: EF drops the cast, while memory still orders by your `CompareTo`.", "oracle": "exception", "surfaces": [ "HandWrittenOrderingAttribute.*" From dba724c5bff87cf5036a8c3774ca88cf41033651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:19:52 +0100 Subject: [PATCH 13/30] fix: CMTK0005 stays silent on an array with a constant zero in any dimension AnalyzeArrayCreation skipped an array only when every dimension was a constant 0. A multidimensional array is empty when any one dimension is, so new Plain[0, 2] held no default element and was reported. The check is now Any; stackalloc reads the same rule, where its one dimension made All and Any agree already. Input class (AIS): an array creation of a no-default struct, by rank and by which dimension is a constant zero. The new row covers rank 2 with the first, the second and both dimensions zero, a variable beside a literal zero and a named constant beside a variable, rank 3 with a zero in the middle and last, a jagged [0][], and two arrays with no zero dimension that still report. No sibling: the invocation forms take one length, where a zero is already skipped. Reverting the source fails the row: 8 reports where 2 are expected. Closes #227 Defect-Class: AIS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ .../DefaultFilledCollectionAnalyzer.cs | 11 ++++--- src/CodoMetis.TypeKit.Analyzers/README.md | 5 +-- .../DefaultFilledCollectionAnalyzerTests.cs | 31 +++++++++++++++++++ test/oracles/claims.json | 14 +++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..c42689b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +### Fixed + +- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, which holds no element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs index 3a45eed..b0657e6 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs @@ -17,9 +17,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// stackalloc OrderId[n], GC.AllocateUninitializedArray<OrderId>(n), /// GC.AllocateArray and Array.Resize (measured 2026-09-28), which adds default slots /// whenever the new size is larger, and the rule cannot tell whether it is. An array with initial -/// elements, a constant length or size of zero and a collection expression start with no default -/// element and are not reported. A warning, since filling such an array in a loop straight after is -/// correct code the rule cannot tell apart. +/// elements, a constant length or size of zero in any one dimension and a collection expression start +/// with no default element and are not reported. A warning, since filling such an array in a loop +/// straight after is correct code the rule cannot tell apart. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class DefaultFilledCollectionAnalyzer : DiagnosticAnalyzer @@ -66,7 +66,8 @@ private static void AnalyzeArrayCreation(OperationAnalysisContext context, TypeK var creation = (IArrayCreationOperation)context.Operation; if (creation.Initializer is not null || creation.Type is not IArrayTypeSymbol { ElementType: var element }) return; - if (creation.DimensionSizes.All(size => size.ConstantValue is { HasValue: true, Value: 0 })) return; + // Any dimension of zero leaves the array with no element: new T[0, n] holds none. + if (creation.DimensionSizes.Any(size => size.ConstantValue is { HasValue: true, Value: 0 })) return; if (Diagnose(context.ContainingSymbol, creation.Syntax, "An array created with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } @@ -107,7 +108,7 @@ private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKit if (stackAlloc.Initializer is not null || stackAlloc.Type is not ArrayTypeSyntax arrayType) return; var sizes = arrayType.RankSpecifiers.SelectMany(rank => rank.Sizes); - if (sizes.All(size => context.SemanticModel.GetConstantValue(size, context.CancellationToken) is { HasValue: true, Value: 0 })) return; + if (sizes.Any(size => context.SemanticModel.GetConstantValue(size, context.CancellationToken) is { HasValue: true, Value: 0 })) return; if (context.SemanticModel.GetTypeInfo(arrayType.ElementType, context.CancellationToken).Type is not { } element) return; diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index b78e309..dc51499 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -30,7 +30,7 @@ project. The two packages share a version and move together. | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation doesn't reference `CodoMetis.TypeKit.Generators`. So nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | | CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. Also an assertion of `CodoMetis.TypeKit.Testing` on a task, left without `await`. `_` says you mean the drop. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core package. | -| CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero isn't reported. | +| CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero, in any dimension, isn't reported. | | CMTK0006 | Warning | A field or auto-property of such a type, in a class, that no initializer, `required` or constructor sets. Also a `required` one that a `[SetsRequiredMembers]` constructor doesn't set. | | CMTK0007 | Info | `FromKnownGood` given a value that comes straight from a parameter. | | CMTK0008 | Warning | The values inside two different value objects compared, `order.CustomerId.Value == product.Id.Value`, or the two value objects themselves compared through `Equals(object)`. | @@ -303,7 +303,8 @@ OrderId[] kept = [.. rows.Select(r => OrderId.From(r.Id))]; // built from valu `stackalloc`, `GC.AllocateUninitializedArray`, `GC.AllocateArray` and `Array.Resize` are reported too. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A -constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. +constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. That +holds for any one dimension: `new OrderId[0, count]` holds no element either. It's a warning, because an array filled in a loop straight after it is created is correct, and the rule can't tell it from one that isn't. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs index 140150e..a92b345 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs @@ -108,6 +108,37 @@ public static void Run(Plain[] existing) """) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A multidimensional array holds no element when any dimension is a constant zero, not only when + /// every one is: new Plain[0, 2] was reported (#227). Across the input class, an array + /// creation by rank and by which dimension is a constant zero; one with no zero dimension reports. + /// + [Fact] + public Task An_array_with_any_constant_zero_dimension_stays_silent() => + Test( + """ + public static class C + { + public static void Run(int n) + { + const int none = 0; + var first = new Plain[0, 2]; + var second = new Plain[2, 0]; + var both = new Plain[0, 0]; + var variable = new Plain[n, 0]; + var named = new Plain[none, n]; + var middle = new Plain[2, 0, 2]; + var last = new Plain[n, 2, 0]; + var jagged = new Plain[0][]; + var grid = {|#0:new Plain[2, 2]|}; + var cube = {|#1:new Plain[n, 1, 2]|}; + } + } + """, + Cmtk0005(0, "An array created with a length", "Plain"), + Cmtk0005(1, "An array created with a length", "Plain")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The subjects' own new Plain[1] inside Plain doubles as the exemption for the type /// itself. Nothing here starts with a default element of a no-default struct. diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..d8aa8c0 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2432,6 +2432,20 @@ "ForbiddenDefaultInitializationAnalyzerTests" ] }, + { + "id": "K05-a-zero-dimension-is-silent", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005", + "quote": "That holds for any one dimension: `new OrderId[0, count]` holds no element either.", + "claim": "CMTK0005 does not report an array whose length is a constant zero in any one of its dimensions.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "DefaultFilledCollectionAnalyzerTests" + ] + }, { "id": "L01-read-as-the-underlying-type-reads", "source": "README.md#what-typekit-guarantees", From 1e81f0250c30fce47873ed11adf05bc19512d6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:21:33 +0100 Subject: [PATCH 14/30] fix: CMTK0005 reads a zero length of any integral type as zero The zero check matched only a boxed int. An array dimension may be a long, uint or ulong, so new Plain[0L] was reported, and a stackalloc size is read from syntax with its type before the conversion to int, so stackalloc Plain[(byte)0], [(short)0] and ['\0'] were too. One IsZero now reads every integral constant, for the array creation, the stackalloc and the allocating calls alike. Found running #227's input class (an array creation by which dimension is a constant zero) across how the zero is written: int, long, uint, ulong, byte, short and char, in new, stackalloc, GC.AllocateArray and Array.Resize. The six that failed are the long and unsigned dimensions and the three stackalloc sizes. Seeding the int-only match back fails the row: 8 reports where 2 are expected. Refs #227 Defect-Class: AIS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../DefaultFilledCollectionAnalyzer.cs | 25 +++++++++++++-- .../DefaultFilledCollectionAnalyzerTests.cs | 32 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42689b..aecf40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ section is the GitHub release's notes. ### Fixed -- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, which holds no element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, or a zero written as `0L` or, in a `stackalloc`, `(byte)0`: neither holds an element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). ## 2.3.0 — 2026-10-29 diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs index b0657e6..c984a14 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs @@ -67,7 +67,7 @@ private static void AnalyzeArrayCreation(OperationAnalysisContext context, TypeK if (creation.Initializer is not null || creation.Type is not IArrayTypeSymbol { ElementType: var element }) return; // Any dimension of zero leaves the array with no element: new T[0, n] holds none. - if (creation.DimensionSizes.Any(size => size.ConstantValue is { HasValue: true, Value: 0 })) return; + if (creation.DimensionSizes.Any(size => IsZero(size.ConstantValue))) return; if (Diagnose(context.ContainingSymbol, creation.Syntax, "An array created with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } @@ -96,7 +96,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitS if (form is null) return; // A length of zero creates no slot, as new T[0] does not. - if (invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == size)?.Value.ConstantValue is { HasValue: true, Value: 0 }) return; + if (invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == size) is { } length && IsZero(length.Value.ConstantValue)) return; if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, method.TypeArguments[0], symbols) is { } diagnostic) context.Report(diagnostic); } @@ -108,13 +108,32 @@ private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKit if (stackAlloc.Initializer is not null || stackAlloc.Type is not ArrayTypeSyntax arrayType) return; var sizes = arrayType.RankSpecifiers.SelectMany(rank => rank.Sizes); - if (sizes.Any(size => context.SemanticModel.GetConstantValue(size, context.CancellationToken) is { HasValue: true, Value: 0 })) return; + if (sizes.Any(size => IsZero(context.SemanticModel.GetConstantValue(size, context.CancellationToken)))) return; if (context.SemanticModel.GetTypeInfo(arrayType.ElementType, context.CancellationToken).Type is not { } element) return; if (Diagnose(context.ContainingSymbol, stackAlloc, "A stackalloc with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } + /// + /// A constant zero of any integral type: a dimension may be a long, uint or ulong, + /// and a size read from syntax keeps its type before the conversion to int, (byte)0 or '\0'. + /// + private static bool IsZero(Optional constant) => + constant.HasValue && constant.Value switch + { + int value => value == 0, + long value => value == 0, + uint value => value == 0, + ulong value => value == 0, + short value => value == 0, + ushort value => value == 0, + byte value => value == 0, + sbyte value => value == 0, + char value => value == 0, + _ => false, + }; + private static Diagnostic? Diagnose(ISymbol? containingSymbol, SyntaxNode node, string form, ITypeSymbol element, TypeKitSymbols symbols) { if (!symbols.IsNoDefaultStruct(element)) return null; diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs index a92b345..f9c91eb 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs @@ -139,6 +139,38 @@ public static void Run(int n) Cmtk0005(1, "An array created with a length", "Plain")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// A zero of any integral type creates no slot: a dimension may be a long, uint or + /// ulong, and a stackalloc size's constant is read before its conversion to int, + /// so 0L and (byte)0 were reported where 0 was not. + /// + [Fact] + public Task A_zero_of_any_integral_type_stays_silent() => + Test( + """ + public static class C + { + public static void Run(int n, Plain[] existing) + { + var asLong = new Plain[0L]; + var asUnsigned = new Plain[0u, 2]; + var asUnsignedLong = new Plain[2, 0ul]; + var asByte = new Plain[(byte)0]; + var asChar = new Plain['\0']; + Span stackByte = stackalloc Plain[(byte)0]; + Span stackShort = stackalloc Plain[(short)0]; + Span stackChar = stackalloc Plain['\0']; + var allocated = GC.AllocateArray((byte)0); + Array.Resize(ref existing, '\0'); + var one = {|#0:new Plain[1L]|}; + Span stackOne = {|#1:stackalloc Plain[(byte)1]|}; + } + } + """, + Cmtk0005(0, "An array created with a length", "Plain"), + Cmtk0005(1, "A stackalloc with a length", "Plain")) + .RunAsync(TestContext.Current.CancellationToken); + /// /// The subjects' own new Plain[1] inside Plain doubles as the exemption for the type /// itself. Nothing here starts with a default element of a no-default struct. From 52572fb605690269cedf1d7c2d57ada3266d1741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:23:03 +0100 Subject: [PATCH 15/30] fix: let a query filter order the context's own values without a lambda HasQueryFilter(r => r.Rank == Limits.Max()) over a value object whose ordering is its own CompareTo was refused when the model was built, though EF evaluates Limits.Max() in memory, by CompareTo, and sends the answer as one parameter. The Finder refused an ordering operator in a filter without asking whether it runs in memory, as its comparison branches do. It now lets an ordering through in a filter when it reads nothing of the database and takes no lambda. The lambda matters: measured on SQLite, EF evaluates Limits.Max(), Min(), Order().First() and OrderDescending() .First() in memory, but sends the values to the database and orders them there for OrderByDescending(l => l).First(), which kept the row ranked 3 where memory keeps 1, and fails to translate MaxBy(l => l). So #225's proposed change, skipping by database reads alone, would have answered those silently; they stay refused. KB-058 pins the distinction with plain EF (a collection with a lambda goes into the SQL as a VALUES list of parameters; Max(v => v) throws inside EF). The README says when a filter may order the context's values (claim E05-query-filter-own-values). Input class (EFT): an ordering operator in a query filter, by its source: the context's values without a lambda (allowed: Max, Min, Order().First(), OrderDescending().First() over an array and a list), the context's values with a lambda (refused: OrderByDescending(l => l), MaxBy, a boxing Select before Max, which EF happens to evaluate but which the lambda rule keeps refused), the entity's navigation and the table itself (refused). Reverting the source fails the 4 allowed rows; dropping the lambda condition, the change #225 proposed, fails the 3 refused ones. Refs #225 Defect-Class: EFT Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ptured-values-in-sql-only-with-a-lambda.md | 23 +++++ design/knowledge/README.md | 1 + .../HandWrittenOrderingInterceptor.cs | 34 ++++++- .../README.md | 5 + .../HandWrittenOrderingTests.cs | 84 ++++++++++++++++ test/CodoMetis.TypeKit.Facts/KB058.cs | 96 +++++++++++++++++++ test/oracles/claims.json | 13 +++ 8 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB058.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d55fd3..e0f2270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- EF Core: a query filter that orders values your context holds without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`, builds again; EF works it out in memory by your `CompareTo`. With a lambda, EF orders those values in the database, and the filter still fails ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). ## 2.3.0 — 2026-10-29 diff --git a/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md b/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md new file mode 100644 index 0000000..e3ede51 --- /dev/null +++ b/design/knowledge/KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md @@ -0,0 +1,23 @@ +--- +verified: 2026-10-31 +pins: + - Microsoft.EntityFrameworkCore.Sqlite 10.0.12 +source: measured with plain EF Core on SQLite (`ToQueryString`), and with a value object whose `CompareTo` is descending in `HandWrittenOrderingTests` +turned-on: the EF satellite's refusal of an ordering in a query filter (`HandWrittenOrdering.OrderingRunsInMemory`, issue 225), which lets an ordering over the context's own values through only when it takes no lambda +test: test/CodoMetis.TypeKit.Facts/KB058.cs +--- + +# KB-058. EF orders captured values in SQL only when the chain takes a lambda + +A LINQ chain over a collection a query captures, `values.Max()`, `values.Min()`, `values.Order().First()` or +`values.First()`, is evaluated in memory: the SQL holds one parameter with the answer. With a lambda, +`values.OrderBy(v => v).First()` or `values.OrderByDescending(v => v).First()`, EF sends the collection's elements and +orders them in SQL: for an `int[]`, one parameter per element in a `VALUES` list (EF 10's default), `ORDER BY "v"."Value" +DESC LIMIT 1`. `values.Max(v => v)` over an `int[]` throws a `NullReferenceException` inside EF. A query filter that +reads the context's own values, `HasQueryFilter(r => r.P == Values.Max())`, is keyed on the same distinction. + +For a value object whose `CompareTo` is its own, the first kind orders by `CompareTo` and the second by the stored +value: over 1, 2, 3 with a descending `CompareTo`, `Values.Max()` is 1 and `Values.OrderByDescending(v => v).First()` +answered 3. `MaxBy(v => v)` over the parameter is not translated at all. One chain with a lambda, +`values.Select(v => (object)v).Max()`, was evaluated in memory too; the satellite still refuses it, since the +distinction it can rely on is the lambda. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index c9e4b6f..bc949b1 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -85,3 +85,4 @@ test and later for the harness's `Baseline.Fact`. | [KB-052](KB-052-sql-server-takes-no-max-of-a-bit.md) | SQL Server takes neither `MAX` nor `MIN` of a `bit` | SQL Server 2022, EF SqlServer 10.0.12 | `KB052`, with Docker | | [KB-053](KB-053-greatest-and-least-need-compatibility-level-160.md) | EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 | EF SqlServer 10.0.12 | `KB053` | | [KB-054](KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md) | `datetime2` keeps every tick of a `DateTime` and reads it back with `Kind` Unspecified | SQL Server 2022, EF SqlServer 10.0.12 | `KB054`, with Docker | +| [KB-058](KB-058-ef-orders-captured-values-in-sql-only-with-a-lambda.md) | EF orders captured values in SQL only when the chain takes a lambda: `values.Max()` runs in memory, `values.Max(v => v)` in SQL | EF Sqlite 10.0.12 | `KB058` | diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index 3b922fd..d20033d 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -111,7 +111,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) foreach (var argument in node.Arguments) Refuse(argument.Type, $"compared with {method.Name}"); } } - else if (IsLinq(method) && OrderedType(method) is { } ordered) + else if (IsLinq(method) && OrderedType(method) is { } ordered && !OrderingRunsInMemory(node)) { Refuse(ordered, $"ordered by {method.Name}"); @@ -384,6 +384,15 @@ private static Expression Unconverted(Expression expression) => /// private bool RunsInMemory(Expression comparison) => skipsWhatRunsInMemory && !DatabaseReads.In(comparison); + /// + /// Whether EF evaluates an ordering before translating, in a filter: it reads nothing of the database and takes + /// no lambda. Over the context's own values EF evaluates Limits.Max() and Limits.Order().First() in + /// memory, by CompareTo, but sends Limits to the database as a parameter and orders it there for + /// Limits.Max(l => l) or Limits.OrderByDescending(l => l).First(), by the column's order + /// (measured with EF Core 10 on SQLite, #225). An ordering with a lambda that EF happens to evaluate stays refused. + /// + private bool OrderingRunsInMemory(Expression ordering) => RunsInMemory(ordering) && !LambdaFinder.In(ordering); + /// The type a LINQ operator orders by: the key, the element for Order, the result for Min and Max. private static Type? OrderedType(MethodInfo method) => method.Name switch { @@ -418,6 +427,29 @@ private void Refuse(Type type, string how) } /// Whether an expression reads a parameter it does not declare itself, or a query. + /// Whether an expression holds a lambda, quoted or not. + private sealed class LambdaFinder : ExpressionVisitor + { + private bool _found; + + public static bool In(Expression expression) + { + var finder = new LambdaFinder(); + finder.Visit(expression); + + return finder._found; + } + + public override Expression? Visit(Expression? node) => _found || node is null ? node : base.Visit(node); + + protected override Expression VisitLambda(Expression node) + { + _found = true; + + return node; + } + } + private sealed class DatabaseReads : ExpressionVisitor { private readonly HashSet _declared = []; diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index e9d01ac..f33da9e 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -222,6 +222,11 @@ instead, which works everywhere and may add a cast: and stays allowed. A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type. + One exception: a filter may order values your context holds, without a lambda, as in + `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a + lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that + filter fails too. + ### On SQL Server A value object behaves on SQL Server as the type inside it does there. That isn't always how it diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs index 941f1b3..63dabf0 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenOrderingTests.cs @@ -204,6 +204,10 @@ public void A_comparison_EF_evaluates_in_memory_is_not_refused() [InlineData(typeof(FilteredByGreatestDb), "compared by Greatest", "the query filter of FilteredRank")] [InlineData(typeof(FilteredByBoxedChildRankDb), "ordered by Max", "the query filter of RankParent")] [InlineData(typeof(FilteredByProjectedChildRankDb), "ordered by Max", "the query filter of RankParent")] + [InlineData(typeof(FilteredByTableMaxDb), "ordered by Max", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedOrderByDescendingFirstDb), "ordered by OrderByDescending", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedMaxByDb), "ordered by MaxBy", "the query filter of FilteredRank")] + [InlineData(typeof(CapturedBoxedMaxDb), "ordered by Max", "the query filter of FilteredRank")] public void A_query_filter_that_orders_by_its_own_CompareTo_is_refused(Type context, string how, string filter) { _connection.Open(); @@ -250,6 +254,29 @@ public void A_query_filter_by_Value_or_in_memory_is_not_refused() db.Set().Select(r => r.Rank.Value).Order().ToList().ShouldBe([2, 3]); } + /// + /// An ordering over the context's own values that takes no lambda reads nothing of the database, and EF evaluates + /// it in memory, by CompareTo: Limits.Max() over 1, 2, 3 is 1 for the descending order, so the filter + /// keeps the row ranked 1. Each was refused when the model was built (#225). With a lambda, EF orders the values + /// in the database: OrderByDescending(l => l).First() kept 3 where memory keeps 1, so those stay refused. + /// + [Theory] + [InlineData(typeof(CapturedMaxDb), 1)] + [InlineData(typeof(CapturedMinDb), 3)] + [InlineData(typeof(CapturedOrderFirstDb), 3)] + [InlineData(typeof(CapturedOrderDescendingFirstDb), 1)] + public void A_query_filter_that_orders_the_contexts_own_values_runs_in_memory(Type context, int kept) + { + _connection.Open(); + using var db = (DbContext)Activator.CreateInstance(context, new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options)!; + db.Database.EnsureCreated(); + foreach (var value in new[] { 1, 2, 3 }) db.Add(new FilteredRank { Rank = ProbeDescending.From(value) }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + db.Set().Select(r => r.Rank.Value).ToList().ShouldBe([kept]); + } + public void Dispose() => _connection.Dispose(); private RankedDb Seeded() @@ -362,6 +389,63 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +/// A query filter by the greatest rank of the table itself, which the database orders by the column. +public sealed class FilteredByTableMaxDb(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Set().Max(x => x.Rank)); +} + +/// Contexts whose query filter orders values the context holds: in memory without a lambda, in the database with one. +public abstract class CapturedLimitsDb(DbContextOptions options) : DbContext(options) +{ + public ProbeDescending[] Limits { get; } = [ProbeDescending.From(1), ProbeDescending.From(2), ProbeDescending.From(3)]; + + public List LimitList { get; } = [ProbeDescending.From(1), ProbeDescending.From(2), ProbeDescending.From(3)]; +} + +public sealed class CapturedMaxDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.Max()); +} + +public sealed class CapturedMinDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.Min()); +} + +public sealed class CapturedOrderFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.Order().First()); +} + +public sealed class CapturedOrderDescendingFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.OrderDescending().First()); +} + +public sealed class CapturedOrderByDescendingFirstDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == LimitList.OrderByDescending(l => l).First()); +} + +public sealed class CapturedMaxByDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == Limits.MaxBy(l => l)); +} + +public sealed class CapturedBoxedMaxDb(DbContextOptions options) : CapturedLimitsDb(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => + modelBuilder.Entity().HasQueryFilter(r => r.Rank == (ProbeDescending)Limits.Select(l => (object)l).Max()!); +} + /// A projection that holds a rank boxed. public sealed class BoxedRank { diff --git a/test/CodoMetis.TypeKit.Facts/KB058.cs b/test/CodoMetis.TypeKit.Facts/KB058.cs new file mode 100644 index 0000000..21d9208 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB058.cs @@ -0,0 +1,96 @@ +using System.Linq.Expressions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-058: EF Core evaluates a LINQ chain over a collection the query captures in memory when the chain +/// takes no lambda, values.Max(), and sends the result as one parameter; with a lambda, +/// values.OrderByDescending(v => v).First(), it sends the collection's elements and orders them in SQL, and +/// values.Max(v => v) throws inside EF. A query filter over the context's own values does the same. +/// +public sealed class KB058 +{ + public sealed class Row + { + public int Id { get; set; } + + public int P { get; set; } + } + + private sealed class Db(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); + } + + private sealed class MaxFilterDb(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity().HasQueryFilter(r => r.P == Values.Max()); + } + + private sealed class KeyedMaxFilterDb(DbContextOptions options) : DbContext(options) + { + public int[] Values { get; } = [1, 2, 3]; + + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity().HasQueryFilter(r => r.P == Values.OrderByDescending(v => v).First()); + } + + public static TheoryData Chains => new() + { + { "Max()", false }, + { "Min()", false }, + { "Order().First()", false }, + { "First()", false }, + { "OrderBy(v => v).First()", true }, + { "OrderByDescending(v => v).First()", true }, + }; + + [Theory, MemberData(nameof(Chains))] + public void A_chain_over_captured_values_is_ordered_in_SQL_only_with_a_lambda(string chain, bool inSql) + { + int[] values = [1, 2, 3]; + Expression> filter = chain switch + { + "Max()" => r => r.P == values.Max(), + "Min()" => r => r.P == values.Min(), + "Order().First()" => r => r.P == values.Order().First(), + "First()" => r => r.P == values.First(), + "OrderBy(v => v).First()" => r => r.P == values.OrderBy(v => v).First(), + _ => r => r.P == values.OrderByDescending(v => v).First(), + }; + using var connection = new SqliteConnection("Data Source=:memory:"); + using var db = new Db(new DbContextOptionsBuilder().UseSqlite(connection).Options); + + HoldsTheCollection(db.Set().Where(filter).ToQueryString()).ShouldBe(inSql); + } + + [Fact] + public void A_keyed_Max_over_captured_values_throws_inside_EF() + { + int[] values = [1, 2, 3]; + using var connection = new SqliteConnection("Data Source=:memory:"); + using var db = new Db(new DbContextOptionsBuilder().UseSqlite(connection).Options); + + Should.Throw(() => db.Set().Where(r => r.P == values.Max(v => v)).ToQueryString()); + } + + [Fact] + public void A_query_filter_over_the_contexts_values_is_ordered_in_SQL_only_with_a_lambda() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + + using (var db = new MaxFilterDb(new DbContextOptionsBuilder().UseSqlite(connection).Options)) + HoldsTheCollection(db.Set().ToQueryString()).ShouldBeFalse(); + + using (var db = new KeyedMaxFilterDb(new DbContextOptionsBuilder().UseSqlite(connection).Options)) + HoldsTheCollection(db.Set().ToQueryString()).ShouldBeTrue(); + } + + /// The collection's elements in the SQL: one parameter each in a VALUES list (EF 10's default), or as JSON. + private static bool HoldsTheCollection(string sql) => sql.Contains("VALUES") || sql.Contains("json_each"); +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 24979d5..c14223e 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1976,6 +1976,19 @@ "QueryTranslationVariantTests" ] }, + { + "id": "E05-query-filter-own-values", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "claim": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "oracle": "exception", + "surfaces": [ + "HandWrittenOrderingAttribute.*" + ], + "heldBy": [ + "HandWrittenOrderingTests" + ] + }, { "id": "E05-query-filter", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", From fbfb8ef98cdf68546654d62ed285ee672690eb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:28:44 +0100 Subject: [PATCH 16/30] fix: keep an overload of a generated member that differs by arity or ref kind ValueObjectDeclaration.MethodsWithSignature and ClosedSetTypes.SameParameters matched a hand-written method to a generated one by name and parameter types alone. A generic Parse(string, IFormatProvider?), a From(in int) or From(ref int), a TryParse whose last parameter is by value, a generic or by-value TryFormat, and a closed set's generic Parse or by-value TryParse were refused as the generated member written by hand (CMTK1011), on the type and on a base record or base class, though C# overloads by each and Metalama introduces the generated member beside it (measured in a scratch consumer: every row builds and calls the generated member, no LAMA error). Both now match only a method without type parameters, and compare each parameter's ref kind: by reference where the generated one is out, by value where it is by value. ref or in against out is no overload in C# (CS0663) and nullability none at all, so those stay CMTK1011. Input class (GTS): a method that shares a generated member's name and parameter types, by what differs (arity; in, ref or out against by value; ref and in against out; nullability) and where it is declared (the type, a base record of this assembly and of another, a closed set and its base class), over the declaration shapes the check reads (record struct, sealed record class, closed set). 34 rows in OverloadTests build and call the generated member, 10 reach the hand-written overload, and 8 controls in BuildOutcomeTests stay refused. The wrapped type only enters the check as From's parameter, so the rows wrap int, which has every generated parse and format. Siblings found by the same catalogue on the other signature checks (TryFrom, Revalidate, CompareTo, Equals, GetHashCode, PrintMembers, ToString()) are the next commit. Reverting the source refuses all 16 new value objects and sets in the test project (32 CMTK1011); comparing ref kinds exactly (ref against out as another method) fails the 6 ref and in controls and Every_error_is_one_of_ the_intended_ones. FabricTests looked up From and TryFrom by name, which an overload made ambiguous; it looks them up by signature. The surface snapshot renders a generic method's type parameters and a ref or in parameter, so an overload no longer reads as a duplicate line; Match gains . Closes #226 Defect-Class: GTS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + design/aspects.md | 6 + .../ClosedSetTypes.cs | 20 +- src/CodoMetis.TypeKit.Generators/README.md | 10 +- .../ValueObjectDeclaration.cs | 36 +- .../Probes.cs | 17 + .../BuildOutcomeTests.cs | 38 + .../FabricTests.cs | 3 +- .../GeneratedSurface.verified.txt | 841 +++++++++++++++++- .../GeneratedSurfaceTests.cs | 14 +- .../OverloadTests.cs | 281 ++++++ test/oracles/claims.json | 29 + 12 files changed, 1246 insertions(+), 50 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a6066..bb41446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/aspects.md b/design/aspects.md index dc7ef6a..e4c4048 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -184,6 +184,12 @@ members of a generated name or signature and its `IParsable` are CMTK1011, its `sealed` `ToString()` is the seam and one not sealed is CMTK1011 (#205). Not refused: an interface's `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. + - **A signature is what C# overloads by (#226).** A hand-written method matches a generated one only with no type + parameters, as the generated one has none, and with each parameter by reference where the generated one's is + `out` and by value where it is by value. A generic `Parse(string, IFormatProvider?)`, a `From(in int)` + and a `TryParse` whose last parameter is by value were CMTK1011, on the type, a base record and a closed set, + though Metalama introduces the generated member beside each (measured 2026-10-31). `ref` or `in` against `out` is + no overload in C# (CS0663), and nullability none at all, so both stay CMTK1011. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and whatever the base declares. A base comparing by reference made two value objects holding 42 unequal diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index 6deb354..f152439 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -203,8 +203,9 @@ private static string WithoutComments(string text) /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { - var text = TypeFactory.GetType(SpecialType.String); - var boolean = TypeFactory.GetType(SpecialType.Boolean); + (IType, bool) text = (TypeFactory.GetType(SpecialType.String), false); + (IType, bool) boolean = (TypeFactory.GetType(SpecialType.Boolean), false); + (IType, bool) result = (set, true); var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); var generatedNames = GeneratedNames(set); @@ -222,7 +223,7 @@ .. bases.SelectMany(type => type.Fields.Concat(type.Properties).Concat( .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.Accessibility != Accessibility.Private && generatedNames.Contains(member.Name)) .Select(member => $"{member.ToDisplayString()} in its base type {type.ToDisplayString()}")), .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean)))), - .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, result) || SameParameters(method, text, boolean, result)))), .. Declared(type => type.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count))), .. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), @@ -243,10 +244,15 @@ .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedT private static bool IsOwn(IMethod method) => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false }; - /// Parameter types equal, nullability aside: Metalama refuses to introduce a method beside one of the same signature. - private static bool SameParameters(IMethod method, params IType[] types) => - method.Parameters.Count == types.Length - && method.Parameters.Select(parameter => parameter.Type.ToNonNullable()).Zip(types, (declared, expected) => declared.Equals(expected.ToNonNullable())).All(same => same); + /// + /// Parameter types equal, nullability aside, and each by reference where the generated one is out, in a + /// method without type parameters: Metalama refuses to introduce a method beside one of the same signature. A + /// generic Parse<T>(string) or a TryParse(string?, TSelf) is another method (#226). + /// + private static bool SameParameters(IMethod method, params (IType Type, bool Out)[] parameters) => + method.TypeParameters.Count == 0 + && method.Parameters.Count == parameters.Length + && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.ToNonNullable().Equals(expected.Type.ToNonNullable()) && declared.RefKind != RefKind.None == expected.Out).All(same => same); /// The generated Match<TResult>: one Func<TResult> per member. private static bool IsGeneratedMatch(IMethod method, int memberCount) => diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 3a248e8..90bc280 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -239,6 +239,12 @@ covers: `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach it. The error names the base. +An overload of a generated member is yours, and the generated one sits beside it. C# tells two methods +apart by their type parameters and by whether a parameter is `ref`, `in` or `out`, so a +`Parse(string, IFormatProvider?)`, a `From(in int value)` or a `TryParse` whose last parameter +isn't `out` is kept, on the value object or a base record. A `ref` or `in` where the generated member has +`out` is the same method to C#, as a different nullability is, so it fails the build like the member itself. + Two explicit implementations in an interface are fine, because a value object loses nothing by replacing them. A record's own `==` and `!=` replace an interface's, generated or not. And .NET's `INumberBase` formats and parses UTF-8 through the UTF-16 members, which every .NET number @@ -462,8 +468,8 @@ public sealed partial class OrderStatus : IClosedSet, IParsable(string name)`, is kept too. Any other generated member written by hand fails the build (CMTK1011), in the set or in a class it derives from. - **A base class's `ToString()`.** A `sealed` one is kept, since C# keeps it in every derived class. One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 7dbd478..da158d3 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -210,12 +210,15 @@ IEnumerable Declared(Func> find) => if (kind == ValueObjectKind.Plain) { - found.AddRange(Declared(type => MethodsWithSignature(type, "From", valueType))); + found.AddRange(Declared(type => MethodsWithSignature(type, "From", (valueType, false)))); found.AddRange(Declared(type => MembersNamed(type, methods: false, "From"))); } - var stringType = TypeFactory.GetType(SpecialType.String); - var provider = TypeFactory.GetType(typeof(IFormatProvider)); + // A generated method's parameters, and whether each is out: an overload that takes one by value where the + // generated one takes it by reference, or the other way round, is another method (#226). + (IType, bool) stringType = (TypeFactory.GetType(SpecialType.String), false); + (IType, bool) provider = (TypeFactory.GetType(typeof(IFormatProvider)), false); + (IType, bool) result = (target, true); // ValueObjectParsableAspect and, where parsing is generated, ValueObjectTypeConverterAspect. var parse = ValueObjectParsableAspect.ResolveStrategy(valueType); @@ -223,7 +226,7 @@ IEnumerable Declared(Func> find) => if (parse != ValueParseStrategy.Unsupported) { found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", stringType, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", stringType, provider, target))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", stringType, provider, result))); found.AddRange(Declared(type => MembersNamed(type, $"{target.Name}TypeConverter"))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IParsable<>))); @@ -231,18 +234,18 @@ IEnumerable Declared(Func> find) => { generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanParsable<>))); - var span = TypeFactory.GetType(typeof(ReadOnlySpan)); + (IType, bool) span = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", span, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", span, provider, target))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", span, provider, result))); } if (ValueObjectParsableAspect.SupportsUtf8(valueType)) { generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanParsable<>))); - var utf8 = TypeFactory.GetType(typeof(ReadOnlySpan)); + (IType, bool) utf8 = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", utf8, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", utf8, provider, target))); + found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", utf8, provider, result))); } } @@ -254,13 +257,13 @@ IEnumerable Declared(Func> find) => if (ValueObjectFormattableAspect.ResolveStrategy(valueType) == ValueFormatStrategy.SpanFormattable) { - found.AddRange(Declared(type => MethodsWithSignature(type, nameof(ISpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, nameof(ISpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanFormattable))); } if (ValueObjectFormattableAspect.SupportsUtf8(valueType)) { - found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IUtf8SpanFormattable.TryFormat), TypeFactory.GetType(typeof(Span)), TypeFactory.GetType(SpecialType.Int32), TypeFactory.GetType(typeof(ReadOnlySpan)), provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IUtf8SpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanFormattable))); } } @@ -678,11 +681,18 @@ internal static IEnumerable MembersNamed(INamedType target, bool methods /// whatever they return: a generated member with that signature is either refused by Metalama or /// skipped in favour of the hand-written one. /// - private static IEnumerable MethodsWithSignature(INamedType target, string name, params IType[] parameters) => + /// + /// The signature C# overloads by (#226): no type parameters, as the generated method has none, and each + /// parameter by reference where the generated one is out, by value where it is by value. A generic + /// Parse<TFormat>(string, IFormatProvider?), a From(in int) and a TryParse whose + /// last parameter is by value are other methods, which the generated ones sit beside. ref or in + /// against out is no overload in C# (CS0663), so it is the same method, as nullability is. + /// + private static IEnumerable MethodsWithSignature(INamedType target, string name, params (IType Type, bool Out)[] parameters) => target.Methods - .Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } + .Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, TypeParameters.Count: 0 } && method.Name == name && method.Parameters.Count == parameters.Length - && method.Parameters.Select(parameter => parameter.Type).Zip(parameters, (declared, expected) => declared.Equals(expected)).All(same => same)) + && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.Equals(expected.Type) && declared.RefKind != RefKind.None == expected.Out).All(same => same)) .Select(method => method.ToDisplayString()); } diff --git a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index e4c3c36..45ef516 100644 --- a/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs +++ b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs @@ -186,6 +186,23 @@ public override void Write(System.Text.Json.Utf8JsonWriter writer, ProbeSpanText /// public abstract record ProbeRecordBase; +/// +/// A base record in another assembly whose methods share a generated member's name and parameter types, but are +/// other methods to C#: a generic Parse, a From that takes its value by in, and a +/// TryParse whose last parameter is by value. The generated members sit beside them (#226). +/// +public abstract record ProbeOverloadingRecord +{ + /// A generic overload, never the generated Parse. + public static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// An overload by in, never the generated From. + public static TSelf From(in int value) => throw new NotSupportedException("hand-written"); + + /// An overload whose last parameter is by value, never the generated TryParse. + public static bool TryParse(string? text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); +} + /// /// A base record in another assembly for the test assembly's value objects, whose sealed /// ToString() is the formatting seam: C# keeps it in every derived record. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 5c81867..a726059 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -183,6 +183,8 @@ public void A_member_name_the_generators_take_is_refused_with_the_reason(string [InlineData("SetOverParsingBase", "ParsingSetBase.Parse(string) in its base type ParsingSetBase")] [InlineData("SetOverIgnoreCaseParsingBase", "IgnoreCaseParsingSetBase.Parse(string, bool) in its base type IgnoreCaseParsingSetBase")] [InlineData("SetOverTryParsingBase", "TryParsingSetBase.TryParse(string?, bool, out SetOverTryParsingBase) in its base type TryParsingSetBase")] + [InlineData("SetWithTryParseByRef", "SetWithTryParseByRef.TryParse(string?, ref SetWithTryParseByRef)")] + [InlineData("SetOverTryParsingByRefBase", "TryParsingByRefSetBase.TryParse(string?, bool, ref SetOverTryParsingByRefBase) in its base type TryParsingByRefSetBase")] [InlineData("SetOverMatchingBase", "MatchingSetBase.Match(Func) in its base type MatchingSetBase")] [InlineData("SetOverParsableBase", "the interface IParsable in its base type ParsableSetBase")] [InlineData("SetOverAllListingBase", "AllListingSetBase.All in its base type AllListingSetBase")] @@ -338,6 +340,10 @@ public void A_part_a_source_generator_adds_does_not_count() => [InlineData("HandWrittenParse", "HandWrittenParse.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenParseOverASet", "HandWrittenParseOverASet.Parse(string, IFormatProvider?)")] [InlineData("HandWrittenFormat", "HandWrittenFormat.ToString(string?, IFormatProvider?)")] + [InlineData("TryParseByRef", "TryParseByRef.TryParse(string?, IFormatProvider?, ref TryParseByRef)")] + [InlineData("TryParseByIn", "TryParseByIn.TryParse(string?, IFormatProvider?, in TryParseByIn)")] + [InlineData("ParseOfOtherNullability", "ParseOfOtherNullability.Parse(string?, IFormatProvider)")] + [InlineData("TryFormatByRef", "TryFormatByRef.TryFormat(Span, ref int, ReadOnlySpan, IFormatProvider?)")] [InlineData("HandWrittenMinValue", "HandWrittenMinValue.MinValue")] [InlineData("HandWrittenJsonConverter", "the attribute JsonConverter")] [InlineData("HandWrittenTypeConverter", "the attribute TypeConverter")] @@ -495,6 +501,7 @@ public void One_Create_over_a_base_record_is_not_refused(string type) => [InlineData("CMTK1011", "InheritsPublicFormatting", "PublicFormattingBase.ToString(string?, IFormatProvider?) in its base type PublicFormattingBase")] [InlineData("CMTK1008", "InheritsPublicObjectOrder", "PublicObjectOrderBase.CompareTo(object?) in its base type PublicObjectOrderBase")] [InlineData("CMTK1011", "InheritsPublicParsing", "PublicParsingBase.Parse(string, IFormatProvider?) in its base type PublicParsingBase")] + [InlineData("CMTK1011", "InheritsTryParseByRef", "TryParseByRefBase.TryParse(string?, IFormatProvider?, ref InheritsTryParseByRef) in its base type TryParseByRefBase")] [InlineData("CMTK1011", "InheritsMinMaxNames", "MinMaxNamesBase.MinValue in its base type MinMaxNamesBase")] [InlineData("CMTK1011", "InheritsValueName", "ValueNameBase.Value in its base type ValueNameBase")] [InlineData("CMTK1011", "InheritsFromName", "FromNameBase.From(int) in its base type FromNameBase")] @@ -1043,6 +1050,27 @@ public static Result Create(string value) => public string ToString(string? format, System.IFormatProvider? provider) => ""; } + // No overload in C#: ref or in against the generated out (CS0663), and another nullability (#226). + public readonly partial record struct TryParseByRef : IValue + { + public static bool TryParse(string? s, System.IFormatProvider? provider, ref TryParseByRef result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct TryParseByIn : IValue + { + public static bool TryParse(string? s, System.IFormatProvider? provider, in TryParseByIn result) => throw new System.NotSupportedException(); + } + + public readonly partial record struct ParseOfOtherNullability : IValue + { + public static ParseOfOtherNullability Parse(string? s, System.IFormatProvider provider) => throw new System.NotSupportedException(); + } + + public readonly partial record struct TryFormatByRef : IValue + { + public bool TryFormat(System.Span destination, ref int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => false; + } + public readonly partial record struct HandWrittenMinValue : IValue { public static int MinValue => 0; @@ -1511,6 +1539,13 @@ public abstract record PublicParsingBase : System.IParsable where public sealed partial record InheritsPublicParsing : PublicParsingBase, IValue; + public abstract record TryParseByRefBase + { + public static bool TryParse(string? s, System.IFormatProvider? provider, ref TSelf result) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryParseByRef : TryParseByRefBase, IValue; + public abstract record MinMaxNamesBase where TSelf : MinMaxNamesBase { public static TSelf MinValue => throw new System.NotSupportedException(); @@ -2042,6 +2077,9 @@ public abstract class OpenToStringSetBase { public override string ToString() => public sealed partial class SetOverIgnoreCaseParsingBase : IgnoreCaseParsingSetBase, IClosedSet { public static readonly SetOverIgnoreCaseParsingBase A = new(); } public abstract class TryParsingSetBase { public static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException(); } public sealed partial class SetOverTryParsingBase : TryParsingSetBase, IClosedSet { public static readonly SetOverTryParsingBase A = new(); } + public sealed partial class SetWithTryParseByRef : IClosedSet { public static readonly SetWithTryParseByRef A = new(); public static bool TryParse(string? name, ref SetWithTryParseByRef member) => throw new System.NotSupportedException(); } + public abstract class TryParsingByRefSetBase { public static bool TryParse(string? name, bool ignoreCase, ref TSelf member) => throw new System.NotSupportedException(); } + public sealed partial class SetOverTryParsingByRefBase : TryParsingByRefSetBase, IClosedSet { public static readonly SetOverTryParsingByRefBase A = new(); } public abstract class MatchingSetBase { public TResult Match(System.Func only) => only(); } public sealed partial class SetOverMatchingBase : MatchingSetBase, IClosedSet { public static readonly SetOverMatchingBase A = new(); } public abstract class ParsableSetBase : System.IParsable where TSelf : ParsableSetBase diff --git a/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs index a288162..b3c1664 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/FabricTests.cs @@ -44,7 +44,8 @@ public void Every_declared_value_object_is_generated(Type type) type.GetInterfaces().ShouldContain(typeof(IValueObject<,>).MakeGenericType(type, underlying)); type.GetProperty("Value", BindingFlags.Public | BindingFlags.Instance).ShouldNotBeNull().PropertyType.ShouldBe(underlying); - type.GetMethod(plain ? "From" : "TryFrom", BindingFlags.Public | BindingFlags.Static).ShouldNotBeNull(); + // By its signature: a value object may declare an overload of it, generic or by reference (#226). + type.GetMethod(plain ? "From" : "TryFrom", 0, BindingFlags.Public | BindingFlags.Static, [underlying]).ShouldNotBeNull(); } /// diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 67b03ee..cb6623e 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -100,7 +100,7 @@ ProbeArticle ProbeArticleFault Description : String Kind : FaultKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -166,7 +166,7 @@ ProbeCode ProbeConcurrencyFault Description : String Kind : FaultKind - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -700,7 +700,7 @@ ProbeIsbn extension static ProbeIsbnExtensions.GetValue(ProbeIsbn) : ProbeIsbnText extension static ProbeIsbnExtensions.ValueOrNull(ProbeIsbn?) : ProbeIsbnText ProbeKeyword - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -719,7 +719,7 @@ ProbeKeyword static TryParse(String, out ProbeKeyword) : Boolean ProbeKindlessFault Kind : FaultKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -781,7 +781,7 @@ ProbeLabel extension static ProbeLabelExtensions.GetValue(ProbeLabel) : String extension static ProbeLabelExtensions.ValueOrNull(ProbeLabel) : String ProbeLabelled - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -799,7 +799,7 @@ ProbeLabelled static TryParse(String, Boolean, out ProbeLabelled) : Boolean static TryParse(String, out ProbeLabelled) : Boolean ProbeLevel - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -946,7 +946,7 @@ ProbeName extension static ProbeNameExtensions.GetValue(ProbeName) : String extension static ProbeNameExtensions.ValueOrNull(ProbeName?) : String ProbeOrder.Status - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -966,7 +966,7 @@ ProbeOrder.Status ProbeOrderFault Description : String Kind : FaultKind - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1129,7 +1129,7 @@ ProbePin ProbePriority Label : String Match(Action, Action, Action) : Void - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String Rank : Int32 @@ -1284,7 +1284,7 @@ ProbeSecret extension static ProbeSecretExtensions.GetValue(ProbeSecret) : String extension static ProbeSecretExtensions.ValueOrNull(ProbeSecret) : String ProbeShipment.Status - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1360,7 +1360,7 @@ ProbeSpan extension static ProbeSpanExtensions.GetValue(ProbeSpan) : ProbeSpanText extension static ProbeSpanExtensions.ValueOrNull(ProbeSpan?) : ProbeSpanText ProbeSplit - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1378,7 +1378,7 @@ ProbeSplit static TryParse(String, Boolean, out ProbeSplit) : Boolean static TryParse(String, out ProbeSplit) : Boolean ProbeStatus - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1396,7 +1396,7 @@ ProbeStatus static TryParse(String, Boolean, out ProbeStatus) : Boolean static TryParse(String, out ProbeStatus) : Boolean ProbeTier - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String Perk : String QualifiedName : String @@ -1670,7 +1670,7 @@ DefaultedVersion extension static DefaultedVersionExtensions.GetValue(DefaultedVersion) : Version extension static DefaultedVersionExtensions.ValueOrNull(DefaultedVersion?) : Version DocumentKind - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1688,7 +1688,7 @@ DocumentKind static TryParse(String, Boolean, out DocumentKind) : Boolean static TryParse(String, out DocumentKind) : Boolean EarlyAllStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1706,7 +1706,7 @@ EarlyAllStatus static TryParse(String, Boolean, out EarlyAllStatus) : Boolean static TryParse(String, out EarlyAllStatus) : Boolean EarlyIgnoreCaseParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1724,7 +1724,7 @@ EarlyIgnoreCaseParseStatus static TryParse(String, Boolean, out EarlyIgnoreCaseParseStatus) : Boolean static TryParse(String, out EarlyIgnoreCaseParseStatus) : Boolean EarlyIgnoreCaseTryParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1742,7 +1742,7 @@ EarlyIgnoreCaseTryParseStatus static TryParse(String, Boolean, out EarlyIgnoreCaseTryParseStatus) : Boolean static TryParse(String, out EarlyIgnoreCaseTryParseStatus) : Boolean EarlyJsonKeyStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1760,7 +1760,7 @@ EarlyJsonKeyStatus static TryParse(String, Boolean, out EarlyJsonKeyStatus) : Boolean static TryParse(String, out EarlyJsonKeyStatus) : Boolean EarlyJsonStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1778,7 +1778,7 @@ EarlyJsonStatus static TryParse(String, Boolean, out EarlyJsonStatus) : Boolean static TryParse(String, out EarlyJsonStatus) : Boolean EarlyParsableStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1796,7 +1796,7 @@ EarlyParsableStatus static TryParse(String, Boolean, out EarlyParsableStatus) : Boolean static TryParse(String, out EarlyParsableStatus) : Boolean EarlyParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1814,7 +1814,7 @@ EarlyParseStatus static TryParse(String, Boolean, out EarlyParseStatus) : Boolean static TryParse(String, out EarlyParseStatus) : Boolean EarlyTryParseStatus - Match(Func) : TResult + Match(Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1831,8 +1831,273 @@ EarlyTryParseStatus static Parse(String, Boolean) : EarlyTryParseStatus static TryParse(String, Boolean, out EarlyTryParseStatus) : Boolean static TryParse(String, out EarlyTryParseStatus) : Boolean +FormatOverloadedByArity + CompareTo(FormatOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(FormatOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FormatOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FormatOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : FormatOverloadedByArity + static MaxValue : FormatOverloadedByArity + static MinValue : FormatOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByArity + static Parse(String, IFormatProvider) : FormatOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out FormatOverloadedByArity) : Boolean + static op_Equality(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_GreaterThan(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_Inequality(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_LessThan(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + static op_LessThanOrEqual(FormatOverloadedByArity, FormatOverloadedByArity) : Boolean + extension static FormatOverloadedByArityExtensions.GetValue(FormatOverloadedByArity) : Int32 + extension static FormatOverloadedByArityExtensions.ValueOrNull(FormatOverloadedByArity?) : Int32? +FormatOverloadedByIn + CompareTo(FormatOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + Equals(FormatOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString(in String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FormatOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FormatOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : FormatOverloadedByIn + static MaxValue : FormatOverloadedByIn + static MinValue : FormatOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FormatOverloadedByIn + static Parse(String, IFormatProvider) : FormatOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FormatOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out FormatOverloadedByIn) : Boolean + static op_Equality(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_GreaterThan(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_Inequality(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_LessThan(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + static op_LessThanOrEqual(FormatOverloadedByIn, FormatOverloadedByIn) : Boolean + extension static FormatOverloadedByInExtensions.GetValue(FormatOverloadedByIn) : Int32 + extension static FormatOverloadedByInExtensions.ValueOrNull(FormatOverloadedByIn?) : Int32? +FromOverloadedByArity + CompareTo(FromOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByArity + static From(Int32) : FromOverloadedByArity + static MaxValue : FromOverloadedByArity + static MinValue : FromOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByArity + static Parse(String, IFormatProvider) : FromOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByArity) : Boolean + static op_Equality(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_GreaterThan(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_Inequality(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_LessThan(FromOverloadedByArity, FromOverloadedByArity) : Boolean + static op_LessThanOrEqual(FromOverloadedByArity, FromOverloadedByArity) : Boolean + extension static FromOverloadedByArityExtensions.GetValue(FromOverloadedByArity) : Int32 + extension static FromOverloadedByArityExtensions.ValueOrNull(FromOverloadedByArity?) : Int32? +FromOverloadedByIn + CompareTo(FromOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByIn + static From(in Int32) : FromOverloadedByIn + static MaxValue : FromOverloadedByIn + static MinValue : FromOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByIn + static Parse(String, IFormatProvider) : FromOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByIn) : Boolean + static op_Equality(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_GreaterThan(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_Inequality(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_LessThan(FromOverloadedByIn, FromOverloadedByIn) : Boolean + static op_LessThanOrEqual(FromOverloadedByIn, FromOverloadedByIn) : Boolean + extension static FromOverloadedByInExtensions.GetValue(FromOverloadedByIn) : Int32 + extension static FromOverloadedByInExtensions.ValueOrNull(FromOverloadedByIn?) : Int32? +FromOverloadedByRef + CompareTo(FromOverloadedByRef) : Int32 + CompareTo(Object) : Int32 + Equals(FromOverloadedByRef) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested FromOverloadedByRefJsonConverter : JsonConverter, IStoredJsonConverterSource + nested FromOverloadedByRefTypeConverter : TypeConverter + new() + static From(Int32) : FromOverloadedByRef + static From(ref Int32) : FromOverloadedByRef + static MaxValue : FromOverloadedByRef + static MinValue : FromOverloadedByRef + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByRef + static Parse(ReadOnlySpan, IFormatProvider) : FromOverloadedByRef + static Parse(String, IFormatProvider) : FromOverloadedByRef + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByRef) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out FromOverloadedByRef) : Boolean + static TryParse(String, IFormatProvider, out FromOverloadedByRef) : Boolean + static op_Equality(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_GreaterThan(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_GreaterThanOrEqual(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_Inequality(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_LessThan(FromOverloadedByRef, FromOverloadedByRef) : Boolean + static op_LessThanOrEqual(FromOverloadedByRef, FromOverloadedByRef) : Boolean + extension static FromOverloadedByRefExtensions.GetValue(FromOverloadedByRef) : Int32 + extension static FromOverloadedByRefExtensions.ValueOrNull(FromOverloadedByRef?) : Int32? LocalOrder.Stage - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1850,7 +2115,7 @@ LocalOrder.Stage static TryParse(String, Boolean, out LocalOrder.Stage) : Boolean static TryParse(String, out LocalOrder.Stage) : Boolean LocalStatus - Match(Func, Func, Func) : TResult + Match(Func, Func, Func) : TResult Name : String QualifiedName : String ToString() : String @@ -1952,7 +2217,7 @@ MaskedOverALocalRecord extension static MaskedOverALocalRecordExtensions.GetValue(MaskedOverALocalRecord) : String extension static MaskedOverALocalRecordExtensions.ValueOrNull(MaskedOverALocalRecord) : String MaskedSet - Match(Func, Func) : TResult + Match(Func, Func) : TResult Name : String QualifiedName : String attribute CompilerGeneratedAttribute @@ -2084,6 +2349,61 @@ OverADeeperExplicitEqualityRecord static op_LessThanOrEqual(OverADeeperExplicitEqualityRecord, OverADeeperExplicitEqualityRecord) : Boolean extension static OverADeeperExplicitEqualityRecordExtensions.GetValue(OverADeeperExplicitEqualityRecord) : Int32 extension static OverADeeperExplicitEqualityRecordExtensions.ValueOrNull(OverADeeperExplicitEqualityRecord) : Int32? +OverALibraryOverloadingBase + $() : OverALibraryOverloadingBase + CompareTo(Object) : Int32 + CompareTo(OverALibraryOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(OverALibraryOverloadingBase) : Boolean + Equals(ProbeOverloadingRecord) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverALibraryOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverALibraryOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : OverALibraryOverloadingBase + static MaxValue : OverALibraryOverloadingBase + static MinValue : OverALibraryOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverALibraryOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverALibraryOverloadingBase + static Parse(String, IFormatProvider) : OverALibraryOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out OverALibraryOverloadingBase) : Boolean + static op_Equality(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_GreaterThan(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_GreaterThanOrEqual(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_Inequality(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_LessThan(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + static op_LessThanOrEqual(OverALibraryOverloadingBase, OverALibraryOverloadingBase) : Boolean + extension static OverALibraryOverloadingBaseExtensions.GetValue(OverALibraryOverloadingBase) : Int32 + extension static OverALibraryOverloadingBaseExtensions.ValueOrNull(OverALibraryOverloadingBase) : Int32? OverALibraryRecord $() : OverALibraryRecord CompareTo(Object) : Int32 @@ -2345,6 +2665,61 @@ OverAnIdentityRecord static op_LessThanOrEqual(OverAnIdentityRecord, OverAnIdentityRecord) : Boolean extension static OverAnIdentityRecordExtensions.GetValue(OverAnIdentityRecord) : Int32 extension static OverAnIdentityRecordExtensions.ValueOrNull(OverAnIdentityRecord) : Int32? +OverAnOverloadingBase + $() : OverAnOverloadingBase + CompareTo(Object) : Int32 + CompareTo(OverAnOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(OverAnOverloadingBase) : Boolean + Equals(OverloadingBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverAnOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverAnOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : OverAnOverloadingBase + static MaxValue : OverAnOverloadingBase + static MinValue : OverAnOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverAnOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : OverAnOverloadingBase + static Parse(String, IFormatProvider) : OverAnOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out OverAnOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverAnOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out OverAnOverloadingBase) : Boolean + static op_Equality(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_GreaterThan(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_GreaterThanOrEqual(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_Inequality(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_LessThan(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + static op_LessThanOrEqual(OverAnOverloadingBase, OverAnOverloadingBase) : Boolean + extension static OverAnOverloadingBaseExtensions.GetValue(OverAnOverloadingBase) : Int32 + extension static OverAnOverloadingBaseExtensions.ValueOrNull(OverAnOverloadingBase) : Int32? OverByte CompareTo(Object) : Int32 CompareTo(OverByte) : Int32 @@ -3274,6 +3649,29 @@ OverVersion static op_LessThanOrEqual(OverVersion, OverVersion) : Boolean extension static OverVersionExtensions.GetValue(OverVersion) : Version extension static OverVersionExtensions.ValueOrNull(OverVersion?) : Version +OverloadingSet + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested OverloadingSetJsonConverter : JsonConverter + nested OverloadingSetTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : OverloadingSet + static Parse(String, Boolean) : OverloadingSet + static Parse(String) : OverloadingSet + static Parse(String, Boolean) : OverloadingSet + static TryParse(String, Boolean, OverloadingSet) : Boolean + static TryParse(String, Boolean, out OverloadingSet) : Boolean + static TryParse(String, OverloadingSet) : Boolean + static TryParse(String, out OverloadingSet) : Boolean + static TryParse(String, out OverloadingSet) : Boolean ParityByte CompareTo(Object) : Int32 CompareTo(ParityByte) : Int32 @@ -3843,6 +4241,130 @@ ParityUShort static op_LessThanOrEqual(ParityUShort, ParityUShort) : Boolean extension static ParityUShortExtensions.GetValue(ParityUShort) : UInt16 extension static ParityUShortExtensions.ValueOrNull(ParityUShort?) : UInt16? +ParseOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(ParseOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(ParseOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ParseOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ParseOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : ParseOverloadedByArity + static MaxValue : ParseOverloadedByArity + static MinValue : ParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedByArity + static Parse(String, IFormatProvider) : ParseOverloadedByArity + static Parse(String, IFormatProvider) : ParseOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out ParseOverloadedByArity) : Boolean + static op_Equality(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_GreaterThan(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_Inequality(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_LessThan(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + static op_LessThanOrEqual(ParseOverloadedByArity, ParseOverloadedByArity) : Boolean + extension static ParseOverloadedByArityExtensions.GetValue(ParseOverloadedByArity) : Int32 + extension static ParseOverloadedByArityExtensions.ValueOrNull(ParseOverloadedByArity?) : Int32? +ParseOverloadedInARecordClass + $() : ParseOverloadedInARecordClass + CompareTo(Object) : Int32 + CompareTo(ParseOverloadedInARecordClass) : Int32 + Equals(Object) : Boolean + Equals(ParseOverloadedInARecordClass) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ParseOverloadedInARecordClassJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ParseOverloadedInARecordClassTypeConverter : TypeConverter + static From(Int32) : ParseOverloadedInARecordClass + static MaxValue : ParseOverloadedInARecordClass + static MinValue : ParseOverloadedInARecordClass + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(ReadOnlySpan, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(String, IFormatProvider) : ParseOverloadedInARecordClass + static Parse(String, IFormatProvider) : ParseOverloadedInARecordClass + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static TryParse(String, IFormatProvider, out ParseOverloadedInARecordClass) : Boolean + static op_Equality(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_GreaterThan(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_GreaterThanOrEqual(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_Inequality(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_LessThan(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + static op_LessThanOrEqual(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean + extension static ParseOverloadedInARecordClassExtensions.GetValue(ParseOverloadedInARecordClass) : Int32 + extension static ParseOverloadedInARecordClassExtensions.ValueOrNull(ParseOverloadedInARecordClass) : Int32? +SetOverAnOverloadingBase + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetOverAnOverloadingBaseJsonConverter : JsonConverter + nested SetOverAnOverloadingBaseTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetOverAnOverloadingBase + static Parse(String, Boolean) : SetOverAnOverloadingBase + static TryParse(String, Boolean, out SetOverAnOverloadingBase) : Boolean + static TryParse(String, out SetOverAnOverloadingBase) : Boolean ShippableStatus Equals(Object) : Boolean Equals(ShippableStatus) : Boolean @@ -3874,6 +4396,60 @@ ShippableStatus static op_Inequality(ShippableStatus, ShippableStatus) : Boolean extension static ShippableStatusExtensions.GetValue(ShippableStatus) : LocalStatus extension static ShippableStatusExtensions.ValueOrNull(ShippableStatus?) : LocalStatus +SpanParseOverloaded + CompareTo(Object) : Int32 + CompareTo(SpanParseOverloaded) : Int32 + Equals(Object) : Boolean + Equals(SpanParseOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested SpanParseOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested SpanParseOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : SpanParseOverloaded + static MaxValue : SpanParseOverloaded + static MinValue : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static Parse(String, IFormatProvider) : SpanParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : SpanParseOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out SpanParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, SpanParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out SpanParseOverloaded) : Boolean + static TryParse(String, IFormatProvider, out SpanParseOverloaded) : Boolean + static op_Equality(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_GreaterThan(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_GreaterThanOrEqual(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_Inequality(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_LessThan(SpanParseOverloaded, SpanParseOverloaded) : Boolean + static op_LessThanOrEqual(SpanParseOverloaded, SpanParseOverloaded) : Boolean + extension static SpanParseOverloadedExtensions.GetValue(SpanParseOverloaded) : Int32 + extension static SpanParseOverloadedExtensions.ValueOrNull(SpanParseOverloaded?) : Int32? StageOfOrder Equals(Object) : Boolean Equals(StageOfOrder) : Boolean @@ -4215,3 +4791,218 @@ ThrowingSku static op_Inequality(ThrowingSku, ThrowingSku) : Boolean extension static ThrowingSkuExtensions.GetValue(ThrowingSku) : ProbeSkuText extension static ThrowingSkuExtensions.ValueOrNull(ThrowingSku?) : ProbeSkuText +TryFormatOverloaded + CompareTo(Object) : Int32 + CompareTo(TryFormatOverloaded) : Int32 + Equals(Object) : Boolean + Equals(TryFormatOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryFormatOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryFormatOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : TryFormatOverloaded + static MaxValue : TryFormatOverloaded + static MinValue : TryFormatOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFormatOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFormatOverloaded + static Parse(String, IFormatProvider) : TryFormatOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out TryFormatOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryFormatOverloaded) : Boolean + static TryParse(String, IFormatProvider, out TryFormatOverloaded) : Boolean + static op_Equality(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_GreaterThan(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_GreaterThanOrEqual(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_Inequality(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_LessThan(TryFormatOverloaded, TryFormatOverloaded) : Boolean + static op_LessThanOrEqual(TryFormatOverloaded, TryFormatOverloaded) : Boolean + extension static TryFormatOverloadedExtensions.GetValue(TryFormatOverloaded) : Int32 + extension static TryFormatOverloadedExtensions.ValueOrNull(TryFormatOverloaded?) : Int32? +TryParseOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(TryParseOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(TryParseOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryParseOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryParseOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : TryParseOverloadedByArity + static MaxValue : TryParseOverloadedByArity + static MinValue : TryParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByArity + static Parse(String, IFormatProvider) : TryParseOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByArity) : Boolean + static op_Equality(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_GreaterThan(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_Inequality(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_LessThan(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + static op_LessThanOrEqual(TryParseOverloadedByArity, TryParseOverloadedByArity) : Boolean + extension static TryParseOverloadedByArityExtensions.GetValue(TryParseOverloadedByArity) : Int32 + extension static TryParseOverloadedByArityExtensions.ValueOrNull(TryParseOverloadedByArity?) : Int32? +TryParseOverloadedByValue + CompareTo(Object) : Int32 + CompareTo(TryParseOverloadedByValue) : Int32 + Equals(Object) : Boolean + Equals(TryParseOverloadedByValue) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested TryParseOverloadedByValueJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryParseOverloadedByValueTypeConverter : TypeConverter + new() + static From(Int32) : TryParseOverloadedByValue + static MaxValue : TryParseOverloadedByValue + static MinValue : TryParseOverloadedByValue + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByValue + static Parse(ReadOnlySpan, IFormatProvider) : TryParseOverloadedByValue + static Parse(String, IFormatProvider) : TryParseOverloadedByValue + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static TryParse(String, IFormatProvider, TryParseOverloadedByValue) : Boolean + static TryParse(String, IFormatProvider, out TryParseOverloadedByValue) : Boolean + static op_Equality(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_GreaterThan(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_GreaterThanOrEqual(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_Inequality(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_LessThan(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + static op_LessThanOrEqual(TryParseOverloadedByValue, TryParseOverloadedByValue) : Boolean + extension static TryParseOverloadedByValueExtensions.GetValue(TryParseOverloadedByValue) : Int32 + extension static TryParseOverloadedByValueExtensions.ValueOrNull(TryParseOverloadedByValue?) : Int32? +Utf8ParseOverloaded + CompareTo(Object) : Int32 + CompareTo(Utf8ParseOverloaded) : Int32 + Equals(Object) : Boolean + Equals(Utf8ParseOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested Utf8ParseOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested Utf8ParseOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : Utf8ParseOverloaded + static MaxValue : Utf8ParseOverloaded + static MinValue : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static Parse(String, IFormatProvider) : Utf8ParseOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : Utf8ParseOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, Utf8ParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static TryParse(String, IFormatProvider, out Utf8ParseOverloaded) : Boolean + static op_Equality(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_GreaterThan(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_GreaterThanOrEqual(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_Inequality(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_LessThan(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + static op_LessThanOrEqual(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean + extension static Utf8ParseOverloadedExtensions.GetValue(Utf8ParseOverloaded) : Int32 + extension static Utf8ParseOverloadedExtensions.ValueOrNull(Utf8ParseOverloaded?) : Int32? diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs index 391ccc8..cac93cc 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurfaceTests.cs @@ -104,7 +104,7 @@ private static IEnumerable Members(Type type) yield return $"{Static(property.GetMethod!)}{property.Name} : {Display(property.PropertyType)}"; foreach (var method in type.GetMethods(flags).Where(method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))) - yield return $"{Static(method)}{method.Name}({Parameters(method)}) : {Display(method.ReturnType)}"; + yield return $"{Static(method)}{method.Name}{TypeParameters(method)}({Parameters(method)}) : {Display(method.ReturnType)}"; } /// @@ -116,8 +116,18 @@ private static bool IsDeclared(CustomAttributeData attribute) => private static string Static(MethodBase method) => method.IsStatic ? "static " : ""; + /// + /// A generic method's type parameters, and each parameter's ref kind: a hand-written overload that differs from a + /// generated member only by these sits beside it (#226), and the two read as one line without them. + /// + private static string TypeParameters(MethodInfo method) => + method.IsGenericMethodDefinition ? $"<{string.Join(", ", method.GetGenericArguments().Select(Display))}>" : ""; + private static string Parameters(MethodBase method) => - string.Join(", ", method.GetParameters().Select(parameter => (parameter.IsOut ? "out " : "") + Display(parameter.ParameterType))); + string.Join(", ", method.GetParameters().Select(parameter => RefKind(parameter) + Display(parameter.ParameterType))); + + private static string RefKind(ParameterInfo parameter) => + !parameter.ParameterType.IsByRef ? "" : parameter.IsOut ? "out " : parameter.IsIn ? "in " : "ref "; private static string Display(Type type) { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs new file mode 100644 index 0000000..96ac9e8 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs @@ -0,0 +1,281 @@ +using System.Text; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.Generators.Probes; +using CodoMetis.TypeKit.ValueObjects; + +namespace CodoMetis.TypeKit.Generators.Tests; + +// What a value object or a closed set declares beside a generated member: a method of the same name and +// parameter types that C# still tells apart, by its type parameters or by a parameter's ref kind. Each was +// refused as the generated member written by hand (CMTK1011, #226); each is another method, and the generated +// one sits beside it. A ref kind against out (ref, in), and nullability, are no overload, and stay refused +// (BuildOutcomeTests). Every hand-written overload here throws, so a call that reached one fails. + +/// Picks a hand-written generic overload. +public sealed class OverloadFormat; + +/// A generic From. +public readonly partial record struct FromOverloadedByArity : IValue +{ + /// Hand-written. + public static FromOverloadedByArity From(int value) => throw new NotSupportedException("hand-written"); +} + +/// A From by in. +public readonly partial record struct FromOverloadedByIn : IValue +{ + /// Hand-written. + public static FromOverloadedByIn From(in int value) => throw new NotSupportedException("hand-written"); +} + +/// A From by ref. +public readonly partial record struct FromOverloadedByRef : IValue +{ + /// Hand-written. + public static FromOverloadedByRef From(ref int value) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse over text, the issue's scenario. +public readonly partial record struct ParseOverloadedByArity : IValue +{ + /// Hand-written. + public static ParseOverloadedByArity Parse(string text, IFormatProvider? provider) where TFormat : class => throw new NotSupportedException("hand-written"); +} + +/// A generic TryParse over text. +public readonly partial record struct TryParseOverloadedByArity : IValue +{ + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, out TryParseOverloadedByArity result) => throw new NotSupportedException("hand-written"); +} + +/// A TryParse over text whose last parameter is by value. +public readonly partial record struct TryParseOverloadedByValue : IValue +{ + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, TryParseOverloadedByValue fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse and a by-value TryParse over a span. +public readonly partial record struct SpanParseOverloaded : IValue +{ + /// Hand-written. + public static SpanParseOverloaded Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, SpanParseOverloaded fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic Parse and a by-value TryParse over UTF-8. +public readonly partial record struct Utf8ParseOverloaded : IValue +{ + /// Hand-written. + public static Utf8ParseOverloaded Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, Utf8ParseOverloaded fallback) => throw new NotSupportedException("hand-written"); +} + +/// A generic ToString(string?, IFormatProvider?). +public readonly partial record struct FormatOverloadedByArity : IValue +{ + /// Hand-written. + public string ToString(string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A ToString(string?, IFormatProvider?) whose format is by in. +public readonly partial record struct FormatOverloadedByIn : IValue +{ + /// Hand-written. + public string ToString(in string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A generic TryFormat, and one whose count is by value, for both the generated TryFormats. +public readonly partial record struct TryFormatOverloaded : IValue +{ + /// Hand-written. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A record class with a generic Parse. +public sealed partial record ParseOverloadedInARecordClass : IValue +{ + /// Hand-written. + public static ParseOverloadedInARecordClass Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// A base record in this assembly whose methods are overloads of the generated ones. +public abstract record OverloadingBase +{ + /// Hand-written. + public static TSelf From(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf From(in int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static TSelf Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, TSelf fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public string ToString(string? format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hand-written"); +} + +/// Over a base record in this assembly with an overload of each generated member. +public sealed partial record OverAnOverloadingBase : OverloadingBase, IValue; + +/// Over a base record in another assembly with overloads of Parse, From and TryParse. +public sealed partial record OverALibraryOverloadingBase : ProbeOverloadingRecord, IValue; + +/// A closed set with a generic Parse of each form, a generic TryParse, and by-value ones. +public sealed partial class OverloadingSet : IClosedSet +{ + /// The first member. + public static readonly OverloadingSet First = new(); + + /// The second member. + public static readonly OverloadingSet Second = new(); + + /// Hand-written. + public static OverloadingSet Parse(string name) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static OverloadingSet Parse(string name, bool ignoreCase) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, out OverloadingSet member) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, OverloadingSet fallback) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, bool ignoreCase, OverloadingSet fallback) => throw new NotSupportedException("hand-written"); +} + +/// A base class of a closed set whose Parse and TryParse are overloads of the generated ones. +public abstract class OverloadingSetBase +{ + /// Hand-written. + public static TSelf Parse(string name) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static bool TryParse(string? name, bool ignoreCase, TSelf fallback) => throw new NotSupportedException("hand-written"); +} + +/// A closed set over a base class with overloads of the generated Parse and TryParse. +public sealed partial class SetOverAnOverloadingBase : OverloadingSetBase, IClosedSet +{ + /// The first member. + public static readonly SetOverAnOverloadingBase First = new(); + + /// The second member. + public static readonly SetOverAnOverloadingBase Second = new(); +} + +/// +/// The generated members beside a hand-written overload of each, on the type, on a base record of this assembly +/// and of another, and on a closed set and its base class (#226). Refusals are build errors and live in +/// . +/// +public sealed class OverloadTests +{ + public static TheoryData, object> Generated => new() + { + { "From(int)", () => FromOverloadedByArity.From(7).Value, 7 }, + { "From(in int)", () => FromOverloadedByIn.From(7).Value, 7 }, + { "From(ref int)", () => FromOverloadedByRef.From(7).Value, 7 }, + { "Parse(string, IFormatProvider?)", () => ParseOverloadedByArity.Parse("7", null).Value, 7 }, + { "TryParse(string?, IFormatProvider?, out T)", () => TryParseOverloadedByArity.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "TryParse(string?, IFormatProvider?, T)", () => TryParseOverloadedByValue.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "Parse(ReadOnlySpan, IFormatProvider?)", () => SpanParseOverloaded.Parse("7".AsSpan(), null).Value, 7 }, + { "TryParse(ReadOnlySpan, IFormatProvider?, T)", () => SpanParseOverloaded.TryParse("7".AsSpan(), null, out var parsed) ? parsed.Value : -1, 7 }, + { "Parse(ReadOnlySpan, IFormatProvider?)", () => Utf8ParseOverloaded.Parse("7"u8, null).Value, 7 }, + { "TryParse(ReadOnlySpan, IFormatProvider?, T)", () => Utf8ParseOverloaded.TryParse("7"u8, null, out var parsed) ? parsed.Value : -1, 7 }, + { "IParsable.Parse beside Parse", () => ParseThroughInterface("7"), ParseOverloadedByArity.From(7) }, + { "ToString(string?, IFormatProvider?)", () => FormatOverloadedByArity.From(7).ToString("D3", null), "007" }, + { "ToString(in string?, IFormatProvider?)", () => FormatOverloadedByIn.From(7).ToString("D3", null), "007" }, + { "interpolation beside ToString(string?, IFormatProvider?)", () => $"{FormatOverloadedByArity.From(7):D3}", "007" }, + { "TryFormat(Span, out int, ...) and TryFormat(Span, int, ...)", () => Format(TryFormatOverloaded.From(7)), "007" }, + { "TryFormat(Span, int, ...)", () => FormatUtf8(TryFormatOverloaded.From(7)), "007" }, + { "a record class's Parse(string, IFormatProvider?)", () => ParseOverloadedInARecordClass.Parse("7", null).Value, 7 }, + { "a base record's From(int) and From(in int)", () => OverAnOverloadingBase.From(7).Value, 7 }, + { "a base record's Parse(string, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7", null).Value, 7 }, + { "a base record's TryParse(string?, IFormatProvider?, T)", () => OverAnOverloadingBase.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "a base record's Parse(ReadOnlySpan, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7".AsSpan(), null).Value, 7 }, + { "a base record's TryParse(ReadOnlySpan, IFormatProvider?, T)", () => OverAnOverloadingBase.TryParse("7"u8, null, out var parsed) ? parsed.Value : -1, 7 }, + { "a base record's ToString(string?, IFormatProvider?)", () => OverAnOverloadingBase.From(7).ToString("D3", null), "007" }, + { "a base record's TryFormat(Span, out int, ...)", () => Format(OverAnOverloadingBase.From(7)), "007" }, + { "another assembly's From(in int)", () => OverALibraryOverloadingBase.From(7).Value, 7 }, + { "another assembly's Parse(string, IFormatProvider?)", () => OverALibraryOverloadingBase.Parse("7", null).Value, 7 }, + { "another assembly's TryParse(string?, IFormatProvider?, T)", () => OverALibraryOverloadingBase.TryParse("7", null, out var parsed) ? parsed.Value : -1, 7 }, + { "a set's Parse(string)", () => OverloadingSet.Parse("Second"), OverloadingSet.Second }, + { "a set's Parse(string, bool)", () => OverloadingSet.Parse("second", ignoreCase: true), OverloadingSet.Second }, + { "a set's TryParse(string?, out T) and TryParse(string?, T)", () => OverloadingSet.TryParse("Second", out var member) ? member : OverloadingSet.First, OverloadingSet.Second }, + { "a set's TryParse(string?, bool, T)", () => OverloadingSet.TryParse("second", ignoreCase: true, out var member) ? member : OverloadingSet.First, OverloadingSet.Second }, + { "a set's IParsable.Parse beside Parse(string)", () => ParseThroughInterface("Second"), OverloadingSet.Second }, + { "a set's base class's Parse(string)", () => SetOverAnOverloadingBase.Parse("Second"), SetOverAnOverloadingBase.Second }, + { "a set's base class's TryParse(string?, bool, T)", () => SetOverAnOverloadingBase.TryParse("second", ignoreCase: true, out var member) ? member : SetOverAnOverloadingBase.First, SetOverAnOverloadingBase.Second }, + }; + + /// + /// Each generated member is there beside the hand-written overload, and a call that names neither type arguments + /// nor a ref kind reaches the generated one. + /// + [Theory] + [MemberData(nameof(Generated))] + public void The_generated_member_sits_beside_a_hand_written_overload(string overload, Func generated, object expected) => + generated().ShouldBe(expected, overload); + + public static TheoryData HandWritten => new() + { + { "From(int)", () => FromOverloadedByArity.From(7) }, + { "From(in int)", () => FromOverloadedByIn.From(in Seven) }, + { "Parse(string, IFormatProvider?)", () => ParseOverloadedByArity.Parse("7", null) }, + { "TryParse(string?, IFormatProvider?, T)", () => TryParseOverloadedByValue.TryParse("7", null, TryParseOverloadedByValue.From(1)) }, + { "ToString(string?, IFormatProvider?)", () => FormatOverloadedByArity.From(7).ToString("D3", null) }, + { "TryFormat(Span, int, ...)", () => TryFormatOverloaded.From(7).TryFormat(new char[8], 0, "", null) }, + { "a base record's Parse(string, IFormatProvider?)", () => OverAnOverloadingBase.Parse("7", null) }, + { "another assembly's From(in int)", () => OverALibraryOverloadingBase.From(in Seven) }, + { "a set's Parse(string)", () => OverloadingSet.Parse("Second") }, + { "a set's TryParse(string?, T)", () => OverloadingSet.TryParse("Second", OverloadingSet.First) }, + }; + + /// The hand-written overload is kept, and a call that names its type arguments or its ref kind reaches it. + [Theory] + [MemberData(nameof(HandWritten))] + public void The_hand_written_overload_is_kept(string overload, Action handWritten) => + Should.Throw(handWritten, overload).Message.ShouldBe("hand-written"); + + private static readonly int Seven = 7; + + private static T ParseThroughInterface(string text) where T : IParsable => T.Parse(text, null); + + private static string Format(T value) where T : ISpanFormattable + { + Span buffer = stackalloc char[16]; + return value.TryFormat(buffer, out var written, "D3", null) ? new string(buffer[..written]) : ""; + } + + private static string FormatUtf8(T value) where T : IUtf8SpanFormattable + { + Span buffer = stackalloc byte[16]; + return value.TryFormat(buffer, out var written, "D3", null) ? Encoding.UTF8.GetString(buffer[..written]) : ""; + } +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 7e87aad..7eb3e09 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1280,6 +1280,35 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-an-overload-in-a-closed-set", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "**An overload of a generated method with another signature**, such as `Parse(int rank)` or a generic `Parse(string name)`, is kept too.", + "claim": "A closed set's hand-written overload of Parse or TryParse with another signature, generic or by value where the generated one is out, is kept beside the generated one, in the set or its base class.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "OverloadTests", + "BuildOutcomeTests" + ] + }, + { + "id": "C22-an-overload-of-a-generated-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "C# tells two methods apart by their type parameters and by whether a parameter is `ref`, `in` or `out`, so a `Parse(string, IFormatProvider?)`, a `From(in int value)` or a `TryParse` whose last parameter isn't `out` is kept, on the value object or a base record. A `ref` or `in` where the generated member has `out` is the same method to C#, as a different nullability is, so it fails the build like the member itself.", + "claim": "A hand-written overload of a generated member that differs by its type parameters or by a ref kind against a by-value parameter is kept beside the generated member, on the value object or a base record; ref or in against out, or another nullability, fails the build with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "OverloadTests", + "BuildOutcomeTests" + ] + }, { "id": "C22-closed-set-named-as-generated-member", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From 27d7cc0708b8ed45a333473c0aa8aec6efa33db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:29:54 +0100 Subject: [PATCH 17/30] fix: CMTK0005 reports Array.CreateInstance, CreateInstanceFromArrayType and ArrayPool.Rent AnalyzeInvocation looked only at generic calls with one type argument, GC.AllocateArray, GC.AllocateUninitializedArray and Array.Resize. The README defines CMTK0005 as an array of a value object created with a length, which Array.CreateInstance(typeof(Plain), 3) in each overload, CreateInstanceFromArrayType(typeof(Plain[]), 3) and ArrayPool.Shared.Rent(3) are too; none was reported. The two factories are reported when a typeof names the element (the element of the array type for the second); a Type in a variable could be anything and stays silent. Rent is recognised on ArrayPool or an override of it, its T the element. The lengths are read from every int or long parameter and the elements of a params or written-out array of them, never the lower bounds, so #227's rule holds: a constant zero in any dimension stays silent. Input class (AIS): an expression that creates an array of a no-default struct, by the API that creates it. Every Array.CreateInstance and CreateInstanceFromArrayType overload that takes a length, Rent on the shared pool, on ArrayPool.Create() and on a derived pool, each with a zero-length control; a Type variable, GetType(), typeof(Plain?) and an array of another element silent. Rent's T and the typeof element run across TypeArgumentKinds (79 kinds; dynamic and string? take Rent alone, typeof refusing them): each reports exactly where GC.AllocateArray does. No sibling beyond #229's CollectionsMarshal.SetCount, which 2.4.0 takes. Reverting the source fails the forms row and the three kinds that report. Seeding the zero check away fails the three zero rows, reading the lower bounds as lengths fails the forms row, and skipping an array of lengths fails the silent row. Closes #228 Defect-Class: AIS Found-By: RS Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../DefaultFilledCollectionAnalyzer.cs | 92 ++++++++-- src/CodoMetis.TypeKit.Analyzers/README.md | 4 +- .../DefaultFilledCollectionAnalyzerTests.cs | 158 ++++++++++++++++++ test/oracles/claims.json | 14 ++ 5 files changed, 251 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aecf40b..b071c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ section is the GitHub release's notes. - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Analyzers: CMTK0005 now also warns at `Array.CreateInstance(typeof(OrderId), n)`, `Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)` and `ArrayPool.Rent(n)`, which create default slots as `new OrderId[n]` does. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). ### Fixed diff --git a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs index c984a14..1707b0b 100644 --- a/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/DefaultFilledCollectionAnalyzer.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; @@ -16,7 +17,11 @@ namespace CodoMetis.TypeKit.Analyzers; /// CMTK0001 sees default written out, and none of these forms writes it: new OrderId[n], /// stackalloc OrderId[n], GC.AllocateUninitializedArray<OrderId>(n), /// GC.AllocateArray and Array.Resize (measured 2026-09-28), which adds default slots -/// whenever the new size is larger, and the rule cannot tell whether it is. An array with initial +/// whenever the new size is larger, and the rule cannot tell whether it is. Also +/// Array.CreateInstance(typeof(OrderId), n), Array.CreateInstanceFromArrayType(typeof(OrderId[]), n) +/// and ArrayPool<OrderId>.Rent(n) (#228): the first two only where a typeof names the element, +/// since a Type variable could be anything, and a rented array is reported as a fresh one, all +/// default, though a reused one holds what it was returned with. An array with initial /// elements, a constant length or size of zero in any one dimension and a collection expression start /// with no default element and are not reported. A warning, since filling such an array in a loop /// straight after is correct code the rule cannot tell apart. @@ -52,9 +57,10 @@ public override void Initialize(AnalysisContext context) var gc = start.Compilation.GetTypeByMetadataName("System.GC"); var array = start.Compilation.GetTypeByMetadataName("System.Array"); + var pool = start.Compilation.GetTypeByMetadataName("System.Buffers.ArrayPool`1"); start.RegisterOperationAction(operation => AnalyzeArrayCreation(operation, symbols), OperationKind.ArrayCreation); - start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array), OperationKind.Invocation); + start.RegisterOperationAction(operation => AnalyzeInvocation(operation, symbols, gc, array, pool), OperationKind.Invocation); // IOperation has no public node for stackalloc; the syntax is read instead. start.RegisterSyntaxNodeAction(node => AnalyzeStackAlloc(node, symbols), SyntaxKind.StackAllocArrayCreationExpression); @@ -72,33 +78,85 @@ private static void AnalyzeArrayCreation(OperationAnalysisContext context, TypeK if (Diagnose(context.ContainingSymbol, creation.Syntax, "An array created with a length", element, symbols) is { } diagnostic) context.Report(diagnostic); } - private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array) + private static void AnalyzeInvocation(OperationAnalysisContext context, TypeKitSymbols symbols, INamedTypeSymbol? gc, INamedTypeSymbol? array, INamedTypeSymbol? arrayPool) { var invocation = (IInvocationOperation)context.Operation; var method = invocation.TargetMethod; - if (method.TypeArguments.Length != 1) return; + string? form = null; + ITypeSymbol? element = null; - string? form = null; - var size = -1; - - if (SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name)) + if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, gc) && AllocatingMethods.Contains(method.Name)) + { + // (int length, bool pinned = false) + form = $"GC.{method.Name}"; + element = method.TypeArguments[0]; + } + else if (method.TypeArguments.Length == 1 && SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize") + { + // (ref T[]? array, int newSize) + form = "Array.Resize"; + element = method.TypeArguments[0]; + } + else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name is "CreateInstance" or "CreateInstanceFromArrayType") { - form = $"GC.{method.Name}"; - size = 0; // (int length, bool pinned = false) + // (Type elementType or arrayType, the lengths: one to three ints, int[] or long[], then int[] lowerBounds or nothing). + // Only a typeof names the element; a Type held in a variable could be anything. + var type = invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == 0)?.Value; + while (type is IConversionOperation conversion) type = conversion.Operand; + + form = $"Array.{method.Name}"; + element = (type as ITypeOfOperation)?.TypeOperand; + if (method.Name == "CreateInstanceFromArrayType") element = (element as IArrayTypeSymbol)?.ElementType; } - else if (SymbolEqualityComparer.Default.Equals(method.ContainingType, array) && method.Name == "Resize") + else if (method.Name == "Rent" && PoolOf(method, arrayPool) is { } pool) { - form = "Array.Resize"; - size = 1; // (ref T[]? array, int newSize) + // (int minimumLength): a fresh array from the pool holds nothing but default slots. + form = "ArrayPool.Rent"; + element = pool.TypeArguments[0]; } - if (form is null) return; + if (form is null || element is null) return; + + // A length of zero in any dimension creates no slot, as new T[0] and new T[0, n] do not. + if (Lengths(invocation).Any(length => IsZero(length.ConstantValue))) return; + + if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, element, symbols) is { } diagnostic) context.Report(diagnostic); + } + + /// The ArrayPool<T> whose Rent is or overrides. + private static INamedTypeSymbol? PoolOf(IMethodSymbol method, INamedTypeSymbol? arrayPool) + { + for (var current = method; current is not null; current = current.OverriddenMethod) + { + if (SymbolEqualityComparer.Default.Equals(current.ContainingType.OriginalDefinition, arrayPool)) return current.ContainingType; + } - // A length of zero creates no slot, as new T[0] does not. - if (invocation.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == size) is { } length && IsZero(length.Value.ConstantValue)) return; + return null; + } - if (Diagnose(context.ContainingSymbol, invocation.Syntax, form, method.TypeArguments[0], symbols) is { } diagnostic) context.Report(diagnostic); + /// + /// The length arguments of an allocating call: every int or long after the element, and the + /// elements of a params or written-out array of lengths, but not the lower bounds. + /// + private static IEnumerable Lengths(IInvocationOperation invocation) + { + foreach (var argument in invocation.Arguments) + { + if (argument.Parameter is not { } parameter || parameter.Name == "lowerBounds" || parameter.RefKind != RefKind.None) continue; + + switch (parameter.Type) + { + case { SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 }: + yield return argument.Value; + break; + + case IArrayTypeSymbol { ElementType.SpecialType: SpecialType.System_Int32 or SpecialType.System_Int64 } + when argument.Value is IArrayCreationOperation { Initializer: { } lengths }: + foreach (var length in lengths.ElementValues) yield return length; + break; + } + } } private static void AnalyzeStackAlloc(SyntaxNodeAnalysisContext context, TypeKitSymbols symbols) diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index dc51499..7d445b3 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -302,7 +302,9 @@ OrderId[] kept = [.. rows.Select(r => OrderId.From(r.Id))]; // built from valu ``` `stackalloc`, `GC.AllocateUninitializedArray`, `GC.AllocateArray` and `Array.Resize` are reported -too. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A +too, and so are `Array.CreateInstance` and `Array.CreateInstanceFromArrayType` when a `typeof` names +the element, and `ArrayPool.Rent`. A `Type` held in a variable could be anything, so it isn't +reported. `Array.Resize` adds default slots whenever the new size is larger, which the rule can't tell. A constant length or size of zero creates no slot, as `new OrderId[0]` doesn't, and isn't reported. That holds for any one dimension: `new OrderId[0, count]` holds no element either. diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs index f9c91eb..7807f1b 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DefaultFilledCollectionAnalyzerTests.cs @@ -1,4 +1,7 @@ +using CodoMetis.TypeKit.Analyzers.Tests.Variants; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; namespace CodoMetis.TypeKit.Analyzers.Tests; @@ -9,6 +12,7 @@ public sealed class DefaultFilledCollectionAnalyzerTests private const string Subjects = """ using System; + using System.Buffers; using CodoMetis.TypeKit; using CodoMetis.TypeKit.ValueObjects; @@ -139,6 +143,160 @@ public static void Run(int n) Cmtk0005(1, "An array created with a length", "Plain")) .RunAsync(TestContext.Current.CancellationToken); + /// + /// The non-generic array factories and the pool create default slots as new Plain[n] does, and + /// none was reported (#228). Across the input class, an expression that creates an array of a value + /// object, by the API that creates it: each overload of Array.CreateInstance and + /// CreateInstanceFromArrayType, Rent on the shared pool, a created one and a derived one. + /// + [Fact] + public Task The_array_factories_and_the_pool_report() => + Test( + """ + public sealed class Pool : ArrayPool + { + public override Plain[] Rent(int minimumLength) => []; + public override void Return(Plain[] array, bool clearArray = false) { } + } + + public static class C + { + public static void Run(int n, int[] lengths, Pool pool) + { + var one = {|#0:Array.CreateInstance(typeof(Plain), n)|}; + var two = {|#1:Array.CreateInstance(typeof(Plain), 2, 2)|}; + var three = {|#2:Array.CreateInstance(typeof(Plain), 2, n, 2)|}; + var four = {|#3:Array.CreateInstance(typeof(Plain), 2, 2, 2, 2)|}; + var held = {|#4:Array.CreateInstance(typeof(Plain), lengths)|}; + var bounded = {|#5:Array.CreateInstance(typeof(Plain), new[] { 2 }, new[] { 0 })|}; + var wide = {|#6:Array.CreateInstance(typeof(Plain), 2L, 2L)|}; + var fromArray = {|#7:Array.CreateInstanceFromArrayType(typeof(Plain[]), n)|}; + var fromGrid = {|#8:Array.CreateInstanceFromArrayType(typeof(Plain[,]), 2, 2)|}; + var fromBounded = {|#9:Array.CreateInstanceFromArrayType(typeof(Plain[]), [2], [1])|}; + var shared = {|#10:ArrayPool.Shared.Rent(n)|}; + var created = {|#11:ArrayPool.Create().Rent(3)|}; + var derived = {|#12:pool.Rent(3)|}; + var options = {|#13:Array.CreateInstance(typeof(Option), n)|}; + } + + public static Array Generic(int n) where T : struct, IValue => {|#14:Array.CreateInstance(typeof(T), n)|}; + } + """, + Cmtk0005(0, "Array.CreateInstance", "Plain"), + Cmtk0005(1, "Array.CreateInstance", "Plain"), + Cmtk0005(2, "Array.CreateInstance", "Plain"), + Cmtk0005(3, "Array.CreateInstance", "Plain"), + Cmtk0005(4, "Array.CreateInstance", "Plain"), + Cmtk0005(5, "Array.CreateInstance", "Plain"), + Cmtk0005(6, "Array.CreateInstance", "Plain"), + Cmtk0005(7, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(8, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(9, "Array.CreateInstanceFromArrayType", "Plain"), + Cmtk0005(10, "ArrayPool.Rent", "Plain"), + Cmtk0005(11, "ArrayPool.Rent", "Plain"), + Cmtk0005(12, "ArrayPool.Rent", "Plain"), + Cmtk0005(13, "Array.CreateInstance", "Option"), + Cmtk0005(14, "Array.CreateInstance", "T")) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// A constant zero length in any dimension creates no slot, and a Type the rule cannot read names + /// no element: a variable, GetType(), a nullable value object, an array of another type. + /// + [Fact] + public Task The_array_factories_with_a_zero_length_or_an_unknown_type_stay_silent() => + Test( + """ + public static class C + { + public static void Run(int n, Type type, Plain a) + { + var one = Array.CreateInstance(typeof(Plain), 0); + var first = Array.CreateInstance(typeof(Plain), 0, n); + var second = Array.CreateInstance(typeof(Plain), n, 0); + var third = Array.CreateInstance(typeof(Plain), n, 2, 0); + var listed = Array.CreateInstance(typeof(Plain), 2, 0, 2, 2); + var bounded = Array.CreateInstance(typeof(Plain), new[] { 0 }, new[] { 5 }); + var wide = Array.CreateInstance(typeof(Plain), 2L, 0L); + var fromArray = Array.CreateInstanceFromArrayType(typeof(Plain[]), 0); + var fromGrid = Array.CreateInstanceFromArrayType(typeof(Plain[,]), n, 0); + var shared = ArrayPool.Shared.Rent(0); + var created = ArrayPool.Create().Rent(minimumLength: 0); + + var variable = Array.CreateInstance(type, n); + var runtime = Array.CreateInstance(a.GetType(), n); + var nullable = Array.CreateInstance(typeof(Plain?), n); + var numbers = Array.CreateInstance(typeof(int), n); + var jagged = Array.CreateInstance(typeof(Plain[]), n); + var other = Array.CreateInstanceFromArrayType(typeof(int[]), n); + var unknown = Array.CreateInstanceFromArrayType(type, n); + var pooled = ArrayPool.Shared.Rent(n); + var instance = Activator.CreateInstance(typeof(Plain)); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// ArrayPool<T>.Rent and the typeof factories judge their element as + /// GC.AllocateArray<T> does, across every kind of : each + /// reports exactly where the generic allocator does. typeof takes neither dynamic (CS1962) nor a nullable reference type + /// (CS8639), so those two kinds run Rent alone. + /// + [Theory] + [MemberData(nameof(Kinds))] + public async Task The_factories_judge_each_kind_as_the_generic_allocator_does(string kindName) + { + var kind = TypeArgumentKinds.All.Single(candidate => candidate.Name == kindName); + var typeOf = kind.Argument is not ("dynamic" or "string?"); + var source = + $$""" + #nullable enable + using System.Buffers; + + public static class Use{{kind.Parameters}} {{kind.Where}} + { + public static void M(int n) + { + _ = GC.AllocateArray<{{kind.Argument}}>(n); + _ = ArrayPool<{{kind.Argument}}>.Shared.Rent(n); + {{(typeOf ? $"_ = Array.CreateInstance(typeof({kind.Argument}), n);" : "")}} + {{(typeOf ? $"_ = Array.CreateInstance(typeof({kind.Argument}), n, n);" : "")}} + {{(typeOf ? $"_ = Array.CreateInstanceFromArrayType(typeof({kind.Argument}[]), n);" : "")}} + } + } + """; + + var forms = await ReportedForms(source); + + var reported = forms.Contains("GC.AllocateArray"); + if (kind is KindOfType { Is: var type } && type.HasFlag(Is.Struct) && type.HasFlag(Is.ValueObjectMarker)) reported.ShouldBeTrue("a value object struct is the positive control"); + + string[] factories = typeOf ? ["ArrayPool.Rent", "Array.CreateInstance", "Array.CreateInstance", "Array.CreateInstanceFromArrayType"] : ["ArrayPool.Rent"]; + forms.Where(form => form != "GC.AllocateArray").Order().ShouldBe(reported ? factories.Order() : []); + } + + public static TheoryData Kinds() => [.. TypeArgumentKinds.All.Where(kind => kind.Mentions.All(Declared)).Select(kind => kind.Name)]; + + private static bool Declared(string attribute) => RealTypeKit.Assembly.GetType($"CodoMetis.TypeKit.{attribute}Attribute") is not null; + + /// The form of each CMTK0005 the analyzer reports in , which must compile. + private static async Task ReportedForms(string source) + { + var cancellation = TestContext.Current.CancellationToken; + var references = await ReferenceAssemblies.Net.Net100.ResolveAsync(LanguageNames.CSharp, cancellation); + var compilation = CSharpCompilation.Create( + "Kinds", + [CSharpSyntaxTree.ParseText(source, cancellationToken: cancellation), CSharpSyntaxTree.ParseText(TypeArgumentKinds.Candidates, cancellationToken: cancellation)], + [.. references, RealTypeKit.Reference], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + compilation.GetDiagnostics(cancellation).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ShouldBeEmpty(); + + var diagnostics = await compilation.WithAnalyzers([new DefaultFilledCollectionAnalyzer()]).GetAnalyzerDiagnosticsAsync(cancellation); + return [.. diagnostics.Select(diagnostic => diagnostic.GetMessage().Split(" fills ")[0])]; + } + /// /// A zero of any integral type creates no slot: a dimension may be a long, uint or /// ulong, and a stackalloc size's constant is read before its conversion to int, diff --git a/test/oracles/claims.json b/test/oracles/claims.json index d8aa8c0..ca79ecb 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2432,6 +2432,20 @@ "ForbiddenDefaultInitializationAnalyzerTests" ] }, + { + "id": "K05-a-factory-given-a-typeof", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005", + "quote": "and so are `Array.CreateInstance` and `Array.CreateInstanceFromArrayType` when a `typeof` names the element, and `ArrayPool.Rent`. A `Type` held in a variable could be anything, so it isn't reported.", + "claim": "CMTK0005 reports Array.CreateInstance and Array.CreateInstanceFromArrayType given a typeof of a no-default type, and ArrayPool.Rent of one, and not a Type held in a variable.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "DefaultFilledCollectionAnalyzerTests" + ] + }, { "id": "K05-a-zero-dimension-is-silent", "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005", From b2931de6ff77597708729797eabce70bda75ce58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:32:57 +0100 Subject: [PATCH 18/30] fix: CMTK0006 counts an assignment through a cast of this, mapped to the class's implementation Assignments.Collect recorded a property assignment only on a bare IInstanceReferenceOperation and added the referenced property. A class reaches its own explicit interface implementation only through a cast, ((IHasId)this).Id = id, whose Instance is a conversion over this and whose property is the interface's, not the candidate. So a member every constructor assigned was reported, failing a warnings-as-errors build. Collect now strips conversions down to this, for fields and properties alike, and maps an interface's property to the analysed type's implementation (FindImplementationForInterfaceMember). Tuple elements and ref/out arguments go through the same Collect. A local holding this is not followed: IHasId self = this; self.Id = id stays reported, as the README now says. Input class (AIS): the forms that assign a member in a constructor. Plain and this. (silent before and after), a cast of this to an interface the member implements explicitly, implicitly, to the second of two it implements and to a generic one, a cast to the class itself for a field, deconstruction through the cast, and out through the cast: all seven were reported, the same cause, and are silent now. Two constructors where one assigns through the cast still name the other, and a cast of another instance assigns nothing. Reverting the source fails both rows; mapping nothing (casts stripped, the interface's property kept) fails both as well. Closes #231 Defect-Class: AIS Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + src/CodoMetis.TypeKit.Analyzers/README.md | 4 + .../UnassignedNoDefaultMemberAnalyzer.cs | 29 +++-- .../UnassignedNoDefaultMemberAnalyzerTests.cs | 110 ++++++++++++++++++ test/oracles/claims.json | 14 +++ 5 files changed, 153 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..ed17ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +### Fixed + +- Analyzers: CMTK0006 no longer reports an explicit interface implementation that every constructor assigns through a cast, `((IHasId)this).Id = id` ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index b78e309..473d5c1 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -325,6 +325,10 @@ object through it and then set the properties. `required` works with EF Core's c precompiled queries, and makes System.Text.Json reject a document without the property. A Blazor component's `[Parameter]` of a value-object type is reported too, and `required` works there. +An explicit interface implementation is assigned the only way a class can reach it, through a cast: +`((IHasId)this).Id = id` in every constructor keeps it quiet. A local that holds `this` isn't +followed, so `IHasId self = this; self.Id = id;` is still reported. + The message suggests `required` only where it compiles. That leaves out a get-only property or a `readonly` field (CS9034), and a member, or a setter, less visible than the class (CS9032). There the message says to initialize the member or assign it in every constructor. diff --git a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs index 5b7c9f2..fcc8a5b 100644 --- a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs @@ -33,7 +33,9 @@ namespace CodoMetis.TypeKit.Analyzers; /// serializers materialize through before they set the properties, which is how entities declare it. /// A record's copy constructor copies every member and is exempt too. A constructor that chains to /// another with this(…) assigns what that one assigns. An assignment on any path counts, so -/// the rule errs towards silence. +/// the rule errs towards silence. An assignment through a cast of this counts too, an interface's +/// member as the class's implementation of it: ((IHasId)this).Id = id is the only way a class +/// reaches its explicit implementation (#231). A local holding this is not followed. /// /// /// A required member is set by every object initializer, except behind a constructor marked @@ -281,11 +283,11 @@ public static Assignments Of(ImmutableArray blocks, INamedTypeSymbol switch (operation) { case IAssignmentOperation assignment: - Collect(assignment.Target, members); + Collect(assignment.Target, members, type); break; case IArgumentOperation { Parameter.RefKind: RefKind.Out or RefKind.Ref } argument: - Collect(argument.Value, members); + Collect(argument.Value, members, type); break; case IInvocationOperation { TargetMethod.MethodKind: MethodKind.Constructor, Instance: IInstanceReferenceOperation } chained @@ -326,22 +328,33 @@ private static bool Accepts(IMethodSymbol constructor, int count) => constructor.Parameters.Count(parameter => !parameter.IsOptional && !parameter.IsParams) <= count && (count <= constructor.Parameters.Length || constructor.Parameters.LastOrDefault()?.IsParams == true); - private static void Collect(IOperation target, HashSet members) + private static void Collect(IOperation target, HashSet members, INamedTypeSymbol type) { switch (target) { - case IFieldReferenceOperation { Instance: IInstanceReferenceOperation } field: + case IFieldReferenceOperation field when OnThis(field.Instance): members.Add(field.Field.AssociatedSymbol ?? field.Field); break; - case IPropertyReferenceOperation { Instance: IInstanceReferenceOperation } property: - members.Add(property.Property); + // ((IHasId)this).Id = id reaches the interface's member; the class holds its implementation. + case IPropertyReferenceOperation property when OnThis(property.Instance): + members.Add(property.Property.ContainingType.TypeKind == TypeKind.Interface + ? type.FindImplementationForInterfaceMember(property.Property) ?? property.Property + : property.Property); break; case ITupleOperation tuple: - foreach (var element in tuple.Elements) Collect(element, members); + foreach (var element in tuple.Elements) Collect(element, members, type); break; } } + + /// this, under any casts: a class reaches its explicit implementations only through one. + private static bool OnThis(IOperation? instance) + { + while (instance is IConversionOperation conversion) instance = conversion.Operand; + + return instance is IInstanceReferenceOperation; + } } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs index c87b6a4..af7e666 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs @@ -284,6 +284,116 @@ public struct InAStruct { public Plain Id { get; set; } } """) .RunAsync(TestContext.Current.CancellationToken); + private const string Interfaces = + """ + + public interface IHasId { Plain Id { get; set; } } + public interface IAlsoId { Plain Id { get; set; } } + public interface IHas { T Value { get; set; } } + + """; + + /// + /// A class reaches its own explicit interface implementation only through a cast of this, and + /// ((IHasId)this).Id = id was reported although every constructor assigns it (#231). Across the + /// input class, the forms that assign a member in a constructor: plain, this., a cast of + /// this to each interface the member implements (explicitly, implicitly, two at once, a generic + /// one) or to the class itself, deconstruction, and out. + /// + [Fact] + public Task A_member_assigned_through_a_cast_of_this_stays_silent() => + Test( + Interfaces + + """ + public sealed class Explicit : IHasId + { + public Explicit(Plain id) => ((IHasId)this).Id = id; + Plain IHasId.Id { get; set; } + } + + public sealed class Implicit : IHasId + { + public Implicit(Plain id) => ((IHasId)this).Id = id; + public Plain Id { get; set; } + } + + public sealed class Both : IHasId, IAlsoId + { + public Both(Plain id) => ((IAlsoId)this).Id = id; + public Plain Id { get; set; } + } + + public sealed class Generic : IHas + { + public Generic(Plain value) => ((IHas)this).Value = value; + Plain IHas.Value { get; set; } + } + + public sealed class Itself + { + private Plain _field; + public Itself(Plain id) + { + this.Id = id; + ((Itself)this)._field = id; + } + public Plain Id { get; } + } + + public sealed class Deconstructed : IHasId + { + public Deconstructed(Plain id, Plain name) => (((IHasId)this).Id, Name) = (id, name); + Plain IHasId.Id { get; set; } + public Plain Name { get; } + } + + public sealed class ThroughOut + { + private Plain _field; + public ThroughOut() => Init(out ((ThroughOut)this)._field); + private static void Init(out Plain value) => value = Plain.Make(); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// The cast maps to the member it reaches and to nothing else: a constructor that does not assign it is + /// still named, an assignment to another instance's member assigns none of this one's, and a local that + /// holds this is not followed, so IHasId self = this; self.Id = id; stays reported. + /// + [Fact] + public Task A_cast_assigns_only_the_member_it_reaches_on_this() => + Test( + Interfaces + + """ + public sealed class OneOfTwo : IHasId + { + public OneOfTwo(Plain id) => ((IHasId)this).Id = id; + public OneOfTwo() { } + Plain IHasId.{|#0:Id|} { get; set; } + } + + public sealed class Other : IHasId + { + public Other(Other other, Plain id) => ((IHasId)other).Id = id; + Plain IHasId.{|#1:Id|} { get; set; } + } + + public sealed class Aliased : IHasId + { + public Aliased(Plain id) + { + IHasId self = this; + self.Id = id; + } + Plain IHasId.{|#2:Id|} { get; set; } + } + """, + Cmtk0006(0, "IHasId.Id", "Plain", "the constructor 'OneOfTwo()'", AssignAdvice), + Cmtk0006(1, "IHasId.Id", "Plain", "the constructor 'Other(Other, Plain)'", AssignAdvice), + Cmtk0006(2, "IHasId.Id", "Plain", "the constructor 'Aliased(Plain)'", AssignAdvice)) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class S { public int N { get; set; } }", referenceTypeKit: false) diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..ebc55b4 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2432,6 +2432,20 @@ "ForbiddenDefaultInitializationAnalyzerTests" ] }, + { + "id": "K06-an-explicit-implementation-through-a-cast", + "source": "src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006", + "quote": "An explicit interface implementation is assigned the only way a class can reach it, through a cast: `((IHasId)this).Id = id` in every constructor keeps it quiet.", + "claim": "CMTK0006 counts an assignment through a cast of this, an interface's member as the class's implementation of it, and does not follow a local holding this.", + "oracle": "claim", + "surfaces": [ + "IValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "UnassignedNoDefaultMemberAnalyzerTests" + ] + }, { "id": "L01-read-as-the-underlying-type-reads", "source": "README.md#what-typekit-guarantees", From b699d74fb45e25f40390745bf8dfb0bdc837f7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:33:44 +0100 Subject: [PATCH 19/30] fix: a generic or by-reference overload of a seam or a synthesized member is another method The previous commit's catalogue, run over the other checks that find a hand-written member by name and parameters, found the same cause in seven of them: each read a method of any arity and any ref kind. Silent, measured with the check reverted: - IsHandWrittenToString took a generic ToString() for the seam. The value object printed "ToStringOverloadedByArity { Value = a }", what a record prints, and a closed set printed its type name. A base record's was CMTK1011 as an unsealed ToString(). - DeclaresCompareTo took a generic or by-in CompareTo(TSelf) for the ordering seam and marked the ordering [HandWrittenOrdering], which the EF Core satellite refuses to order by, though the ordering was generated. - ValueObjectContractAspect skipped re-implementing a deeper base's explicit IEquatable beside a generic Equals(TBase?), so a HashSet held two value objects holding 42. Refused (CMTK1011, CMTK1008), where the generated member sits beside it: a generic or by-in CompareTo(object), a base record's generic CompareTo, TryFrom(int) or TryFrom(in int) and Revalidate() (InheritedSeams), a generic Equals(TSelf) or GetHashCode() (EqualityDeclaredByHand), a base record's (InheritedEquality, CMTK1023), and a generic PrintMembers. Each check now reads only a method without type parameters whose parameters are all by value (IsByValueAndNotGeneric), as every generated member but TryParse and TryFormat is; those two are MethodsWithSignature's. Input class (GTS): as in the previous commit, over the seams (ToString(), CompareTo(TSelf), TryFrom, Revalidate) and what a record synthesizes or calls (Equals, GetHashCode, PrintMembers, CompareTo(object)), on the type, a base record, two levels up and in a closed set. 19 rows join OverloadTests' 34, 4 the hand-written ones, and 3 check the ordering is not marked hand-written. Reverting the commit refuses 5 of its types; reverting IsHandWrittenToString alone fails the 3 ToString rows, DeclaresCompareTo alone the 2 ordering rows, and the contract aspect's check alone the deeper-base row. Refs #226 Defect-Class: GTS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + design/aspects.md | 6 + src/CodoMetis.TypeKit.Generators/README.md | 2 + .../ValueObjectComparableAspect.cs | 5 +- .../ValueObjectContractAspect.cs | 2 +- .../ValueObjectDeclaration.cs | 19 +- .../GeneratedSurface.verified.txt | 550 ++++++++++++++++++ .../OverloadTests.cs | 170 ++++++ test/oracles/claims.json | 15 + 9 files changed, 762 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb41446..acdc62f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ section is the GitHub release's notes. - Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a generic `ToString()`, or a generic or by-`in` `CompareTo(TSelf)`, is no longer taken for the one you may write: a value object printed what a record prints (`Level { Value = 3 }`), a closed set its type name, and the ordering was marked hand-written, so EF Core refused to order by it. A generic or by-reference overload of `TryFrom`, `Revalidate`, `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` is kept beside the generated member, where the build failed with CMTK1011 or CMTK1008 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/aspects.md b/design/aspects.md index e4c4048..d03456c 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -190,6 +190,12 @@ and a `TryParse` whose last parameter is by value were CMTK1011, on the type, a base record and a closed set, though Metalama introduces the generated member beside each (measured 2026-10-31). `ref` or `in` against `out` is no overload in C# (CS0663), and nullability none at all, so both stay CMTK1011. + The other checks by name and parameters, the seams and what a record synthesizes, read only a method without + type parameters whose parameters are all by value (`ValueObjectDeclaration.IsByValueAndNotGeneric`), as every + generated member but `TryParse` and `TryFormat` is: a generic `ToString()` was taken for the seam, so + the value object printed what a record prints and a closed set its type name, a generic or by-`in` + `CompareTo(TSelf)` marked the ordering hand-written, and a generic or by-reference `TryFrom`, `Revalidate`, + `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` was CMTK1011 or CMTK1008. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and whatever the base declares. A base comparing by reference made two value objects holding 42 unequal diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 90bc280..53f439c 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -244,6 +244,8 @@ apart by their type parameters and by whether a parameter is `ref`, `in` or `out `Parse(string, IFormatProvider?)`, a `From(in int value)` or a `TryParse` whose last parameter isn't `out` is kept, on the value object or a base record. A `ref` or `in` where the generated member has `out` is the same method to C#, as a different nullability is, so it fails the build like the member itself. +The same goes for the members you may write: a generic `ToString()` or a `CompareTo(in TSelf)` isn't +the one that's kept, so the generated `ToString()` and ordering are still there. Two explicit implementations in an interface are fine, because a value object loses nothing by replacing them. A record's own `==` and `!=` replace an interface's, generated or not. And .NET's diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 4427a63..8f30166 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -143,7 +143,7 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe { foreach (var method in target.Methods) { - if (method.IsExplicitInterfaceImplementation) continue; + if (method.IsExplicitInterfaceImplementation || !ValueObjectDeclaration.IsByValueAndNotGeneric(method)) continue; var isObjectOverload = method is { Name: nameof(IComparable.CompareTo), Parameters: [{ Type.SpecialType: SpecialType.Object }] }; var isOrderingOperator = method.OperatorKind is OperatorKind.LessThan or OperatorKind.GreaterThan @@ -162,7 +162,7 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe foreach (var method in type.Methods) { if (method is { IsStatic: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo), Parameters: [{ } other] } - && ValueObjectDeclaration.IsHandWritten(method) + && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && ValueObjectDeclaration.IsHandWritten(method) && (other.Type.SpecialType == SpecialType.Object || other.Type.ToNonNullable().Equals(target))) yield return $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"; } @@ -176,6 +176,7 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe /// The seam: a CompareTo(TSelf) the type declares itself, which the generated one then leaves in place. private static bool DeclaresCompareTo(INamedType target) => target.Methods.Any(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo) } + && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && method.Parameters is [var other] && other.Type.Equals(target)); diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs index c5f440a..ab795e8 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectContractAspect.cs @@ -101,7 +101,7 @@ private static void IntroduceEquality(IAspectBuilder builder, INamed if (type.Equals(direct) || !ExplicitlyImplementsEquatableBetween(direct, type, equatable.MakeGenericInstance(type))) continue; // One the value object declares itself implements the interface already, as C# maps it. - if (builder.Target.Methods.Any(method => method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.ToNonNullable().Equals(type))) continue; + if (builder.Target.Methods.Any(method => method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.ToNonNullable().Equals(type) && ValueObjectDeclaration.IsByValueAndNotGeneric(method))) continue; var hidden = type; builder.IntroduceMethod( diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index da158d3..a240322 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -54,7 +54,7 @@ public static bool DeclaresToString(INamedType type) => } private static bool IsHandWrittenToString(IMethod method) => - method is { Name: nameof(ToString), IsStatic: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 } && IsHandWritten(method); + method is { Name: nameof(ToString), IsStatic: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 } && IsByValueAndNotGeneric(method) && IsHandWritten(method); /// /// A value object and the records it derives from, left out. A struct has @@ -439,7 +439,7 @@ public static IReadOnlyList InheritedSeams(INamedType target, INamedType kind != ValueObjectKind.Validated ? [] : [.. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods - .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation + .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation && IsByValueAndNotGeneric(method) && (method is { Name: "TryFrom", IsStatic: true, Parameters: [{ } value] } && value.Type.ToNonNullable().Equals(valueType.ToNonNullable()) || method is { Name: "Revalidate", IsStatic: false, Parameters.Count: 0 })) .Select(method => $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"))]; @@ -513,7 +513,7 @@ public static IReadOnlyList PrintMembersDeclaredByHand(INamedType target return [ .. SelfAndBases(target).SelectMany(type => type.Methods - .Where(method => method is { Name: "PrintMembers", IsStatic: false, Parameters: [{ } builder] } && builder.Type.Equals(stringBuilder) && IsHandWritten(method)) + .Where(method => method is { Name: "PrintMembers", IsStatic: false, Parameters: [{ } builder] } && builder.Type.Equals(stringBuilder) && IsByValueAndNotGeneric(method) && IsHandWritten(method)) .Select(method => Describe(method) + (type.Equals(target) ? "" : $" in its base type {type.ToDisplayString()}"))) ]; } @@ -540,7 +540,7 @@ public static IReadOnlyList EqualityDeclaredByHand(INamedType target) return [ .. target.Methods - .Where(method => method is { IsStatic: false, IsImplicitlyDeclared: false } + .Where(method => method is { IsStatic: false, IsImplicitlyDeclared: false } && IsByValueAndNotGeneric(method) && (method.IsExplicitInterfaceImplementation ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) : (method is { Name: nameof(Equals), Parameters: [{ } other] } && ownTypes.Any(type => other.Type.ToNonNullable().Equals(type))) @@ -568,7 +568,7 @@ public static IReadOnlyList InheritedEquality(INamedType target) return [ .. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods - .Where(method => method is { IsStatic: false } && IsHandWritten(method) + .Where(method => method is { IsStatic: false } && IsByValueAndNotGeneric(method) && IsHandWritten(method) && (method.IsExplicitInterfaceImplementation ? method.ExplicitInterfaceImplementations.Any(implemented => implemented.DeclaringType.Definition.Equals(equatable)) : (method is { Name: nameof(Equals), Parameters: [{ } other] } && other.Type.Equals(type)) @@ -649,6 +649,15 @@ internal static string Describe(IMember member) internal static bool IsHandWritten(IMember member) => !member.IsImplicitlyDeclared && !member.Attributes.Any(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute)))); + /// + /// A method without type parameters whose parameters are all by value, as every generated method is but + /// TryParse and TryFormat (): C# overloads by arity and by a + /// parameter's ref kind, so a generic CompareTo<T>(object?) or a TryFrom(in int) is another + /// method, which the generated one sits beside, and no seam (#226). + /// + internal static bool IsByValueAndNotGeneric(IMethod method) => + method.TypeParameters.Count == 0 && method.Parameters.All(parameter => parameter.RefKind == RefKind.None); + /// Non-implicit members of with one of , of any kind. private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index cb6623e..ec5946d 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1585,6 +1585,112 @@ ProbeWeekday extension static ProbeWeekdayExtensions.GetValue(ProbeWeekday) : DayOfWeek extension static ProbeWeekdayExtensions.ValueOrNull(ProbeWeekday?) : DayOfWeek? # CodoMetis.TypeKit.Generators.Tests +CompareToOverloadedByArity + CompareTo(CompareToOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + CompareTo(CompareToOverloadedByArity) : Int32 + Equals(CompareToOverloadedByArity) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested CompareToOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested CompareToOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : CompareToOverloadedByArity + static MaxValue : CompareToOverloadedByArity + static MinValue : CompareToOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByArity + static Parse(String, IFormatProvider) : CompareToOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out CompareToOverloadedByArity) : Boolean + static op_Equality(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_GreaterThan(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_Inequality(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_LessThan(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + static op_LessThanOrEqual(CompareToOverloadedByArity, CompareToOverloadedByArity) : Boolean + extension static CompareToOverloadedByArityExtensions.GetValue(CompareToOverloadedByArity) : Int32 + extension static CompareToOverloadedByArityExtensions.ValueOrNull(CompareToOverloadedByArity?) : Int32? +CompareToOverloadedByIn + CompareTo(CompareToOverloadedByIn) : Int32 + CompareTo(Object) : Int32 + CompareTo(in CompareToOverloadedByIn) : Int32 + Equals(CompareToOverloadedByIn) : Boolean + Equals(Object) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested CompareToOverloadedByInJsonConverter : JsonConverter, IStoredJsonConverterSource + nested CompareToOverloadedByInTypeConverter : TypeConverter + new() + static From(Int32) : CompareToOverloadedByIn + static MaxValue : CompareToOverloadedByIn + static MinValue : CompareToOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByIn + static Parse(ReadOnlySpan, IFormatProvider) : CompareToOverloadedByIn + static Parse(String, IFormatProvider) : CompareToOverloadedByIn + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static TryParse(String, IFormatProvider, out CompareToOverloadedByIn) : Boolean + static op_Equality(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_GreaterThan(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_GreaterThanOrEqual(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_Inequality(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_LessThan(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + static op_LessThanOrEqual(CompareToOverloadedByIn, CompareToOverloadedByIn) : Boolean + extension static CompareToOverloadedByInExtensions.GetValue(CompareToOverloadedByIn) : Int32 + extension static CompareToOverloadedByInExtensions.ValueOrNull(CompareToOverloadedByIn?) : Int32? DefaultedAddress Equals(DefaultedAddress) : Boolean Equals(Object) : Boolean @@ -1831,6 +1937,119 @@ EarlyTryParseStatus static Parse(String, Boolean) : EarlyTryParseStatus static TryParse(String, Boolean, out EarlyTryParseStatus) : Boolean static TryParse(String, out EarlyTryParseStatus) : Boolean +EqualityOverloadedByArity + CompareTo(EqualityOverloadedByArity) : Int32 + CompareTo(Object) : Int32 + Equals(EqualityOverloadedByArity) : Boolean + Equals(Object) : Boolean + Equals(EqualityOverloadedByArity) : Boolean + GetHashCode() : Int32 + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested EqualityOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested EqualityOverloadedByArityTypeConverter : TypeConverter + new() + static From(Int32) : EqualityOverloadedByArity + static MaxValue : EqualityOverloadedByArity + static MinValue : EqualityOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : EqualityOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : EqualityOverloadedByArity + static Parse(String, IFormatProvider) : EqualityOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out EqualityOverloadedByArity) : Boolean + static op_Equality(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_GreaterThan(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_Inequality(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_LessThan(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + static op_LessThanOrEqual(EqualityOverloadedByArity, EqualityOverloadedByArity) : Boolean + extension static EqualityOverloadedByArityExtensions.GetValue(EqualityOverloadedByArity) : Int32 + extension static EqualityOverloadedByArityExtensions.ValueOrNull(EqualityOverloadedByArity?) : Int32? +EqualsOverloadOverADeeperExplicitEquality + $() : EqualsOverloadOverADeeperExplicitEquality + CompareTo(EqualsOverloadOverADeeperExplicitEquality) : Int32 + CompareTo(Object) : Int32 + Equals(EqualsOverloadOverADeeperExplicitEquality) : Boolean + Equals(LocalBetweenRecord) : Boolean + Equals(LocalExplicitEqualityRecord) : Boolean + Equals(Object) : Boolean + Equals(LocalExplicitEqualityRecord) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable + implements IReimplementedEquatable + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested EqualsOverloadOverADeeperExplicitEqualityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested EqualsOverloadOverADeeperExplicitEqualityTypeConverter : TypeConverter + static From(Int32) : EqualsOverloadOverADeeperExplicitEquality + static MaxValue : EqualsOverloadOverADeeperExplicitEquality + static MinValue : EqualsOverloadOverADeeperExplicitEquality + static Parse(ReadOnlySpan, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static Parse(ReadOnlySpan, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static Parse(String, IFormatProvider) : EqualsOverloadOverADeeperExplicitEquality + static TryParse(ReadOnlySpan, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static TryParse(String, IFormatProvider, out EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_Equality(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_GreaterThan(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_GreaterThanOrEqual(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_Inequality(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_LessThan(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + static op_LessThanOrEqual(EqualsOverloadOverADeeperExplicitEquality, EqualsOverloadOverADeeperExplicitEquality) : Boolean + extension static EqualsOverloadOverADeeperExplicitEqualityExtensions.GetValue(EqualsOverloadOverADeeperExplicitEquality) : Int32 + extension static EqualsOverloadOverADeeperExplicitEqualityExtensions.ValueOrNull(EqualsOverloadOverADeeperExplicitEquality) : Int32? FormatOverloadedByArity CompareTo(FormatOverloadedByArity) : Int32 CompareTo(Object) : Int32 @@ -2233,6 +2452,60 @@ MaskedSet static Parse(String, Boolean) : MaskedSet static TryParse(String, Boolean, out MaskedSet) : Boolean static TryParse(String, out MaskedSet) : Boolean +ObjectCompareToOverloaded + CompareTo(Object) : Int32 + CompareTo(ObjectCompareToOverloaded) : Int32 + CompareTo(in Object) : Int32 + CompareTo(Object) : Int32 + Equals(Object) : Boolean + Equals(ObjectCompareToOverloaded) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ObjectCompareToOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ObjectCompareToOverloadedTypeConverter : TypeConverter + new() + static From(Int32) : ObjectCompareToOverloaded + static MaxValue : ObjectCompareToOverloaded + static MinValue : ObjectCompareToOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : ObjectCompareToOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : ObjectCompareToOverloaded + static Parse(String, IFormatProvider) : ObjectCompareToOverloaded + static TryParse(ReadOnlySpan, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static TryParse(String, IFormatProvider, out ObjectCompareToOverloaded) : Boolean + static op_Equality(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_GreaterThan(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_GreaterThanOrEqual(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_Inequality(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_LessThan(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + static op_LessThanOrEqual(ObjectCompareToOverloaded, ObjectCompareToOverloaded) : Boolean + extension static ObjectCompareToOverloadedExtensions.GetValue(ObjectCompareToOverloaded) : Int32 + extension static ObjectCompareToOverloadedExtensions.ValueOrNull(ObjectCompareToOverloaded?) : Int32? OverABetweenExplicitEqualityRecord $() : OverABetweenExplicitEqualityRecord CompareTo(Object) : Int32 @@ -4347,6 +4620,114 @@ ParseOverloadedInARecordClass static op_LessThanOrEqual(ParseOverloadedInARecordClass, ParseOverloadedInARecordClass) : Boolean extension static ParseOverloadedInARecordClassExtensions.GetValue(ParseOverloadedInARecordClass) : Int32 extension static ParseOverloadedInARecordClassExtensions.ValueOrNull(ParseOverloadedInARecordClass) : Int32? +PlainOverASeamOverloadingBase + $() : PlainOverASeamOverloadingBase + CompareTo(Object) : Int32 + CompareTo(PlainOverASeamOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(PlainOverASeamOverloadingBase) : Boolean + Equals(SeamOverloadingBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested PlainOverASeamOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested PlainOverASeamOverloadingBaseTypeConverter : TypeConverter + static From(Int32) : PlainOverASeamOverloadingBase + static MaxValue : PlainOverASeamOverloadingBase + static MinValue : PlainOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : PlainOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : PlainOverASeamOverloadingBase + static Parse(String, IFormatProvider) : PlainOverASeamOverloadingBase + static TryParse(ReadOnlySpan, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out PlainOverASeamOverloadingBase) : Boolean + static op_Equality(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_GreaterThan(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_GreaterThanOrEqual(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_Inequality(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_LessThan(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + static op_LessThanOrEqual(PlainOverASeamOverloadingBase, PlainOverASeamOverloadingBase) : Boolean + extension static PlainOverASeamOverloadingBaseExtensions.GetValue(PlainOverASeamOverloadingBase) : Int32 + extension static PlainOverASeamOverloadingBaseExtensions.ValueOrNull(PlainOverASeamOverloadingBase) : Int32? +PrintMembersOverloadedByArity + $() : PrintMembersOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(PrintMembersOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(PrintMembersOverloadedByArity) : Boolean + GetHashCode() : Int32 + PrintMembers(StringBuilder) : Boolean + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested PrintMembersOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested PrintMembersOverloadedByArityTypeConverter : TypeConverter + static From(Int32) : PrintMembersOverloadedByArity + static MaxValue : PrintMembersOverloadedByArity + static MinValue : PrintMembersOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : PrintMembersOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : PrintMembersOverloadedByArity + static Parse(String, IFormatProvider) : PrintMembersOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out PrintMembersOverloadedByArity) : Boolean + static op_Equality(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_GreaterThan(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_Inequality(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_LessThan(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + static op_LessThanOrEqual(PrintMembersOverloadedByArity, PrintMembersOverloadedByArity) : Boolean + extension static PrintMembersOverloadedByArityExtensions.GetValue(PrintMembersOverloadedByArity) : Int32 + extension static PrintMembersOverloadedByArityExtensions.ValueOrNull(PrintMembersOverloadedByArity) : Int32? SetOverAnOverloadingBase Match(Func, Func) : TResult Name : String @@ -4365,6 +4746,25 @@ SetOverAnOverloadingBase static Parse(String, Boolean) : SetOverAnOverloadingBase static TryParse(String, Boolean, out SetOverAnOverloadingBase) : Boolean static TryParse(String, out SetOverAnOverloadingBase) : Boolean +SetWithAGenericToString + Match(Func) : TResult + Name : String + QualifiedName : String + ToString() : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetWithAGenericToStringJsonConverter : JsonConverter + nested SetWithAGenericToStringTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetWithAGenericToString + static Parse(String, Boolean) : SetWithAGenericToString + static TryParse(String, Boolean, out SetWithAGenericToString) : Boolean + static TryParse(String, out SetWithAGenericToString) : Boolean ShippableStatus Equals(Object) : Boolean Equals(ShippableStatus) : Boolean @@ -4791,6 +5191,49 @@ ThrowingSku static op_Inequality(ThrowingSku, ThrowingSku) : Boolean extension static ThrowingSkuExtensions.GetValue(ThrowingSku) : ProbeSkuText extension static ThrowingSkuExtensions.ValueOrNull(ThrowingSku?) : ProbeSkuText +ToStringOverloadedByArity + CompareTo(Object) : Int32 + CompareTo(ToStringOverloadedByArity) : Int32 + Equals(Object) : Boolean + Equals(ToStringOverloadedByArity) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + ToString() : String + Value : String + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements IPlainValueObject + implements ISpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested ToStringOverloadedByArityJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ToStringOverloadedByArityTypeConverter : TypeConverter + new() + static From(String) : ToStringOverloadedByArity + static Parse(ReadOnlySpan, IFormatProvider) : ToStringOverloadedByArity + static Parse(String, IFormatProvider) : ToStringOverloadedByArity + static TryParse(ReadOnlySpan, IFormatProvider, out ToStringOverloadedByArity) : Boolean + static TryParse(String, IFormatProvider, out ToStringOverloadedByArity) : Boolean + static op_Equality(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_GreaterThan(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_GreaterThanOrEqual(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_Inequality(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_LessThan(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + static op_LessThanOrEqual(ToStringOverloadedByArity, ToStringOverloadedByArity) : Boolean + extension static ToStringOverloadedByArityExtensions.GetValue(ToStringOverloadedByArity) : String + extension static ToStringOverloadedByArityExtensions.ValueOrNull(ToStringOverloadedByArity?) : String TryFormatOverloaded CompareTo(Object) : Int32 CompareTo(TryFormatOverloaded) : Int32 @@ -4846,6 +5289,59 @@ TryFormatOverloaded static op_LessThanOrEqual(TryFormatOverloaded, TryFormatOverloaded) : Boolean extension static TryFormatOverloadedExtensions.GetValue(TryFormatOverloaded) : Int32 extension static TryFormatOverloadedExtensions.ValueOrNull(TryFormatOverloaded?) : Int32? +TryFromOverloaded + CompareTo(Object) : Int32 + CompareTo(TryFromOverloaded) : Int32 + Equals(Object) : Boolean + Equals(TryFromOverloaded) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IFormattable + implements IParsable + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested TryFromOverloadedJsonConverter : JsonConverter, IStoredJsonConverterSource + nested TryFromOverloadedTypeConverter : TypeConverter + new() + static Create(Int32) : Result + static FromKnownGood(Int32, String) : TryFromOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFromOverloaded + static Parse(ReadOnlySpan, IFormatProvider) : TryFromOverloaded + static Parse(String, IFormatProvider) : TryFromOverloaded + static TryFrom(Int32) : Option + static TryFrom(in Int32) : Option + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out TryFromOverloaded) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out TryFromOverloaded) : Boolean + static TryParse(String, IFormatProvider, out TryFromOverloaded) : Boolean + static op_Equality(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_GreaterThan(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_GreaterThanOrEqual(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_Inequality(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_LessThan(TryFromOverloaded, TryFromOverloaded) : Boolean + static op_LessThanOrEqual(TryFromOverloaded, TryFromOverloaded) : Boolean + extension static TryFromOverloadedExtensions.GetValue(TryFromOverloaded) : Int32 + extension static TryFromOverloadedExtensions.ValueOrNull(TryFromOverloaded?) : Int32? TryParseOverloadedByArity CompareTo(Object) : Int32 CompareTo(TryParseOverloadedByArity) : Int32 @@ -5006,3 +5502,57 @@ Utf8ParseOverloaded static op_LessThanOrEqual(Utf8ParseOverloaded, Utf8ParseOverloaded) : Boolean extension static Utf8ParseOverloadedExtensions.GetValue(Utf8ParseOverloaded) : Int32 extension static Utf8ParseOverloadedExtensions.ValueOrNull(Utf8ParseOverloaded?) : Int32? +ValidatedOverASeamOverloadingBase + $() : ValidatedOverASeamOverloadingBase + CompareTo(Object) : Int32 + CompareTo(ValidatedOverASeamOverloadingBase) : Int32 + Equals(Object) : Boolean + Equals(SeamOverloadingBase) : Boolean + Equals(ValidatedOverASeamOverloadingBase) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable> + implements IEquatable + implements IFormattable + implements IParsable + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested ValidatedOverASeamOverloadingBaseJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ValidatedOverASeamOverloadingBaseTypeConverter : TypeConverter + static Create(Int32) : Result + static FromKnownGood(Int32, String) : ValidatedOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverASeamOverloadingBase + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverASeamOverloadingBase + static Parse(String, IFormatProvider) : ValidatedOverASeamOverloadingBase + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static TryParse(String, IFormatProvider, out ValidatedOverASeamOverloadingBase) : Boolean + static op_Equality(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_GreaterThan(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_GreaterThanOrEqual(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_Inequality(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_LessThan(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + static op_LessThanOrEqual(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean + extension static ValidatedOverASeamOverloadingBaseExtensions.GetValue(ValidatedOverASeamOverloadingBase) : Int32 + extension static ValidatedOverASeamOverloadingBaseExtensions.ValueOrNull(ValidatedOverASeamOverloadingBase) : Int32? diff --git a/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs index 96ac9e8..29b6cce 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/OverloadTests.cs @@ -189,6 +189,140 @@ public sealed partial class SetOverAnOverloadingBase : OverloadingSetBaseA fault for the validated value objects below. +public enum OverloadFault +{ + /// Below zero. + Negative +} + +/// A generic ToString(), which is no seam. +public readonly partial record struct ToStringOverloadedByArity : IValue +{ + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); +} + +/// A generic CompareTo(TSelf), which is no seam. +public readonly partial record struct CompareToOverloadedByArity : IValue +{ + /// Hand-written. + public int CompareTo(CompareToOverloadedByArity other) => throw new NotSupportedException("hand-written"); +} + +/// A CompareTo(TSelf) by in, which is no seam. +public readonly partial record struct CompareToOverloadedByIn : IValue +{ + /// Hand-written. + public int CompareTo(in CompareToOverloadedByIn other) => throw new NotSupportedException("hand-written"); +} + +/// A generic CompareTo(object?) and one by in. +public readonly partial record struct ObjectCompareToOverloaded : IValue +{ + /// Hand-written. + public int CompareTo(object? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(in object? other) => throw new NotSupportedException("hand-written"); +} + +/// A generic Equals(TSelf) and GetHashCode(), which are not its equality. +public readonly partial record struct EqualityOverloadedByArity : IValue +{ + /// Hand-written. + public bool Equals(EqualityOverloadedByArity other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int GetHashCode() => throw new NotSupportedException("hand-written"); +} + +/// A record class with a generic PrintMembers, which its synthesized ToString() never calls. +public sealed partial record PrintMembersOverloadedByArity : IValue +{ + /// Hand-written. + public bool PrintMembers(StringBuilder builder) => throw new NotSupportedException("hand-written"); +} + +/// A generic TryFrom and one by in, beside the generated TryFrom(int). +public readonly partial record struct TryFromOverloaded : IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(OverloadFault.Negative) : new TryFromOverloaded(value); + + /// Hand-written. + public static Option TryFrom(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static Option TryFrom(in int value) => throw new NotSupportedException("hand-written"); +} + +/// A base record whose seams, equality and ToString() are overloads of the generated ones. +public abstract record SeamOverloadingBase where TSelf : notnull +{ + /// Hand-written. + public static Option TryFrom(int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public static Option TryFrom(in int value) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public Result Revalidate() => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(object? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int CompareTo(TSelf? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public bool Equals(SeamOverloadingBase? other) => throw new NotSupportedException("hand-written"); + + /// Hand-written. + public int GetHashCode() => throw new NotSupportedException("hand-written"); +} + +/// A plain value object over a base record whose seams are overloads. +public sealed partial record PlainOverASeamOverloadingBase : SeamOverloadingBase, IValue; + +/// A validated value object over a base record whose TryFrom and Revalidate are overloads. +public sealed partial record ValidatedOverASeamOverloadingBase : SeamOverloadingBase, IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(OverloadFault.Negative) : new ValidatedOverASeamOverloadingBase(value); +} + +// CMTK1023 names the base's equality, which the generated one does not call. +#pragma warning disable CMTK1023 +/// +/// Two levels above an explicit IEquatable by reference, with a generic Equals of that base's type: the +/// value object implements the base's interface again all the same, since the generic one implements nothing. +/// +public sealed partial record EqualsOverloadOverADeeperExplicitEquality : LocalBetweenRecord, IValue +{ + /// Hand-written. + public bool Equals(LocalExplicitEqualityRecord? other) => throw new NotSupportedException("hand-written"); +} +#pragma warning restore CMTK1023 + +/// A closed set with a generic ToString(), which is no seam. +public sealed partial class SetWithAGenericToString : IClosedSet +{ + /// The first member. + public static readonly SetWithAGenericToString First = new(); + + /// Hand-written. + public string ToString() => throw new NotSupportedException("hand-written"); +} + /// /// The generated members beside a hand-written overload of each, on the type, on a base record of this assembly /// and of another, and on a closed set and its base class (#226). Refusals are build errors and live in @@ -232,6 +366,25 @@ public sealed class OverloadTests { "a set's IParsable.Parse beside Parse(string)", () => ParseThroughInterface("Second"), OverloadingSet.Second }, { "a set's base class's Parse(string)", () => SetOverAnOverloadingBase.Parse("Second"), SetOverAnOverloadingBase.Second }, { "a set's base class's TryParse(string?, bool, T)", () => SetOverAnOverloadingBase.TryParse("second", ignoreCase: true, out var member) ? member : SetOverAnOverloadingBase.First, SetOverAnOverloadingBase.Second }, + { "ToString() beside ToString()", () => ToStringOverloadedByArity.From("a").ToString(), "a" }, + { "interpolation beside ToString()", () => $"{ToStringOverloadedByArity.From("a")}", "a" }, + { "CompareTo(T) beside CompareTo(T)", () => Math.Sign(CompareToOverloadedByArity.From(1).CompareTo(CompareToOverloadedByArity.From(2))), -1 }, + { "IComparable beside CompareTo(T)", () => Math.Sign(((IComparable)CompareToOverloadedByArity.From(1)).CompareTo(CompareToOverloadedByArity.From(2))), -1 }, + { "CompareTo(T) beside CompareTo(in T)", () => Math.Sign(CompareToOverloadedByIn.From(1).CompareTo(CompareToOverloadedByIn.From(2))), -1 }, + { "IComparable beside CompareTo(in T)", () => Math.Sign(((IComparable)CompareToOverloadedByIn.From(1)).CompareTo(CompareToOverloadedByIn.From(2))), -1 }, + { "CompareTo(object) beside CompareTo(object?) and CompareTo(in object?)", () => Math.Sign(((IComparable)ObjectCompareToOverloaded.From(1)).CompareTo(ObjectCompareToOverloaded.From(2))), -1 }, + { "< beside CompareTo(object?)", () => ObjectCompareToOverloaded.From(1) < ObjectCompareToOverloaded.From(2), true }, + { "Equals(T) beside Equals(T)", () => EqualityOverloadedByArity.From(1).Equals(EqualityOverloadedByArity.From(1)), true }, + { "GetHashCode() beside GetHashCode()", () => EqualityOverloadedByArity.From(1).GetHashCode() == EqualityOverloadedByArity.From(1).GetHashCode(), true }, + { "ToString() beside PrintMembers(StringBuilder)", () => PrintMembersOverloadedByArity.From(1).ToString(), "1" }, + { "TryFrom(int) beside TryFrom(int) and TryFrom(in int)", () => TryFromOverloaded.TryFrom(1).IsSome() && TryFromOverloaded.TryFrom(-1).IsNone(), true }, + { "a base record's TryFrom(int) and TryFrom(in int)", () => ValidatedOverASeamOverloadingBase.TryFrom(1).IsSome() && ValidatedOverASeamOverloadingBase.TryFrom(-1).IsNone(), true }, + { "a base record's Revalidate()", () => ValidatedOverASeamOverloadingBase.FromKnownGood(1).Revalidate().TryGetValue(out _, out _), true }, + { "a base record's CompareTo(object?) and CompareTo(TSelf?)", () => PlainOverASeamOverloadingBase.From(1) < PlainOverASeamOverloadingBase.From(2), true }, + { "a base record's ToString()", () => PlainOverASeamOverloadingBase.From(1).ToString(), "1" }, + { "a base record's Equals(TBase?) and GetHashCode()", () => new HashSet> { PlainOverASeamOverloadingBase.From(1), PlainOverASeamOverloadingBase.From(1) }.Count, 1 }, + { "a deeper base's IEquatable beside Equals(TBase?)", () => new HashSet { EqualsOverloadOverADeeperExplicitEquality.From(42), EqualsOverloadOverADeeperExplicitEquality.From(42) }.Count, 1 }, + { "a set's ToString() beside ToString()", () => $"{SetWithAGenericToString.First}", "First" }, }; /// @@ -255,6 +408,10 @@ public void The_generated_member_sits_beside_a_hand_written_overload(string over { "another assembly's From(in int)", () => OverALibraryOverloadingBase.From(in Seven) }, { "a set's Parse(string)", () => OverloadingSet.Parse("Second") }, { "a set's TryParse(string?, T)", () => OverloadingSet.TryParse("Second", OverloadingSet.First) }, + { "ToString()", () => ToStringOverloadedByArity.From("a").ToString() }, + { "CompareTo(in T)", () => CompareToOverloadedByIn.From(1).CompareTo(in OneByIn) }, + { "TryFrom(int)", () => _ = TryFromOverloaded.TryFrom(1) }, + { "a set's ToString()", () => SetWithAGenericToString.First.ToString() }, }; /// The hand-written overload is kept, and a call that names its type arguments or its ref kind reaches it. @@ -263,8 +420,21 @@ public void The_generated_member_sits_beside_a_hand_written_overload(string over public void The_hand_written_overload_is_kept(string overload, Action handWritten) => Should.Throw(handWritten, overload).Message.ShouldBe("hand-written"); + public static TheoryData NotHandWrittenOrdering => [typeof(CompareToOverloadedByArity), typeof(CompareToOverloadedByIn), typeof(PlainOverASeamOverloadingBase)]; + + /// + /// A generic or by-in CompareTo(TSelf) is no seam, so the ordering is the generated one and is not marked + /// hand-written: the EF Core satellite refuses to order by a column whose value object is marked. + /// + [Theory] + [MemberData(nameof(NotHandWrittenOrdering))] + public void An_overload_of_CompareTo_does_not_mark_the_ordering_hand_written(Type type) => + type.IsDefined(typeof(CodoMetis.TypeKit.CompilerServices.HandWrittenOrderingAttribute), inherit: false).ShouldBeFalse(); + private static readonly int Seven = 7; + private static readonly CompareToOverloadedByIn OneByIn = CompareToOverloadedByIn.From(1); + private static T ParseThroughInterface(string text) where T : IParsable => T.Parse(text, null); private static string Format(T value) where T : ISpanFormattable diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 7eb3e09..119cf03 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1309,6 +1309,21 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-an-overload-of-a-seam", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "The same goes for the members you may write: a generic `ToString()` or a `CompareTo(in TSelf)` isn't the one that's kept, so the generated `ToString()` and ordering are still there.", + "claim": "A generic or by-reference overload of ToString() or CompareTo(TSelf) is no seam: the generated ToString() and ordering are introduced beside it, and the ordering is not marked hand-written.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*", + "IClosedSet.*" + ], + "heldBy": [ + "OverloadTests" + ] + }, { "id": "C22-closed-set-named-as-generated-member", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors", From 8bae0377e96a95d3dbee2eaa7f387fda672d90ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:33:46 +0100 Subject: [PATCH 20/30] fix: CMTK0006 no longer counts an initializer of another object as assigning this one Collect took any IInstanceReferenceOperation as this. An object initializer's receiver and a with expression's are instance references too, of the object they create. So a constructor whose only assignment was _ = new Twin(0) { Id = x } counted as assigning its own Id, and the member nothing set on this instance was not reported. Only the containing type's instance counts now. Found running #231's input class (the forms that assign a member in a constructor) across what the receiver is: this, a cast of it, another instance, a local alias, an object initializer and a with. The last two were silent; both report now. Reverting the source fails the row: 0 reports where 2 are expected. Refs #231 Defect-Class: AIS Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../UnassignedNoDefaultMemberAnalyzer.cs | 7 +++-- .../UnassignedNoDefaultMemberAnalyzerTests.cs | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed17ff2..5e1e3cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ section is the GitHub release's notes. - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Analyzers: CMTK0006 now warns when a constructor sets a member only on another object it creates, `new Order(...) { Id = id }` or `other with { Id = id }`, which it took as setting its own. In a build that treats warnings as errors, that can report code that built before ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). ### Fixed diff --git a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs index fcc8a5b..d510591 100644 --- a/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/UnassignedNoDefaultMemberAnalyzer.cs @@ -349,12 +349,15 @@ private static void Collect(IOperation target, HashSet members, INamedT } } - /// this, under any casts: a class reaches its explicit implementations only through one. + /// + /// this, under any casts: a class reaches its explicit implementations only through one. An object + /// initializer's receiver is an instance reference too, of the object it creates, not this one. + /// private static bool OnThis(IOperation? instance) { while (instance is IConversionOperation conversion) instance = conversion.Operand; - return instance is IInstanceReferenceOperation; + return instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance }; } } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs index af7e666..0eb1f96 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/UnassignedNoDefaultMemberAnalyzerTests.cs @@ -394,6 +394,32 @@ public Aliased(Plain id) Cmtk0006(2, "IHasId.Id", "Plain", "the constructor 'Aliased(Plain)'", AssignAdvice)) .RunAsync(TestContext.Current.CancellationToken); + /// + /// An object initializer or a with inside a constructor sets the new object's member, not this + /// one's: its receiver is an implicit instance reference, as this is, and counted as assigning. + /// + [Fact] + public Task An_initializer_of_another_instance_assigns_nothing_on_this() => + Test( + """ + public sealed class Twin + { + public Twin() => _ = new Twin(0) { Id = Plain.Make() }; + public Twin(int _) { Id = Plain.Make(); } + public Plain {|#0:Id|} { get; set; } + } + + public sealed record Copied + { + public Copied() => _ = new Copied(0) with { Id = Plain.Make() }; + public Copied(int _) { Id = Plain.Make(); } + public Plain {|#1:Id|} { get; init; } + } + """, + Cmtk0006(0, "Id", "Plain", "the constructor 'Twin()'"), + Cmtk0006(1, "Id", "Plain", "the constructor 'Copied()'")) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public Task A_compilation_without_TypeKit_is_left_alone() => new AnalyzerTest("public sealed class S { public int N { get; set; } }", referenceTypeKit: false) From 2ec8b721d5e8bb4bedd3018f7606b464be51957e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:36:03 +0100 Subject: [PATCH 21/30] fix: describe each element of a list or a dictionary of patches as a patch property ASP.NET leaves out items and additionalProperties for a converter-backed element, and PatchSchemaTransformer acted only on a Patch itself. So a Dictionary> was {"type":"object"} and a List> {"type":"array"}, admitting anything, in 3.1, 3.0 and 2.0, while a Patch property was its argument's schema admitting null. The container branch now puts the patch's own schema back where ASP.NET left it out, asked for through the context, so it is filled as a property is: a described or replaced argument, a value object through its wrapped type, 3.0's nullable form. Input class: where a Patch sits. Catalogue: PatchSchemaTests' arguments (12 kinds, now 14 with Lines[] and Dictionary, a list of a component and a dictionary) in 6 positions (property, list, array, Dictionary, IReadOnlyDictionary, a list in a dictionary) plus a list as the whole body, in a minimal API and an MVC controller, over OpenAPI 3.1, 3.0 and 2.0 and the three value-object placements (components, InlineValueObjects, inlined by the host). A host that inlines everything has no polymorphic argument: ASP.NET refuses a plain property of one too. A wire test reads a map and a list of patches as the document says they are read. The nullable patch the catalogue found is the next commit. Reverted per verify-the-guard: the 11 new document rows fail; the dictionary arm alone fails them too. Closes #232 Defect-Class: FCI Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + design/patch.md | 2 +- docs/merge-patches.md | 4 + .../PatchSchemaTransformer.cs | 25 ++- src/CodoMetis.TypeKit.AspNetCore/README.md | 2 + .../PatchSchemaTests.cs | 172 +++++++++++++++++- test/oracles/axes.md | 4 +- test/oracles/claims.json | 39 ++++ 8 files changed, 237 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c..6587bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +### Fixed + +- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/design/patch.md b/design/patch.md index 5fc4981..9e6f698 100644 --- a/design/patch.md +++ b/design/patch.md @@ -59,7 +59,7 @@ names what is missing. | Where | How | Held by | |---|---|---| -| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0 | `PatchSchemaTests` | +| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0; each element of a list or a dictionary of patches as a property is, in 2.0 too | `PatchSchemaTests` | | Testing | Assertions per state that hand back the value | `PatchAssertionTests` | | Serilog | Logs its state only | the Serilog tests | | EF Core | Not mapped: a patch is the shape of a request, applied to an entity with its own types | — | diff --git a/docs/merge-patches.md b/docs/merge-patches.md index d8b5a38..73b2e63 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -194,6 +194,10 @@ OpenAPI 3.1 and 3.0: A value object inside a patch is described through its underlying type, so `email` is a `string`. +A patch inside a list or a dictionary is described the same way, element by element. So a +`Dictionary>`, where a request sets a key with a value and clears it with `null`, +is an object whose values are a string or `null`. + **Good to know** - **This isn't ASP.NET Core's JSON Patch** (RFC 6902, `application/json-patch+json`), which is a list diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 17865cc..ccc15fd 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -7,7 +7,7 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// A property in the document: its argument's schema, inlined, required only /// where the serializer requires it, and admitting null, since the Null state is part of the wire format whatever the -/// argument is. +/// argument is. The same for each element of a list or a dictionary of patches. /// /// /// @@ -32,7 +32,13 @@ public static void Apply(OpenApiOptions options) options.CreateSchemaReferenceId = type => IsPatch(type.Type) ? null : inner(type); } - private static bool IsPatch(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Patch<>); + private static bool IsPatch(Type type) => Argument(type) is not null; + + /// The argument of a . + private static Type? Argument(Type type) => + type is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) + ? patch.GetGenericArguments()[0] + : null; /// /// The argument's schema, as it is, with null admitted beside every value it admits. @@ -89,11 +95,22 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC } } - if (!IsPatch(info.Type)) return; + // A container of patches arrives without its element, as one of value objects does: ASP.NET leaves out + // items or additionalProperties for a converter-backed element before any transformer runs. The patch's + // own schema is put back, only where it is missing; asked for, it is filled below, as a property's is. + if (info.ElementType is { } element && IsPatch(element)) + { + if (info.Kind == JsonTypeInfoKind.Enumerable && schema.Items is null) + schema.Items = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + else if (info.Kind == JsonTypeInfoKind.Dictionary && schema.AdditionalProperties is null) + schema.AdditionalProperties = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); + return; + } + + if (Argument(info.Type) is not { } argument) return; // What the argument is described as comes first, the host's word or a converter's that replaces a // value object's generated one, as it does where the argument is a property of its own. - var argument = info.Type.GetGenericArguments()[0]; // The argument is held by the patch alone, where ASP.NET asks no transformer of it: a document and a // controller that describe it differently are told apart here. DescribedOptionsAgreement.ThrowIfDisagreeing(context.ApplicationServices, argument, info.Options); diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 231481f..3dedc45 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,6 +150,8 @@ described as `T`'s schema: A value object inside a patch is described through its underlying type. That's also what a host on source-generated JSON can do, because the patch's converter hides the value object from the context. +Each element of a list, an array or a dictionary of patches is described as a patch property is. + **A described type.** ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a dictionary's `additionalProperties` of that type. The same goes for a converter a library registers for diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 7fe4c2f..125c9f8 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -48,22 +48,57 @@ public sealed record Square(int Side) : Shape; /// Described by the host as a string or a number, two branches that each refuse null. public sealed record Either(string Value); - /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all. + /// + /// One patch per kind of schema: a type, an enum without a type, an enum with one, branches, none at all, and the + /// containers, whose elements are a schema of their own or a component's. + /// public sealed record KindsPatch( Patch Name, Patch Quantity, Patch Code, Patch Id, Patch Lines, - Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either); + Patch Colour, Patch Status, Patch Size, Patch Numbers, Patch Shape, Patch Anything, Patch Either, + Patch LinesList, Patch> Counts); /// The same arguments, unpatched: what each value is judged against. public sealed record Kinds( string Name, int Quantity, ProbeCode Code, ProbeId Id, Lines Lines, - Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either); + Colour Colour, ProbeStatus Status, Size Size, int[] Numbers, Shape Shape, object Anything, Either Either, + Lines[] LinesList, Dictionary Counts); + + /// + /// Every place a patch can sit, beside a patch property of the same argument: an element of a list, an array or a + /// dictionary, and an element of a list in a dictionary. + /// + public sealed record PatchPositions( + Patch Property, + List> List, + Patch[] Array, + Dictionary> Map, + IReadOnlyDictionary> ReadOnlyMap, + Dictionary>> Nested) where T : notnull; + + /// The patch in each position of other than the property itself. + private static readonly (string Position, Func Patch)[] Positions = + [ + ("list", schema => schema["items"]), + ("array", schema => schema["items"]), + ("map", schema => schema["additionalProperties"]), + ("readOnlyMap", schema => schema["additionalProperties"]), + ("nested", schema => schema["additionalProperties"]?["items"]), + ]; + + [Microsoft.AspNetCore.Mvc.ApiController] + public sealed class PositionsController : Microsoft.AspNetCore.Mvc.ControllerBase + { + [Microsoft.AspNetCore.Mvc.HttpPatch("/mvc/positions/guid")] + public int Patch([Microsoft.AspNetCore.Mvc.FromBody] PatchPositions positions) => 0; + } public static TheoryData Versions => [OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0]; - private static Task DocumentAsync(OpenApiSpecVersion version) => + private static Task DocumentAsync(OpenApiSpecVersion version, string placement = "components") => ProbeHost.DocumentAsync(openApi => { - openApi.AddTypeKit(); + if (placement == "inlined by the host") openApi.CreateSchemaReferenceId = _ => null; + openApi.AddTypeKit(typeKit => typeKit.InlineValueObjects = placement == "InlineValueObjects"); openApi.AddSchemaTransformer((schema, context, _) => { if (context.JsonTypeInfo.Type != typeof(Either)) return Task.CompletedTask; @@ -77,9 +112,41 @@ private static Task DocumentAsync(OpenApiSpecVersion version) => { app.MapPatch("/patched/{id}", int (int id, OrderPatch patch) => 0); app.MapPost("/control", int (Control control) => 0); - app.MapPatch("/kinds", int (KindsPatch patch) => 0); - app.MapPost("/kinds", int (Kinds kinds) => 0); - }); + MapPositions(app, "name"); + MapPositions(app, "quantity"); + MapPositions(app, "code"); + MapPositions(app, "id"); + MapPositions(app, "lines"); + MapPositions(app, "colour"); + MapPositions(app, "status"); + MapPositions(app, "size"); + MapPositions(app, "numbers"); + if (placement != "inlined by the host") + { + app.MapPatch("/kinds", int (KindsPatch patch) => 0); + app.MapPost("/kinds", int (Kinds kinds) => 0); + MapPositions(app, "shape"); + } + MapPositions(app, "anything"); + MapPositions(app, "either"); + MapPositions(app, "linesList"); + MapPositions>(app, "counts"); + }, controllers: [typeof(PositionsController)]); + + /// The positions of a patch of , in a body of their own, and a list of patches as the whole body. + private static void MapPositions(WebApplication app, string key) where T : notnull + { + app.MapPatch($"/positions/{key}", int (PatchPositions positions) => 0); + app.MapPatch($"/positions/{key}/body", int (List> body) => 0); + } + + /// A request body's schema, in 3.x the content's and in 2.0 the body parameter's. + private static JsonNode Body(OpenApiJson document, string path) + { + var operation = document.Root["paths"]?[path]?["patch"] ?? throw new InvalidOperationException($"No operation patch {path}."); + return operation["requestBody"]?["content"]?["application/json"]?["schema"] + ?? operation["parameters"]!.AsArray().Single(parameter => parameter!["in"]!.GetValue() == "body")!["schema"]!; + } /// For each property, values its argument accepts and values it refuses, as JSON. private static readonly (string Property, string[] Values)[] Values = @@ -96,6 +163,8 @@ private static readonly (string Property, string[] Values)[] Values = ("shape", ["{\"$type\":\"circle\",\"radius\":1}", "\"x\"", "1"]), ("anything", ["1", "\"x\"", "{}", "[]"]), ("either", ["\"x\"", "1", "true"]), + ("linesList", ["[{\"count\":1}]", "[{}]", "{\"count\":1}"]), + ("counts", ["{\"a\":1}", "{\"a\":\"x\"}", "[1]"]), ]; /// @@ -126,6 +195,93 @@ public async Task A_patch_accepts_null_and_otherwise_exactly_what_its_argument_a } } + public static TheoryData Hosts() + { + var data = new TheoryData(); + foreach (var version in new[] { OpenApiSpecVersion.OpenApi3_1, OpenApiSpecVersion.OpenApi3_0, OpenApiSpecVersion.OpenApi2_0 }) + foreach (var placement in new[] { "components", "InlineValueObjects", "inlined by the host" }) + data.Add(version, placement); + + return data; + } + + /// + /// A patch anywhere else is described as a patch property is: an element of a list, an array or a dictionary, one in + /// a list in a dictionary, and the element of a list that is the whole body. ASP.NET left the elements out, as it does + /// for any converter-backed element, so a client generator typed a map of patches as a map of anything. Over every + /// argument of the catalogue, in each version, wherever value objects are described, in a minimal API and an MVC + /// controller. + /// + [Theory] + [MemberData(nameof(Hosts))] + public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(OpenApiSpecVersion version, string placement) + { + var document = await DocumentAsync(version, placement); + + // ASP.NET refuses a polymorphic type with no reference id, a plain property of it too (an ArgumentNullException for + // the referenceId, measured with Microsoft.AspNetCore.OpenApi 10.0), so a host that inlines everything has no shape. + var arguments = Values.Select(value => value.Property).Where(property => placement != "inlined by the host" || property != "shape"); + foreach (var path in arguments.Select(property => "/positions/" + property).Append("/mvc/positions/guid")) + { + var positions = document.Resolve(Body(document, path)); + var property = document.Expand(positions["properties"]!["property"])!; + property["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field", customMessage: $"{path}: the property is the patch's"); + + foreach (var (position, patch) in Positions) + document.Expand(patch(positions["properties"]![position]!)) + .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}") + .ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + + if (path.StartsWith("/mvc", StringComparison.Ordinal)) continue; + document.Expand(Body(document, path + "/body")["items"]).ShouldNotBeNull($"{path}/body").ShouldDescribeTheSameAs(property, $"{path}/body"); + } + } + + /// + /// What the document says of a map and a list of patches is what the endpoint reads: a value sets a key and a null + /// clears it. + /// + [Fact] + public async Task A_map_of_patches_reads_a_value_and_a_null_as_the_document_says() + { + await using var host = await ProbeHost.StartAsync(openApi => openApi.AddTypeKit(), endpoints: app => + app.MapPatch("/read", string (PatchPositions patch) => string.Join(" ", + patch.Map.OrderBy(entry => entry.Key).Select(entry => $"{entry.Key}={entry.Value.State}") + .Concat(patch.List.Select(element => element.State.ToString()))))); + + using var response = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{"a":"x","b":null},"list":["y",null]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null"); + } + + /// + /// The element of a container of patches accepts null and otherwise exactly what its argument accepts, judged by a + /// JSON Schema validator over the catalogue above, as a patch property is. + /// + [Theory] + [MemberData(nameof(Versions))] + public async Task A_patch_in_a_container_accepts_null_and_otherwise_exactly_what_its_argument_accepts(OpenApiSpecVersion version) + { + var document = await DocumentAsync(version); + + foreach (var (property, values) in Values) + { + var positions = document.Resolve(Body(document, "/positions/" + property)); + var argument = Validator(document, document.Property(nameof(Kinds), property), version); + + foreach (var (position, select) in Positions) + { + var schema = select(positions["properties"]![position]!) ?? throw new ShouldAssertException($"'{property}' '{position}' has no element schema"); + var patch = Validator(document, schema, version); + + patch("null").ShouldBeTrue($"'{property}' '{position}' should accept null: {schema.ToJsonString()}"); + foreach (var value in values) + patch(value).ShouldBe(argument(value), $"'{property}' '{position}' should judge {value} as its argument does: {schema.ToJsonString()}"); + } + } + } + /// /// A validator for a property's schema, its references to components inlined, and in 3.0 its /// nullable read as 3.0.3 defines it: null added to a type the same schema states, and diff --git a/test/oracles/axes.md b/test/oracles/axes.md index 05e1cbf..3f6b388 100644 --- a/test/oracles/axes.md +++ b/test/oracles/axes.md @@ -32,8 +32,8 @@ footprint, it runs with the suite. |---|---|---|---|---| | JSON | the host's options | defaults, Web, a naming policy, a dictionary-key policy, number handling, a host converter | `JsonParityTests` | reference preservation beside a dictionary key | | JSON | the wrapped type | every built-in the generators wrap, nullable or not | `JsonParityTests`, `JsonTests` | | -| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests` | a withheld fault name under 2.0 | -| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | +| OpenAPI | the version | 3.1, 3.0, 2.0 | `SchemaTests`, `FaultResponseTests`, `PatchSchemaTests` | a withheld fault name under 2.0 | +| OpenAPI | where value objects are described | components, inlined by `InlineValueObjects`, everything inlined by the host | `SchemaTests`, `NullablePositionTests`, `DescribedParameterTests`, `PatchSchemaTests` | `InlineValueObjects` beside a described type's parameter (`DescribedParameterTests` inlines by the host's own delegate) | | HTTP | the host | minimal API, MVC with `[ApiController]`, MVC without | `MvcBindingTests`, `FaultStatusScopeTests` | | | EF | the provider | SQLite, Npgsql, SQL Server | `KeyGenerationVariantTests`, `QueryTranslationVariantTests` | | | EF | where a key sits | `KeyShapes` (`test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/`) | `KeyGenerationVariantTests` | | diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df1..079b1b2 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3671,6 +3671,45 @@ "MergePatchesTests" ] }, + { + "id": "P06-element-by-element", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "A patch inside a list or a dictionary is described the same way, element by element.", + "claim": "A patch inside a list or a dictionary is described the same way, element by element.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-map-of-patches", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "claim": "So a `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, + { + "id": "P06-readme-containers", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "claim": "Each element of a list, an array or a dictionary of patches is described as a patch property is.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters", From bb8922fc2469471b71c75ac8c475aa56b5854fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:36:03 +0100 Subject: [PATCH 22/30] fix: describe a nullable patch as its argument admitting null, not as an empty required component Found running the container fix over its input class: a Patch? property was an empty PatchOfstring component, admitting anything, and required, and a List?> had no items. The transformer asked only whether a type was Patch itself; it now sees through Nullable, so a nullable patch is inlined, optional, and its argument's schema admitting null, as an element and as a property. It is not described as a merge-patch field: System.Text.Json reads a null into a Nullable> as null, never the Null state, the same as an absent property (measured by a request in the wire test). The docs say so and point to Patch. Two quirks of the same cause: the serializer gives a Nullable the element type T. So the container check read a nullable patch as a container, and now reads the kind first; and ASP.NET visits a nullable patch's items and additionalProperties as the patch, which made a Patch?'s numbers admit null. Those schemas are recorded and skipped. A registration of a reference's target no test caught was measured unneeded (the Lines component stays clean) and dropped. Catalogue: the previous commit's, with a nullable element and a nullable property (8 positions). Reverted per verify-the-guard: 11 rows fail; each of the nullable argument, the kind check, the description and the guard's items and additionalProperties arms fails 9 or 11. Refs #232 Defect-Class: FCI Found-By: VA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- design/patch.md | 2 +- docs/merge-patches.md | 4 + .../PatchSchemaTransformer.cs | 31 ++++++-- src/CodoMetis.TypeKit.AspNetCore/README.md | 4 +- .../PatchSchemaTests.cs | 74 +++++++++++++------ test/oracles/claims.json | 26 +++++++ 7 files changed, 114 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6587bff..aabf085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ section is the GitHub release's notes. ### Fixed -- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). +- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). ## 2.3.0 — 2026-10-29 diff --git a/design/patch.md b/design/patch.md index 9e6f698..9f07029 100644 --- a/design/patch.md +++ b/design/patch.md @@ -59,7 +59,7 @@ names what is missing. | Where | How | Held by | |---|---|---| -| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0; each element of a list or a dictionary of patches as a property is, in 2.0 too | `PatchSchemaTests` | +| OpenAPI | The argument's schema, inlined, admitting null, required only where the serializer requires it, in 3.1 and 3.0; each element of a list or a dictionary of patches as a property is, in 2.0 too; a nullable patch admits the same, without the merge-patch description, since the serializer reads its null as null | `PatchSchemaTests` | | Testing | Assertions per state that hand back the value | `PatchAssertionTests` | | Serilog | Logs its state only | the Serilog tests | | EF Core | Not mapped: a patch is the shape of a request, applied to an entity with its own types | — | diff --git a/docs/merge-patches.md b/docs/merge-patches.md index 73b2e63..7ae87ea 100644 --- a/docs/merge-patches.md +++ b/docs/merge-patches.md @@ -198,6 +198,10 @@ A patch inside a list or a dictionary is described the same way, element by elem `Dictionary>`, where a request sets a key with a value and clears it with `null`, is an object whose values are a string or `null`. +A nullable `Patch?` accepts the same values. But System.Text.Json reads a `null` there as a +`null` patch, the same as a property the request leaves out, so you can't tell a clear from no change. +Use `Patch` for a field a request can clear. + **Good to know** - **This isn't ASP.NET Core's JSON Patch** (RFC 6902, `application/json-patch+json`), which is a list diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index ccc15fd..1a10321 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -7,7 +8,8 @@ namespace CodoMetis.TypeKit.AspNetCore; /// /// A property in the document: its argument's schema, inlined, required only /// where the serializer requires it, and admitting null, since the Null state is part of the wire format whatever the -/// argument is. The same for each element of a list or a dictionary of patches. +/// argument is. The same for each element of a list or a dictionary of patches, and for a nullable patch, which the +/// serializer reads as the argument or null but not as a merge-patch field (). /// /// /// @@ -25,6 +27,13 @@ namespace CodoMetis.TypeKit.AspNetCore; /// internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer { + /// + /// The argument's elements below a nullable patch's schema, which ASP.NET visits again as the patch: the serializer + /// gives a an element type, T, and ASP.NET visits items and + /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null. + /// + private static readonly ConditionalWeakTable ArgumentElements = new(); + /// A patch is inlined where it is used, never hoisted into a component of its own. public static void Apply(OpenApiOptions options) { @@ -34,9 +43,13 @@ public static void Apply(OpenApiOptions options) private static bool IsPatch(Type type) => Argument(type) is not null; - /// The argument of a . + /// + /// The argument of a , or of a nullable one, whose JSON is the same values or null. The serializer + /// reads a null into a nullable patch as null, never the patch's Null state, as it leaves an absent property: so a + /// nullable patch admits null without being described as a merge-patch field. + /// private static Type? Argument(Type type) => - type is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) + (Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) ? patch.GetGenericArguments()[0] : null; @@ -98,7 +111,8 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC // A container of patches arrives without its element, as one of value objects does: ASP.NET leaves out // items or additionalProperties for a converter-backed element before any transformer runs. The patch's // own schema is put back, only where it is missing; asked for, it is filled below, as a property's is. - if (info.ElementType is { } element && IsPatch(element)) + // The kind first: the serializer gives a Nullable an element type too, T. + if (info.Kind is JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary && info.ElementType is { } element && IsPatch(element)) { if (info.Kind == JsonTypeInfoKind.Enumerable && schema.Items is null) schema.Items = await PolymorphicBaseBranch.GetOrCreateSchemaAsync(context, element, parameterDescription: null, cancellationToken); @@ -107,7 +121,7 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC return; } - if (Argument(info.Type) is not { } argument) return; + if (Argument(info.Type) is not { } argument || ArgumentElements.TryGetValue(schema, out _)) return; // What the argument is described as comes first, the host's word or a converter's that replaces a // value object's generated one, as it does where the argument is a property of its own. @@ -123,6 +137,13 @@ public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerC schema.Properties?.Clear(); AdmitNull(schema, inner); + if (Nullable.GetUnderlyingType(info.Type) is not null) + { + foreach (var below in new[] { schema.Items, schema.AdditionalProperties }) + if (below is not null) ArgumentElements.AddOrUpdate(below, argument); + return; + } + schema.Description = "A JSON Merge Patch field: absent leaves the value as it is, null clears it, a value sets it." + (inner.Description is null ? "" : " " + inner.Description); } diff --git a/src/CodoMetis.TypeKit.AspNetCore/README.md b/src/CodoMetis.TypeKit.AspNetCore/README.md index 3dedc45..97b8d98 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/README.md +++ b/src/CodoMetis.TypeKit.AspNetCore/README.md @@ -150,7 +150,9 @@ described as `T`'s schema: A value object inside a patch is described through its underlying type. That's also what a host on source-generated JSON can do, because the patch's converter hides the value object from the context. -Each element of a list, an array or a dictionary of patches is described as a patch property is. +Each element of a list, an array or a dictionary of patches is described as a patch property is. A +nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: +a `null` there reads as a `null` patch, the same as a property left out. **A described type.** ASP.NET can't see through a JSON converter you write yourself. It writes the type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a diff --git a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs index 125c9f8..fc73ee6 100644 --- a/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs +++ b/test/CodoMetis.TypeKit.AspNetCore.Tests/PatchSchemaTests.cs @@ -65,7 +65,7 @@ public sealed record Kinds( /// /// Every place a patch can sit, beside a patch property of the same argument: an element of a list, an array or a - /// dictionary, and an element of a list in a dictionary. + /// dictionary, a nullable element, an element of a list in a dictionary, and a nullable patch property. /// public sealed record PatchPositions( Patch Property, @@ -73,16 +73,23 @@ public sealed record PatchPositions( Patch[] Array, Dictionary> Map, IReadOnlyDictionary> ReadOnlyMap, - Dictionary>> Nested) where T : notnull; + List?> NullableElements, + Dictionary>> Nested, + Patch? Nullable) where T : notnull; - /// The patch in each position of other than the property itself. - private static readonly (string Position, Func Patch)[] Positions = + /// + /// The patch in each position of other than the property itself, and whether it is a + /// nullable patch, which the serializer reads as null for a null, not as the Null state. + /// + private static readonly (string Position, Func Patch, bool Nullable)[] Positions = [ - ("list", schema => schema["items"]), - ("array", schema => schema["items"]), - ("map", schema => schema["additionalProperties"]), - ("readOnlyMap", schema => schema["additionalProperties"]), - ("nested", schema => schema["additionalProperties"]?["items"]), + ("list", schema => schema["items"], false), + ("array", schema => schema["items"], false), + ("map", schema => schema["additionalProperties"], false), + ("readOnlyMap", schema => schema["additionalProperties"], false), + ("nullableElements", schema => schema["items"], true), + ("nested", schema => schema["additionalProperties"]?["items"], false), + ("nullable", schema => schema, true), ]; [Microsoft.AspNetCore.Mvc.ApiController] @@ -208,9 +215,10 @@ public static TheoryData Hosts() /// /// A patch anywhere else is described as a patch property is: an element of a list, an array or a dictionary, one in /// a list in a dictionary, and the element of a list that is the whole body. ASP.NET left the elements out, as it does - /// for any converter-backed element, so a client generator typed a map of patches as a map of anything. Over every - /// argument of the catalogue, in each version, wherever value objects are described, in a minimal API and an MVC - /// controller. + /// for any converter-backed element, so a client generator typed a map of patches as a map of anything. A nullable patch, + /// a property or an element, which was an empty component and required, is the same schema without the merge-patch + /// description, since a null there reads as null. Over every argument of the catalogue, in each version, wherever value + /// objects are described, in a minimal API and an MVC controller. /// [Theory] [MemberData(nameof(Hosts))] @@ -227,10 +235,22 @@ public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(Op var property = document.Expand(positions["properties"]!["property"])!; property["description"]!.GetValue().ShouldStartWith("A JSON Merge Patch field", customMessage: $"{path}: the property is the patch's"); - foreach (var (position, patch) in Positions) - document.Expand(patch(positions["properties"]![position]!)) - .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}") - .ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + foreach (var (position, patch, nullable) in Positions) + { + var schema = document.Expand(patch(positions["properties"]![position]!)) + .ShouldNotBeNull($"{path} '{position}': {positions["properties"]![position]!.ToJsonString()}"); + if (!nullable) + { + schema.ShouldDescribeTheSameAs(property, $"{path} '{position}'"); + continue; + } + + schema["description"].ShouldBeNull($"{path} '{position}': a null in a nullable patch reads as null, not as a clear"); + WithoutDescription(schema).ShouldDescribeTheSameAs(WithoutDescription(property), $"{path} '{position}'"); + } + + var required = positions["required"]?.AsArray().Select(node => node!.GetValue()) ?? []; + required.ShouldNotContain("nullable", $"{path}: a nullable patch is optional as a patch is"); if (path.StartsWith("/mvc", StringComparison.Ordinal)) continue; document.Expand(Body(document, path + "/body")["items"]).ShouldNotBeNull($"{path}/body").ShouldDescribeTheSameAs(property, $"{path}/body"); @@ -239,7 +259,8 @@ public async Task A_patch_in_any_position_is_described_as_a_patch_property_is(Op /// /// What the document says of a map and a list of patches is what the endpoint reads: a value sets a key and a null - /// clears it. + /// clears it. A nullable patch reads a null as null, as it reads a property the request leaves out, which is why the + /// document does not call it a merge-patch field. /// [Fact] public async Task A_map_of_patches_reads_a_value_and_a_null_as_the_document_says() @@ -247,12 +268,16 @@ public async Task A_map_of_patches_reads_a_value_and_a_null_as_the_document_says await using var host = await ProbeHost.StartAsync(openApi => openApi.AddTypeKit(), endpoints: app => app.MapPatch("/read", string (PatchPositions patch) => string.Join(" ", patch.Map.OrderBy(entry => entry.Key).Select(entry => $"{entry.Key}={entry.Value.State}") - .Concat(patch.List.Select(element => element.State.ToString()))))); + .Concat(patch.List.Select(element => element.State.ToString())) + .Append(patch.Nullable?.State.ToString() ?? "none")))); using var response = await host.Client.PatchAsync("/read", new StringContent( - """{"map":{"a":"x","b":null},"list":["y",null]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + """{"map":{"a":"x","b":null},"list":["y",null],"nullable":null}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); + using var leftOut = await host.Client.PatchAsync("/read", new StringContent( + """{"map":{},"list":[]}""", System.Text.Encoding.UTF8, "application/json"), TestContext.Current.CancellationToken); - (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null"); + (await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("a=Value b=Null Value Null none"); + (await leftOut.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).ShouldBe("none"); } /// @@ -270,7 +295,7 @@ public async Task A_patch_in_a_container_accepts_null_and_otherwise_exactly_what var positions = document.Resolve(Body(document, "/positions/" + property)); var argument = Validator(document, document.Property(nameof(Kinds), property), version); - foreach (var (position, select) in Positions) + foreach (var (position, select, _) in Positions) { var schema = select(positions["properties"]![position]!) ?? throw new ShouldAssertException($"'{property}' '{position}' has no element schema"); var patch = Validator(document, schema, version); @@ -407,6 +432,13 @@ public async Task A_patch_parameter_defaulted_with_default_breaks_the_document() refusal.StackTrace.ShouldNotBeNull().ShouldContain(nameof(System.Text.Json.Schema.JsonSchemaExporter), customMessage: "the exporter throws, not the converter"); } + private static JsonNode WithoutDescription(JsonNode schema) + { + var copy = schema.DeepClone().AsObject(); + copy.Remove("description"); + return copy; + } + private static bool AdmitsNull(JsonNode schema, OpenApiSpecVersion version) => version == OpenApiSpecVersion.OpenApi3_1 ? schema["type"] is JsonArray types && types.Any(type => type!.GetValue() == "null") diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 079b1b2..6a6b575 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3697,6 +3697,19 @@ "PatchSchemaTests" ] }, + { + "id": "P06-nullable-patch-reads-null-as-null", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "claim": "But System.Text.Json reads a `null` there as a `null` patch, the same as a property the request leaves out, so you can't tell a clear from no change.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P06-readme-containers", "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", @@ -3710,6 +3723,19 @@ "PatchSchemaTests" ] }, + { + "id": "P06-readme-nullable-patch", + "source": "src/CodoMetis.TypeKit.AspNetCore/README.md#what-the-document-says", + "quote": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "claim": "A nullable patch, `Patch?`, admits the same values and null, but isn't described as a merge patch: a `null` there reads as a `null` patch, the same as a property left out.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests" + ] + }, { "id": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters", From 7961c35f478661e4fb78ca65d7ecb274689369ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:39:44 +0100 Subject: [PATCH 23/30] docs: record KB-057, a nullable contract's element type and its null The fix of #232 turned on two facts of System.Text.Json: a Nullable contract reports T as its element type, which Microsoft.AspNetCore.OpenApi walks items and additionalProperties with, and a JSON null read into a T? is null without asking T's converter, even one that handles null. The second is what the merge-patches page now tells a reader of Patch?. KB057 characterizes both against a plain T as the control. Co-Authored-By: Claude Opus 5.5 --- ...-an-element-type-and-reads-null-as-null.md | 26 +++++++++ design/knowledge/README.md | 1 + .../PatchSchemaTransformer.cs | 5 +- test/CodoMetis.TypeKit.Facts/KB057.cs | 58 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB057.cs diff --git a/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md new file mode 100644 index 0000000..d8baeb1 --- /dev/null +++ b/design/knowledge/KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md @@ -0,0 +1,26 @@ +--- +verified: 2026-10-31 +pins: + - Microsoft.AspNetCore.OpenApi 10.0.12 + - sdk 10.0.4xx +source: System.Text.Json in the .NET 10 runtime, the contract of a Nullable from the reflection resolver and a JSON null read into T? as a property, an element and the root, over a T whose converter handles null, measured; and the schemas Microsoft.AspNetCore.OpenApi hands its schema transformers below a Nullable>, measured in PatchSchemaTests +turned-on: the fix of issue 232 (pull request 237), where a nullable patch is described as its argument admitting null but not as a merge-patch field, the container check reads the kind before the element type, and the argument's elements below a nullable patch are skipped +test: test/CodoMetis.TypeKit.Facts/KB057.cs +--- + +# KB-057. A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does + +The contract System.Text.Json gives a `Nullable`: + +- has `Kind` `None` and `ElementType` `T`, for a built-in `T` and one with a converter of its own; `T`'s own + contract has no element type; +- reads a JSON null as null, without calling `T`'s converter, even one whose `HandleNull` is true. That holds as a + property, as an element (`List`) and at the root. A plain `T` hands the same null to its converter. + +So a `T?` property the request sets to null reads the same as one it leaves out. For `Patch?` that means a +null is never the `Null` state, and a request can't clear the field through it. + +Microsoft.AspNetCore.OpenApi walks a schema's `items` and `additionalProperties` with the contract's element type, +whatever its kind. Below a nullable schema it hands them to every schema transformer as `T`: the numbers of a +`Patch?` reached the patch transformer as a `Patch` and gained a null. `PatchSchemaTests` holds that +consequence (its guard's seeds), and this entry's test the serializer's part. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index c9e4b6f..9177e43 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -85,3 +85,4 @@ test and later for the harness's `Baseline.Fact`. | [KB-052](KB-052-sql-server-takes-no-max-of-a-bit.md) | SQL Server takes neither `MAX` nor `MIN` of a `bit` | SQL Server 2022, EF SqlServer 10.0.12 | `KB052`, with Docker | | [KB-053](KB-053-greatest-and-least-need-compatibility-level-160.md) | EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 | EF SqlServer 10.0.12 | `KB053` | | [KB-054](KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md) | `datetime2` keeps every tick of a `DateTime` and reads it back with `Kind` Unspecified | SQL Server 2022, EF SqlServer 10.0.12 | `KB054`, with Docker | +| [KB-057](KB-057-a-nullable-contract-has-an-element-type-and-reads-null-as-null.md) | A `Nullable` contract has `T` as its element type, and a JSON null read into it is null whatever `T`'s converter does | OpenApi 10.0.12, SDK 10.0.4xx | `KB057` | diff --git a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs index 1a10321..a220d4e 100644 --- a/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs +++ b/src/CodoMetis.TypeKit.AspNetCore/PatchSchemaTransformer.cs @@ -30,7 +30,8 @@ internal sealed class PatchSchemaTransformer : IOpenApiSchemaTransformer /// /// The argument's elements below a nullable patch's schema, which ASP.NET visits again as the patch: the serializer /// gives a an element type, T, and ASP.NET visits items and - /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null. + /// additionalProperties as that type. Filled as a patch, a Patch<int[]>?'s numbers admitted null + /// (KB-057). /// private static readonly ConditionalWeakTable ArgumentElements = new(); @@ -46,7 +47,7 @@ public static void Apply(OpenApiOptions options) /// /// The argument of a , or of a nullable one, whose JSON is the same values or null. The serializer /// reads a null into a nullable patch as null, never the patch's Null state, as it leaves an absent property: so a - /// nullable patch admits null without being described as a merge-patch field. + /// nullable patch admits null without being described as a merge-patch field (KB-057). /// private static Type? Argument(Type type) => (Nullable.GetUnderlyingType(type) ?? type) is { IsGenericType: true } patch && patch.GetGenericTypeDefinition() == typeof(Patch<>) diff --git a/test/CodoMetis.TypeKit.Facts/KB057.cs b/test/CodoMetis.TypeKit.Facts/KB057.cs new file mode 100644 index 0000000..122348e --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB057.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-057: a Nullable<T> contract has T as its element type, though its kind is +/// , and a JSON null read into a T? is null without asking T's converter, +/// even one that handles null: as a property, an element and the root alike, where a plain T hands the null to it. +/// +public sealed class KB057 +{ + /// A struct whose converter reads a null as a state of its own, as a merge-patch field's does. + [JsonConverter(typeof(MarkedConverter))] + public readonly record struct Marked(bool WasNull); + + private sealed class MarkedConverter : JsonConverter + { + public override bool HandleNull => true; + + public override Marked Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var wasNull = reader.TokenType == JsonTokenType.Null; + reader.Skip(); + return new Marked(wasNull); + } + + public override void Write(Utf8JsonWriter writer, Marked value, JsonSerializerOptions options) => writer.WriteNullValue(); + } + + public sealed record Holder(Marked Plain, Marked? Maybe); + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + [Fact] + public void A_nullable_contract_has_the_underlying_type_as_its_element_type() + { + var nullable = Options.GetTypeInfo(typeof(Marked?)); + + nullable.Kind.ShouldBe(JsonTypeInfoKind.None); + nullable.ElementType.ShouldBe(typeof(Marked)); + Options.GetTypeInfo(typeof(int?)).ElementType.ShouldBe(typeof(int), "the same for a built-in type"); + Options.GetTypeInfo(typeof(Marked)).ElementType.ShouldBeNull("the underlying type itself has none"); + } + + [Fact] + public void A_null_read_into_a_nullable_never_reaches_a_converter_that_handles_null() + { + var holder = JsonSerializer.Deserialize("""{"plain":null,"maybe":null}""", Options)!; + holder.Plain.WasNull.ShouldBeTrue("a plain T hands the null to its converter"); + holder.Maybe.ShouldBeNull("a T? property reads it as null"); + + JsonSerializer.Deserialize("""{"plain":null}""", Options)!.Maybe.ShouldBeNull("the same as a property left out"); + JsonSerializer.Deserialize>("[null]", Options).ShouldBe([null], "an element too"); + JsonSerializer.Deserialize("null", Options).ShouldBeNull("and the root"); + JsonSerializer.Deserialize("null", Options).WasNull.ShouldBeTrue(); + } +} From c1c6e3d188be253498b9cd8ee1f6d98b498f933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:43:35 +0100 Subject: [PATCH 24/30] fix: a base's private member of a generated name is hidden from the value object, not refused 2.3.1's base checks read every member of every base at any accessibility: Declared over SelfAndBases in ValueObjectDeclaration.GeneratedMembersDeclaredByHand (#197), InheritedSeams (#195) and the base loop of ValueObjectComparableAspect.HandWrittenComparisonMembers (#194), and, from #218, the method lines of ClosedSetTypes.GeneratedMembersDeclaredByHand (#205). C# hides a base's private member from the derived type, so it stands in for no generated member: a base record's private MaxValue, Parse, TryFrom or CompareTo(object) failed the build with CMTK1011 or CMTK1008, where 2.3.0 generated the value object, and a set's base class's private Parse(string) with CMTK1011. Each base check now counts only a member the type can see (IsAccessibleFrom), on the value object and on the closed set, whose field and property line read Accessibility != Private and now reads the same. A value object nested in its base sees the base's private members, as C# does, and stays refused; a library's private and internal ones were never listed by Metalama and still build. Input class (GTS): a base member of a generated member's name or signature, by accessibility. BaseMemberAccessibility is the catalogue: private, private protected, protected, internal, protected internal and public, by each name the checks read (Value, From, MinValue, MaxValue, Parse and TryParse over string, span and UTF-8, ToString(string?, IFormatProvider?), TryFormat, CompareTo(object), CompareTo(TSelf), TryFrom, Revalidate) and a closed set's Parse, TryParse and Match: 108 types in the consumer of BuildOutcomeTests. The 19 private ones build, the 80 others and the nested one are refused naming the base, and a library's hidden members build. InheritedMembersTests holds what the private ones generate. Reverting the source fails the 19 private rows and refuses the three types of InheritedMembersTests in the test project's own build; counting only a base's public members fails the 76 protected and internal rows and the nested one. No sibling outside these checks: the other base reads (equality, PrintMembers, an unsealed ToString()) match members C# requires to be public or protected in a record. Closes #230 Defect-Class: GTS Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + design/aspects.md | 4 +- .../ClosedSetTypes.cs | 8 +- src/CodoMetis.TypeKit.Generators/README.md | 8 +- .../ValueObjectComparableAspect.cs | 5 +- .../ValueObjectDeclaration.cs | 67 +++--- .../BaseMemberAccessibility.cs | 203 ++++++++++++++++++ .../BuildOutcomeTests.cs | 6 +- .../GeneratedSurface.verified.txt | 127 +++++++++++ .../InheritedMembersTests.cs | 111 ++++++++++ test/oracles/claims.json | 29 +++ 11 files changed, 527 insertions(+), 42 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index acdc62f..830847f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ section is the GitHub release's notes. - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: a generic `ToString()`, or a generic or by-`in` `CompareTo(TSelf)`, is no longer taken for the one you may write: a value object printed what a record prints (`Level { Value = 3 }`), a closed set its type name, and the ordering was marked hand-written, so EF Core refused to order by it. A generic or by-reference overload of `TryFrom`, `Revalidate`, `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` is kept beside the generated member, where the build failed with CMTK1011 or CMTK1008 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a member of a base record or of a closed set's base class that the type can't see, a `private` one such as a `private const int MaxValue`, doesn't count as its own: it stands in for no generated member, so the checks above leave it alone and the type is generated as in 2.3.0 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/aspects.md b/design/aspects.md index d03456c..70b4613 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -175,7 +175,9 @@ a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base record's `Create` from another project was a second rule set for generic `T.Create`. A seam counts where the value object declares it: a base record's `CompareTo(TSelf)`, `TryFrom` or `Revalidate` stood in for the - generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). Found by the interface + generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). A base's member the value + object cannot see (`IsAccessibleFrom`), a private one, reaches nothing: C# hides it, the generated member sits + beside it, and 2.3.0 generated the type, so it is not counted, on a value object or a closed set (#230). Found by the interface mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private member of a type in another assembly, so a base record's from a referenced project was missed as well. `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` declared through an diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index f152439..41d6c12 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -197,8 +197,8 @@ private static string WithoutComments(string text) /// /// A base class's count as the set's own (#205): its Parse, TryParse, Match or All, /// and an IParsable<TSelf> it implements, failed the aspect (LAMA0500, LAMA0503, LAMA0512) naming - /// nothing the user wrote, in this project and from another (measured 2026-10-30). A private member of a base, - /// such as a _name field, is out of the set's sight and builds. + /// nothing the user wrote, in this project and from another (measured 2026-10-30). A member of a base the set cannot + /// see, such as a private _name field or Parse(string), is out of its sight and builds (#230). /// /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) @@ -212,7 +212,7 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType se var bases = ValueObjectDeclaration.SelfAndBases(set).Skip(1).ToList(); IEnumerable Declared(Func> find) => - ValueObjectDeclaration.SelfAndBases(set).SelectMany(type => find(type).Select(method => type.Equals(set) + ValueObjectDeclaration.SelfAndBases(set).SelectMany(type => find(type).Where(method => method.IsAccessibleFrom(set)).Select(method => type.Equals(set) ? ValueObjectDeclaration.Describe(method) : $"{ValueObjectDeclaration.Describe(method)} in its base type {type.ToDisplayString()}")); @@ -220,7 +220,7 @@ IEnumerable Declared(Func> find) => [ .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. generatedNames]).Where(declared => !memberNames.Contains(declared)), .. bases.SelectMany(type => type.Fields.Concat(type.Properties).Concat(type.Events) - .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.Accessibility != Accessibility.Private && generatedNames.Contains(member.Name)) + .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.IsAccessibleFrom(set) && generatedNames.Contains(member.Name)) .Select(member => $"{member.ToDisplayString()} in its base type {type.ToDisplayString()}")), .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean)))), .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, result) || SameParameters(method, text, boolean, result)))), diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 53f439c..bae8fa8 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -237,7 +237,8 @@ covers: another. - **Any of these members in a base record**, public or explicit, in your project or another. A base's `MaxValue` would stand in for the generated one, and generic code calling `T.MaxValue` would reach - it. The error names the base. + it. The error names the base. A base's `private` member is fine: the value object can't see it, so + it stands in for nothing. An overload of a generated member is yours, and the generated one sits beside it. C# tells two methods apart by their type parameters and by whether a parameter is `ref`, `in` or `out`, so a @@ -471,8 +472,9 @@ public sealed partial class OrderStatus : IClosedSet, IParsable(string name)`, is kept too. Any other generated member written by hand fails the build (CMTK1011), in the set or in a - class it derives from. + generic `Parse(string name)`, is kept too. Any other generated member written by hand + fails the build (CMTK1011), in the set or in a class it derives from. A `private` one in that + class is fine, since the set can't see it. - **A base class's `ToString()`.** A `sealed` one is kept, since C# keeps it in every derived class. One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace it. Declare `ToString()` in the set, or seal the base's. diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 8f30166..a0c3039 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -156,13 +156,14 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe // A base record's CompareTo(object) failed the aspect (LAMA0500), and its CompareTo(TSelf), the seam, was // used without a word: no [HandWrittenOrdering], so EF Core ordered by the column, and the generated // operators called the generated CompareTo(object), which C# prefers to the base's, until the stack - // overflowed (#194, measured 2026-10-30). The seam is kept where the value object declares it. + // overflowed (#194, measured 2026-10-30). The seam is kept where the value object declares it. A base's + // private CompareTo is hidden from the value object, so it stands in for nothing (#230). foreach (var type in ValueObjectDeclaration.SelfAndBases(target).Skip(1)) { foreach (var method in type.Methods) { if (method is { IsStatic: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo), Parameters: [{ } other] } - && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && ValueObjectDeclaration.IsHandWritten(method) + && ValueObjectDeclaration.IsByValueAndNotGeneric(method) && ValueObjectDeclaration.IsHandWritten(method) && method.IsAccessibleFrom(target) && (other.Type.SpecialType == SpecialType.Object || other.Type.ToNonNullable().Equals(target))) yield return $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"; } diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index a240322..288a3f5 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -201,17 +201,18 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta // A base record's count as the value object's own (#197). Beside a generated one, its public // Value, From, Parse, TryParse, ToString(string?, IFormatProvider?) or TryFormat failed the aspect // (LAMA0500), naming nothing the user wrote, and its static MinValue and MaxValue were kept as the - // value object's, with no MinValue or MaxValue generated. + // value object's, with no MinValue or MaxValue generated. One the value object cannot see, a base's + // private member, stands in for nothing: C# hides it, and the generated one sits beside it (#230). IEnumerable Declared(Func> find) => SelfAndBases(target).SelectMany(type => find(type).Select(member => type.Equals(target) ? member : $"{member} in its base type {type.ToDisplayString()}")); // ValueObjectAspect: the field, the entry points; ValueObjectContractAspect: Value; the JSON aspect's nested converter. - found.AddRange(Declared(type => MembersNamed(type, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter"))); + found.AddRange(Declared(type => MembersNamed(type, target, "_value", "Value", "__FromJson", "__FromText", "__TryFromText", $"{target.Name}JsonConverter"))); if (kind == ValueObjectKind.Plain) { - found.AddRange(Declared(type => MethodsWithSignature(type, "From", (valueType, false)))); - found.AddRange(Declared(type => MembersNamed(type, methods: false, "From"))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "From", (valueType, false)))); + found.AddRange(Declared(type => MembersNamed(type, target, methods: false, "From"))); } // A generated method's parameters, and whether each is out: an overload that takes one by value where the @@ -225,9 +226,9 @@ IEnumerable Declared(Func> find) => if (parse != ValueParseStrategy.Unsupported) { - found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", stringType, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", stringType, provider, result))); - found.AddRange(Declared(type => MembersNamed(type, $"{target.Name}TypeConverter"))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", stringType, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", stringType, provider, result))); + found.AddRange(Declared(type => MembersNamed(type, target, $"{target.Name}TypeConverter"))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IParsable<>))); if (ValueObjectParsableAspect.SupportsSpan(valueType)) @@ -235,8 +236,8 @@ IEnumerable Declared(Func> find) => generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanParsable<>))); (IType, bool) span = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); - found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", span, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", span, provider, result))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", span, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", span, provider, result))); } if (ValueObjectParsableAspect.SupportsUtf8(valueType)) @@ -244,26 +245,26 @@ IEnumerable Declared(Func> find) => generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanParsable<>))); (IType, bool) utf8 = (TypeFactory.GetType(typeof(ReadOnlySpan)), false); - found.AddRange(Declared(type => MethodsWithSignature(type, "Parse", utf8, provider))); - found.AddRange(Declared(type => MethodsWithSignature(type, "TryParse", utf8, provider, result))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "Parse", utf8, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, "TryParse", utf8, provider, result))); } } // ValueObjectFormattableAspect, unless ToString() is hand-written and it generates nothing. if (!DeclaresToString(target)) { - found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IFormattable.ToString), stringType, provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(IFormattable.ToString), stringType, provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IFormattable))); if (ValueObjectFormattableAspect.ResolveStrategy(valueType) == ValueFormatStrategy.SpanFormattable) { - found.AddRange(Declared(type => MethodsWithSignature(type, nameof(ISpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(ISpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(ISpanFormattable))); } if (ValueObjectFormattableAspect.SupportsUtf8(valueType)) { - found.AddRange(Declared(type => MethodsWithSignature(type, nameof(IUtf8SpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); + found.AddRange(Declared(type => MethodsWithSignature(type, target, nameof(IUtf8SpanFormattable.TryFormat), (TypeFactory.GetType(typeof(Span)), false), (TypeFactory.GetType(SpecialType.Int32), true), (TypeFactory.GetType(typeof(ReadOnlySpan)), false), provider))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IUtf8SpanFormattable))); } } @@ -271,7 +272,7 @@ IEnumerable Declared(Func> find) => // ValueObjectMinMaxValueAspect. if (kind == ValueObjectKind.Plain && ValueObjectMinMaxValueAspect.HasMinMaxValue(valueType)) { - found.AddRange(Declared(type => MembersNamed(type, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue)))); + found.AddRange(Declared(type => MembersNamed(type, target, nameof(IMinMaxValue.MinValue), nameof(IMinMaxValue.MaxValue)))); generatedInterfaces.Add(TypeFactory.GetNamedType(typeof(IMinMaxValue<>))); } @@ -433,13 +434,14 @@ private static string PublicName(IMember implementation, IMember implemented) /// base's counted, so it stood in for the value object's without a word: TryFrom answered whatever the /// base did, which need not call Create, and Revalidate threw where the base's did (#194, /// measured 2026-10-30). A base's FromKnownGood(T) is hidden by the generated one, whose caller - /// expression parameter makes it another signature, so it is not one. + /// expression parameter makes it another signature, so it is not one, and a base's private one is hidden from the + /// value object, which C# never calls it for (#230). /// public static IReadOnlyList InheritedSeams(INamedType target, INamedType valueType, ValueObjectKind kind) => kind != ValueObjectKind.Validated ? [] : [.. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods - .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation && IsByValueAndNotGeneric(method) + .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation && IsByValueAndNotGeneric(method) && method.IsAccessibleFrom(target) && (method is { Name: "TryFrom", IsStatic: true, Parameters: [{ } value] } && value.Type.ToNonNullable().Equals(valueType.ToNonNullable()) || method is { Name: "Revalidate", IsStatic: false, Parameters.Count: 0 })) .Select(method => $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"))]; @@ -658,37 +660,43 @@ internal static bool IsHandWritten(IMember member) => internal static bool IsByValueAndNotGeneric(IMethod method) => method.TypeParameters.Count == 0 && method.Parameters.All(parameter => parameter.RefKind == RefKind.None); - /// Non-implicit members of with one of , of any kind. - private static IEnumerable MembersNamed(INamedType target, params string[] names) => MembersNamed(target, methods: true, names); + /// + /// Non-implicit members of with one of , of any kind, that + /// can see: a base's private member is hidden from the value object (#230). + /// + private static IEnumerable MembersNamed(INamedType target, INamedType seenFrom, params string[] names) => MembersNamed(target, seenFrom, methods: true, names); + + /// Non-implicit members of with one of . + internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) => MembersNamed(target, target, methods, names); /// /// An explicit implementation is 's, which names it, and so is a public static /// member of another assembly that implements a static abstract one, which reads as explicit there. /// - internal static IEnumerable MembersNamed(INamedType target, bool methods, params string[] names) + private static IEnumerable MembersNamed(INamedType target, INamedType seenFrom, bool methods, params string[] names) { foreach (var field in target.Fields) - if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name)) yield return field.ToDisplayString(); + if (!field.IsImplicitlyDeclared && Enumerable.Contains(names, field.Name) && field.IsAccessibleFrom(seenFrom)) yield return field.ToDisplayString(); foreach (var property in target.Properties) - if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, property.Name)) yield return property.ToDisplayString(); + if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, property.Name) && property.IsAccessibleFrom(seenFrom)) yield return property.ToDisplayString(); foreach (var @event in target.Events) - if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, @event.Name)) yield return @event.ToDisplayString(); + if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && Enumerable.Contains(names, @event.Name) && @event.IsAccessibleFrom(seenFrom)) yield return @event.ToDisplayString(); foreach (var nested in target.Types) - if (Enumerable.Contains(names, nested.Name)) yield return $"the nested type {nested.ToDisplayString()}"; + if (Enumerable.Contains(names, nested.Name) && nested.IsAccessibleFrom(seenFrom)) yield return $"the nested type {nested.ToDisplayString()}"; if (!methods) yield break; foreach (var method in target.Methods) - if (!method.IsImplicitlyDeclared && !method.IsExplicitInterfaceImplementation && Enumerable.Contains(names, method.Name)) yield return method.ToDisplayString(); + if (!method.IsImplicitlyDeclared && !method.IsExplicitInterfaceImplementation && Enumerable.Contains(names, method.Name) && method.IsAccessibleFrom(seenFrom)) yield return method.ToDisplayString(); } /// /// Methods named whose parameter types are , - /// whatever they return: a generated member with that signature is either refused by Metalama or - /// skipped in favour of the hand-written one. + /// whatever they return, that can see: a generated member with that signature is + /// either refused by Metalama or skipped in favour of the hand-written one. /// /// /// The signature C# overloads by (#226): no type parameters, as the generated method has none, and each @@ -697,11 +705,12 @@ internal static IEnumerable MembersNamed(INamedType target, bool methods /// last parameter is by value are other methods, which the generated ones sit beside. ref or in /// against out is no overload in C# (CS0663), so it is the same method, as nullability is. /// - private static IEnumerable MethodsWithSignature(INamedType target, string name, params (IType Type, bool Out)[] parameters) => + private static IEnumerable MethodsWithSignature(INamedType target, INamedType seenFrom, string name, params (IType Type, bool Out)[] parameters) => target.Methods .Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, TypeParameters.Count: 0 } && method.Name == name && method.Parameters.Count == parameters.Length - && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.Equals(expected.Type) && declared.RefKind != RefKind.None == expected.Out).All(same => same)) + && method.Parameters.Zip(parameters, (declared, expected) => declared.Type.Equals(expected.Type) && declared.RefKind != RefKind.None == expected.Out).All(same => same) + && method.IsAccessibleFrom(seenFrom)) .Select(method => method.ToDisplayString()); } diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs b/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs new file mode 100644 index 0000000..2b04171 --- /dev/null +++ b/test/CodoMetis.TypeKit.Generators.Tests/BaseMemberAccessibility.cs @@ -0,0 +1,203 @@ +using System.Text; + +namespace CodoMetis.TypeKit.Generators.Tests; + +/// +/// A base type's member of a generated member's name or signature, by its accessibility (#230): what the value +/// object or the closed set can see stands in for the generated member and is refused, and what it cannot see, a +/// base's private member, is hidden by C# and stands in for nothing, so the type is generated as 2.3.0 generated +/// it. One base per member and accessibility, one value object or set over each, built in +/// ; what the private ones generate is '. +/// +internal static class BaseMemberAccessibility +{ + /// Every accessibility a member of a base can have, by the name the declarations use for it. + public static IReadOnlyList<(string Name, string Keyword)> Accessibilities { get; } = + [ + ("Private", "private"), + ("PrivateProtected", "private protected"), + ("Protected", "protected"), + ("Internal", "internal"), + ("ProtectedInternal", "protected internal"), + ("Public", "public"), + ]; + + /// + /// The generated members a base record's member stands in for, the declaration of each in a base record + /// <TSelf>, whether the value object is a validated one, and the error that refuses it. + /// + private static readonly (string Name, string Declaration, bool Validated, string Id)[] ValueObjectMembers = + [ + ("Value", "int Value => 0;", false, "CMTK1011"), + ("From", "static TSelf From(int value) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("MinValue", "static int MinValue => 0;", false, "CMTK1011"), + ("MaxValue", "static int MaxValue => 0;", false, "CMTK1011"), + ("Parse", "static TSelf Parse(string text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("TryParse", "static bool TryParse(string? text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("SpanParse", "static TSelf Parse(System.ReadOnlySpan text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("SpanTryParse", "static bool TryParse(System.ReadOnlySpan text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Utf8Parse", "static TSelf Parse(System.ReadOnlySpan text, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Utf8TryParse", "static bool TryParse(System.ReadOnlySpan text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("Format", "string ToString(string? format, System.IFormatProvider? provider) => \"\";", false, "CMTK1011"), + ("TryFormat", "bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) => throw new System.NotSupportedException();", false, "CMTK1011"), + ("ObjectCompareTo", "int CompareTo(object? other) => 0;", false, "CMTK1008"), + ("CompareTo", "int CompareTo(TSelf? other) => 0;", false, "CMTK1008"), + ("TryFrom", "static Option TryFrom(int value) => throw new System.NotSupportedException();", true, "CMTK1011"), + ("Revalidate", "Result Revalidate() => throw new System.NotSupportedException();", true, "CMTK1011"), + ]; + + /// The generated members of a closed set a base class's member stands in for, declared in a base class <TSelf>. + private static readonly (string Name, string Declaration)[] SetMembers = + [ + ("Parse", "static TSelf Parse(string name) => throw new System.NotSupportedException();"), + // The ignore-case form, which no interface declares: a base's protected TryParse(string?, out TSelf) is also + // CS0737 beside the refusal, as it can't implement IClosedSet.TryParse. + ("TryParse", "static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException();"), + ("Match", "TResult Match(System.Func a) => a();"), + ]; + + private static string ValueObject(string member, string accessibility) => $"{member}In{accessibility}Base"; + + private static string Set(string member, string accessibility) => $"SetWith{member}In{accessibility}Base"; + + /// The value objects, for the consumer's declarations build. + public static string ValueObjectSource() + { + var source = new StringBuilder( + """ + using CodoMetis.TypeKit; + using CodoMetis.TypeKit.ValueObjects; + + namespace Consumer; + + public enum AccessibilityFault { Negative } + + // A value object nested in its base record sees the base's private members, as C# does: refused. + public abstract record EnclosingBase + { + private static int MaxValue => 0; + + public sealed partial record NestedInItsBase : EnclosingBase, IValue; + } + + // A library's private and internal members, which no project but the library's sees. + public sealed partial record OverALibrarysHiddenMembers : Library.HiddenMembersBase, IValue; + + """); + + foreach (var (member, declaration, validated, _) in ValueObjectMembers) + { + foreach (var (accessibility, keyword) in Accessibilities) + { + var type = ValueObject(member, accessibility); + source.AppendLine($"public abstract record {type}Record where TSelf : notnull {{ {keyword} {declaration} }}"); + source.AppendLine(validated + ? $"public sealed partial record {type} : {type}Record<{type}>, IValidatedValue<{type}, int, AccessibilityFault> {{ public static Result<{type}, AccessibilityFault> Create(int value) => throw new System.NotSupportedException(); }}" + : $"public sealed partial record {type} : {type}Record<{type}>, IValue;"); + } + } + + return source.ToString(); + } + + /// The closed sets, for the consumer's refused-sets build, where a refused set's CS0535 is expected. + public static string SetSource() + { + var source = new StringBuilder( + """ + using CodoMetis.TypeKit.ClosedSets; + + namespace Consumer; + + public sealed partial class SetOverALibrarysHiddenMembers : Library.HiddenSetMembersBase, IClosedSet { public static readonly SetOverALibrarysHiddenMembers A = new(); } + + """); + + foreach (var (member, declaration) in SetMembers) + { + foreach (var (accessibility, keyword) in Accessibilities) + { + var type = Set(member, accessibility); + source.AppendLine($"public abstract class {type}Class {{ {keyword} {declaration} }}"); + source.AppendLine($"public sealed partial class {type} : {type}Class<{type}>, IClosedSet<{type}> {{ public static readonly {type} A = new(); }}"); + } + } + + return source.ToString(); + } + + /// What the library declares, appended to its file-scoped namespace, which the consumer cannot see. + public const string LibrarySource = + """ + + public abstract record HiddenMembersBase where TSelf : notnull + { + private static int MaxValue => 0; + internal static int MinValue => 0; + private static TSelf Parse(string text, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + internal static bool TryParse(string? text, System.IFormatProvider? provider, out TSelf result) => throw new System.NotSupportedException(); + private int CompareTo(object? other) => 0; + internal int CompareTo(TSelf? other) => 0; + protected int Uses() => MaxValue + CompareTo(null) + (Parse("", null) is null ? 0 : 1); + } + + public abstract class HiddenSetMembersBase + { + private static TSelf Parse(string name) => throw new System.NotSupportedException(); + internal static bool TryParse(string? name, out TSelf member) => throw new System.NotSupportedException(); + protected static TSelf Uses() => Parse(""); + } + """; + + /// Each value object or set over a member it can see, with the error that refuses it and the member's name. + public static TheoryData Refused() + { + var rows = new TheoryData(); + + foreach (var (member, _, _, id) in ValueObjectMembers) + foreach (var (accessibility, _) in Accessibilities.Where(accessibility => accessibility.Name != "Private")) + rows.Add(accessibility, id, ValueObject(member, accessibility), $"{ValueObject(member, accessibility)}Record<{ValueObject(member, accessibility)}>."); + + foreach (var (member, _) in SetMembers) + foreach (var (accessibility, _) in Accessibilities.Where(accessibility => accessibility.Name != "Private")) + rows.Add(accessibility, "CMTK1011", Set(member, accessibility), $"{Set(member, accessibility)}Class<{Set(member, accessibility)}>."); + + rows.Add("private, seen from a nested type", "CMTK1011", "EnclosingBase.NestedInItsBase", "EnclosingBase.MaxValue in its base type EnclosingBase"); + return rows; + } + + /// Each value object or set over a member it cannot see. + public static TheoryData Generated() => + [ + .. ValueObjectMembers.Select(member => ValueObject(member.Name, "Private")), + .. SetMembers.Select(member => Set(member.Name, "Private")), + "OverALibrarysHiddenMembers", + "SetOverALibrarysHiddenMembers", + ]; +} + +public sealed partial class BuildOutcomeTests +{ + public static TheoryData SeenBaseMembers => BaseMemberAccessibility.Refused(); + + public static TheoryData HiddenBaseMembers => BaseMemberAccessibility.Generated(); + + /// + /// A base's member the value object or set can see, protected, internal or public, stands in for the generated + /// one, and is refused naming the base, as for a public one (#197, #205). + /// + [Theory] + [MemberData(nameof(SeenBaseMembers))] + public void A_base_member_the_type_can_see_is_refused(string accessibility, string id, string type, string member) => + consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member) && error.Message.Contains(" in its base type "), + $"{id} on {type}, {accessibility}. The build reported:{Environment.NewLine}{consumer.Output}"); + + /// + /// A base's private member is hidden from the value object or set, so it stands in for nothing: the type builds, + /// as it did on 2.3.0, where 2.3.1's base-record checks refused it (#230). So does a library's internal one. + /// + [Theory] + [MemberData(nameof(HiddenBaseMembers))] + public void A_base_member_the_type_cannot_see_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'"), consumer.Output); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index a726059..da9acf4 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -2252,9 +2252,9 @@ public readonly partial record struct SplitAcrossFiles public async ValueTask InitializeAsync() { - var library = await BuildLibrary(_libraryDirectory, Library); - var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile }, library); - var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets }, library); + var library = await BuildLibrary(_libraryDirectory, Library + BaseMemberAccessibility.LibrarySource); + var declarations = Build(_directory, new Dictionary { ["Declarations.cs"] = Declarations, ["OtherFile.cs"] = OtherFile, ["BaseMemberAccessibility.cs"] = BaseMemberAccessibility.ValueObjectSource() }, library); + var sets = Build(_setsDirectory, new Dictionary { ["RefusedSets.cs"] = RefusedSets, ["BaseMemberAccessibility.cs"] = BaseMemberAccessibility.SetSource() }, library); var (declarationOutput, declarationErrors) = await declarations; var (setOutput, setErrors) = await sets; diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index ec5946d..23b0b8c 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -3676,6 +3676,61 @@ OverParsesVirtually static op_Inequality(OverParsesVirtually, OverParsesVirtually) : Boolean extension static OverParsesVirtuallyExtensions.GetValue(OverParsesVirtually) : IParsesVirtually extension static OverParsesVirtuallyExtensions.ValueOrNull(OverParsesVirtually?) : IParsesVirtually +OverPrivateNames + $() : OverPrivateNames + CompareTo(Object) : Int32 + CompareTo(OverPrivateNames) : Int32 + Equals(Object) : Boolean + Equals(OverPrivateNames) : Boolean + Equals(PrivateNamesBase) : Boolean + GetHashCode() : Int32 + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable + implements IEquatable> + implements IFormattable + implements IMinMaxValue + implements IParsable + implements IPlainValueObject + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValue + implements IValueObject + implements IValueObjectMaterializer + nested OverPrivateNamesJsonConverter : JsonConverter, IStoredJsonConverterSource + nested OverPrivateNamesTypeConverter : TypeConverter + static From(Int32) : OverPrivateNames + static MaxValue : OverPrivateNames + static MinValue : OverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : OverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : OverPrivateNames + static Parse(String, IFormatProvider) : OverPrivateNames + static TryParse(ReadOnlySpan, IFormatProvider, out OverPrivateNames) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out OverPrivateNames) : Boolean + static TryParse(String, IFormatProvider, out OverPrivateNames) : Boolean + static op_Equality(OverPrivateNames, OverPrivateNames) : Boolean + static op_GreaterThan(OverPrivateNames, OverPrivateNames) : Boolean + static op_GreaterThanOrEqual(OverPrivateNames, OverPrivateNames) : Boolean + static op_Inequality(OverPrivateNames, OverPrivateNames) : Boolean + static op_LessThan(OverPrivateNames, OverPrivateNames) : Boolean + static op_LessThanOrEqual(OverPrivateNames, OverPrivateNames) : Boolean + extension static OverPrivateNamesExtensions.GetValue(OverPrivateNames) : Int32 + extension static OverPrivateNamesExtensions.ValueOrNull(OverPrivateNames) : Int32? OverProbeStatus Equals(Object) : Boolean Equals(OverProbeStatus) : Boolean @@ -4746,6 +4801,24 @@ SetOverAnOverloadingBase static Parse(String, Boolean) : SetOverAnOverloadingBase static TryParse(String, Boolean, out SetOverAnOverloadingBase) : Boolean static TryParse(String, out SetOverAnOverloadingBase) : Boolean +SetOverPrivateNames + Match(Func, Func) : TResult + Name : String + QualifiedName : String + ToString() : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested SetOverPrivateNamesJsonConverter : JsonConverter + nested SetOverPrivateNamesTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : SetOverPrivateNames + static Parse(String, Boolean) : SetOverPrivateNames + static TryParse(String, Boolean, out SetOverPrivateNames) : Boolean + static TryParse(String, out SetOverPrivateNames) : Boolean SetWithAGenericToString Match(Func) : TResult Name : String @@ -5556,3 +5629,57 @@ ValidatedOverASeamOverloadingBase static op_LessThanOrEqual(ValidatedOverASeamOverloadingBase, ValidatedOverASeamOverloadingBase) : Boolean extension static ValidatedOverASeamOverloadingBaseExtensions.GetValue(ValidatedOverASeamOverloadingBase) : Int32 extension static ValidatedOverASeamOverloadingBaseExtensions.ValueOrNull(ValidatedOverASeamOverloadingBase) : Int32? +ValidatedOverPrivateNames + $() : ValidatedOverPrivateNames + CompareTo(Object) : Int32 + CompareTo(ValidatedOverPrivateNames) : Int32 + Equals(Object) : Boolean + Equals(PrivateNamesBase) : Boolean + Equals(ValidatedOverPrivateNames) : Boolean + GetHashCode() : Int32 + Revalidate() : Result + ToString() : String + ToString(String, IFormatProvider) : String + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + TryFormat(Span, out Int32, ReadOnlySpan, IFormatProvider) : Boolean + Value : Int32 + attribute CompilerGeneratedAttribute + attribute GeneratedValueObjectAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IComparable + implements IComparable + implements IComparisonOperators + implements IConvertible + implements IEqualityOperators + implements IEquatable> + implements IEquatable + implements IFormattable + implements IParsable + implements IReimplementedEquatable> + implements ISpanFormattable + implements ISpanParsable + implements IUtf8SpanFormattable + implements IUtf8SpanParsable + implements IValidatedValue + implements IValueObject + implements IValueObjectMaterializer + nested ValidatedOverPrivateNamesJsonConverter : JsonConverter, IStoredJsonConverterSource + nested ValidatedOverPrivateNamesTypeConverter : TypeConverter + static Create(Int32) : Result + static FromKnownGood(Int32, String) : ValidatedOverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverPrivateNames + static Parse(ReadOnlySpan, IFormatProvider) : ValidatedOverPrivateNames + static Parse(String, IFormatProvider) : ValidatedOverPrivateNames + static TryFrom(Int32) : Option + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static TryParse(ReadOnlySpan, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static TryParse(String, IFormatProvider, out ValidatedOverPrivateNames) : Boolean + static op_Equality(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_GreaterThan(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_GreaterThanOrEqual(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_Inequality(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_LessThan(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + static op_LessThanOrEqual(ValidatedOverPrivateNames, ValidatedOverPrivateNames) : Boolean + extension static ValidatedOverPrivateNamesExtensions.GetValue(ValidatedOverPrivateNames) : Int32 + extension static ValidatedOverPrivateNamesExtensions.ValueOrNull(ValidatedOverPrivateNames) : Int32? diff --git a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs index 3ab3237..a193896 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs @@ -88,6 +88,70 @@ public sealed partial class MaskedSet : LocalMaskingSetBase, CodoMetis.TypeKit.C public static readonly MaskedSet Closed = new(); } +/// +/// A base record whose private members have the generated members' names and signatures. C# hides them from a +/// derived record, so they stand in for nothing: 2.3.0 generated the value object beside them, and 2.3.1's +/// base-record checks refused it (CMTK1011, CMTK1008, #230). +/// +public abstract record PrivateNamesBase where TSelf : notnull +{ + private static int MaxValue => 100; + private static int MinValue => 0; + private int Value => -1; + private static TSelf From(int value) => throw new NotSupportedException("hidden"); + private static TSelf Parse(string text, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private static bool TryParse(string? text, IFormatProvider? provider, out TSelf result) => throw new NotSupportedException("hidden"); + private static TSelf Parse(ReadOnlySpan text, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private static bool TryParse(ReadOnlySpan text, IFormatProvider? provider, out TSelf result) => throw new NotSupportedException("hidden"); + private string ToString(string? format, IFormatProvider? provider) => "hidden"; + private bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => throw new NotSupportedException("hidden"); + private int CompareTo(object? other) => 0; + private int CompareTo(TSelf? other) => 0; + private static Option TryFrom(int value) => throw new NotSupportedException("hidden"); + private Result Revalidate() => throw new NotSupportedException("hidden"); + + /// What the base uses its private members for. + protected bool InRange(int value) => value >= MinValue && value <= MaxValue && Value < 0 && CompareTo(null) == 0 && ToString(null, null) == "hidden"; +} + +/// Why a value object over is refused. +public enum PrivateNamesFault +{ + /// Below zero. + Negative +} + +/// A plain value object over a base record's private members of generated names. +public sealed partial record OverPrivateNames : PrivateNamesBase, IValue; + +/// A validated value object over a base record's private TryFrom and Revalidate. +public sealed partial record ValidatedOverPrivateNames : PrivateNamesBase, IValidatedValue +{ + /// Zero or more. + public static Result Create(int value) => value < 0 ? Result.Error(PrivateNamesFault.Negative) : new ValidatedOverPrivateNames(value); +} + +/// A base class whose private members have a closed set's generated names and signatures (#230). +public abstract class PrivateSetNamesBase +{ + private static TSelf Parse(string name) => throw new NotSupportedException("hidden"); + private static bool TryParse(string? name, out TSelf member) => throw new NotSupportedException("hidden"); + private TResult Match(Func first, Func second) => throw new NotSupportedException("hidden"); + + /// What the base uses its private members for. + protected static bool Parses(string name) => TryParse(name, out _) || Parse(name) is not null || ((PrivateSetNamesBase)null!).Match(() => 0, () => 1) == 0; +} + +/// A closed set over a base class's private members of generated names. +public sealed partial class SetOverPrivateNames : PrivateSetNamesBase, CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + /// The first member. + public static readonly SetOverPrivateNames First = new(); + + /// The second member. + public static readonly SetOverPrivateNames Second = new(); +} + /// /// What a value object inherits from a base record. Refusals are build errors and live in /// ; this is what is generated. @@ -203,6 +267,53 @@ public void A_sealed_ToString_in_a_base_record_is_the_seam(object value) TypeDescriptor.GetConverter(value.GetType()).ConvertToInvariantString(value).ShouldBe("SECRET"); } + /// + /// A base record's private members stand in for nothing: every generated member is there, and is what a call + /// on the value object reaches (#230). + /// + [Fact] + public void A_base_record_s_private_members_of_generated_names_are_hidden() + { + OverPrivateNames.MaxValue.Value.ShouldBe(int.MaxValue); + OverPrivateNames.MinValue.Value.ShouldBe(int.MinValue); + OverPrivateNames.From(7).Value.ShouldBe(7); + OverPrivateNames.Parse("7", null).ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.TryParse("7", null, out var parsed).ShouldBeTrue(); + parsed.ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.Parse("7".AsSpan(), null).ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.TryParse("7"u8, null, out var utf8).ShouldBeTrue(); + utf8.ShouldBe(OverPrivateNames.From(7)); + OverPrivateNames.From(7).ToString("D3", null).ShouldBe("007"); + + Span buffer = stackalloc char[8]; + OverPrivateNames.From(7).TryFormat(buffer, out var written, "D3", null).ShouldBeTrue(); + buffer[..written].ToString().ShouldBe("007"); + + (OverPrivateNames.From(1) < OverPrivateNames.From(2)).ShouldBeTrue(); + OverPrivateNames.From(1).CompareTo((object)OverPrivateNames.From(2)).ShouldBeLessThan(0); + typeof(OverPrivateNames).IsDefined(typeof(CodoMetis.TypeKit.CompilerServices.HandWrittenOrderingAttribute), inherit: false).ShouldBeFalse(); + } + + /// A base record's private TryFrom and Revalidate are no seams: the generated ones run Create (#230). + [Fact] + public void A_base_record_s_private_TryFrom_and_Revalidate_are_hidden() + { + ValidatedOverPrivateNames.TryFrom(50).IsSome().ShouldBeTrue(); + ValidatedOverPrivateNames.TryFrom(-1).IsNone().ShouldBeTrue(); + ValidatedOverPrivateNames.FromKnownGood(50).Revalidate().TryGetValue(out var revalidated, out _).ShouldBeTrue(); + revalidated.ShouldBe(ValidatedOverPrivateNames.FromKnownGood(50)); + } + + /// A base class's private Parse, TryParse and Match are hidden from a closed set too (#230). + [Fact] + public void A_closed_set_s_base_class_s_private_members_of_generated_names_are_hidden() + { + SetOverPrivateNames.Parse("Second").ShouldBeSameAs(SetOverPrivateNames.Second); + SetOverPrivateNames.TryParse("First", out var member).ShouldBeTrue(); + member.ShouldBeSameAs(SetOverPrivateNames.First); + SetOverPrivateNames.Second.Match(first: () => 1, second: () => 2).ShouldBe(2); + } + /// /// A base class's sealed ToString() is a closed set's seam too (#205): it is what the set prints, while /// its name, parsing and JSON are generated as usual. diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 119cf03..3403541 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1209,6 +1209,21 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-base-s-private-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "A base's `private` member is fine: the value object can't see it, so it stands in for nothing.", + "claim": "A base record's private member of a generated member's name or signature does not fail the build: the value object is generated beside it, and every generated member is what a call reaches; a protected, internal or public one is refused.", + "oracle": "claim", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, { "id": "C22-a-contract-through-an-interface", "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", @@ -1251,6 +1266,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-set-s-base-class-private-member", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "A `private` one in that class is fine, since the set can't see it.", + "claim": "A closed set's base class's private Parse, TryParse or Match does not fail the build, and the set's generated members are what a call reaches; a protected, internal or public one is refused.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, { "id": "C22-a-set-s-base-class-tostring", "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", From 19bb13d3376e07dc2be449e593d7fb31ae09a6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:59:37 +0100 Subject: [PATCH 25/30] test: keep the claims sorted by id, as OracleCatalogueTests requires Co-Authored-By: Claude Opus 5.5 --- test/oracles/claims.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 6a6b575..14514cf 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -3645,10 +3645,10 @@ ] }, { - "id": "P06-inlined", + "id": "P06-element-by-element", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", - "quote": "**Inlined**, rather than as a component of its own.", - "claim": "**Inlined**, rather than as a component of its own.", + "quote": "A patch inside a list or a dictionary is described the same way, element by element.", + "claim": "A patch inside a list or a dictionary is described the same way, element by element.", "oracle": "claim", "surfaces": [ "TypeKitOpenApiOptionsExtensions.AddTypeKit" @@ -3658,24 +3658,10 @@ ] }, { - "id": "P06-through-the-underlying-type", - "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", - "quote": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", - "claim": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", - "oracle": "claim", - "surfaces": [ - "TypeKitOpenApiOptionsExtensions.AddTypeKit" - ], - "heldBy": [ - "PatchSchemaTests", - "MergePatchesTests" - ] - }, - { - "id": "P06-element-by-element", + "id": "P06-inlined", "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", - "quote": "A patch inside a list or a dictionary is described the same way, element by element.", - "claim": "A patch inside a list or a dictionary is described the same way, element by element.", + "quote": "**Inlined**, rather than as a component of its own.", + "claim": "**Inlined**, rather than as a component of its own.", "oracle": "claim", "surfaces": [ "TypeKitOpenApiOptionsExtensions.AddTypeKit" @@ -3736,6 +3722,20 @@ "PatchSchemaTests" ] }, + { + "id": "P06-through-the-underlying-type", + "source": "docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document", + "quote": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", + "claim": "A value object inside a patch is described through its underlying type, so `email` is a `string`.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "PatchSchemaTests", + "MergePatchesTests" + ] + }, { "id": "P07-default-breaks-the-document", "source": "docs/merge-patches.md#required-constructor-parameters", From 4bcbf8040325a3fab756a2bf55887672cc27a818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 08:59:37 +0100 Subject: [PATCH 26/30] test: keep the claims sorted by id, as OracleCatalogueTests requires Co-Authored-By: Claude Opus 5.5 --- test/oracles/claims.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/oracles/claims.json b/test/oracles/claims.json index c14223e..6800280 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1977,10 +1977,10 @@ ] }, { - "id": "E05-query-filter-own-values", + "id": "E05-query-filter", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", - "quote": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", - "claim": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "quote": "A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type.", + "claim": "A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type.", "oracle": "exception", "surfaces": [ "HandWrittenOrderingAttribute.*" @@ -1990,10 +1990,10 @@ ] }, { - "id": "E05-query-filter", + "id": "E05-query-filter-own-values", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", - "quote": "A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type.", - "claim": "A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type.", + "quote": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", + "claim": "One exception: a filter may order values your context holds, without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`. EF works that out in memory, by your `CompareTo`. With a lambda, as in `Limits.OrderByDescending(l => l).First()`, EF orders those values in the database, so that filter fails too.", "oracle": "exception", "surfaces": [ "HandWrittenOrderingAttribute.*" From bdf961d7342f3d78c7e2883565f5797e22aff483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 10:09:03 +0100 Subject: [PATCH 27/30] test: run the analyzer sweeps' rows in parallel, and leave the pragma pass to each rule's focused tests DroppedTaskVariantTests is one class of 1,838 rows, which xunit's default mode ran one after another. On CI it kept the test step going for two to seven minutes after every other project had finished (#245). The analyzer test project now runs with parallelMode "all" (xunit.runner.json), so every row of every theory is scheduled on its own. No test there shares state: each builds its own compilation. The four sweeps (DroppedTaskVariantTests, TypeArgumentConstraintKindTests, QueryDelegateConditionTests and TypeArgumentSiteRun, which TypeArgumentConstraintVariantTests runs) set VariantSweep.Behaviors, which skips the verifier's second run of each reporting case under '#pragma warning disable'. Whether a pragma silences a rule is the same for every shape, and the focused tests of each rule (IgnoredOutcomeAnalyzerTests, TypeArgumentConstraintAnalyzerTests, DefaultProducingCallAnalyzerTests) keep it. The verifier's generated-code pass already never ran: every analyzer configures generated-code analysis. Seeded twice, each reverted by its inverse edit: - CMTK0003 dropping the first element of an array's tasks: DroppedTaskVariantTests fails 2 rows under the new mode, so the sweep still catches its own defects. - CMTK0003 reporting a probe rule only below a leading '#pragma warning disable': IgnoredOutcomeAnalyzerTests fails 16 tests in the pragma pass, DroppedTaskVariantTests passes. The pass is now the focused tests'. Co-Authored-By: Claude Opus 5.5 --- .../AnalyzerTest.cs | 14 ++++++++++++++ .../CodoMetis.TypeKit.Analyzers.Tests.csproj | 11 +++++++++++ .../DroppedTaskVariantTests.cs | 1 + .../QueryDelegateConditionTests.cs | 1 + .../TypeArgumentConstraintKindTests.cs | 1 + .../Variants/TypeArgumentSiteRun.cs | 1 + .../xunit.runner.json | 4 ++++ 7 files changed, 33 insertions(+) create mode 100644 test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs index 061fe50..9e98fad 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs @@ -31,6 +31,20 @@ public AnalyzerTest(string source, bool referenceTypeKit = true) } } +/// What a sweep over a variant catalogue leaves to the focused tests of its rule. +/// +/// The verifier runs a case that expects a diagnostic a second time, under #pragma warning disable for each id +/// it expects, and checks that nothing is left. Whether a pragma silences a rule is the compiler's, the same for every +/// shape, so a sweep paid for that pass once per row: 28% of DroppedTaskVariantTests' time (#245). The focused tests +/// of each rule keep it. The verifier's other pass, under an <auto-generated> header, never runs here: +/// every analyzer configures generated-code analysis (GeneratedCode.AnalysisFlags), and GeneratedCodeTests +/// holds what each rule does in generated code. +/// +internal static class VariantSweep +{ + public const TestBehaviors Behaviors = TestBehaviors.SkipSuppressionCheck; +} + /// A verifier run of one analyzer and its code fix: the source, then the source as the fix leaves it. internal sealed class CodeFixTest : CSharpCodeFixTest where TAnalyzer : DiagnosticAnalyzer, new() diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj index a6513c1..e287115 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj @@ -23,4 +23,15 @@ + + + + + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs index 4f3544e..34b80ff 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/DroppedTaskVariantTests.cs @@ -36,6 +36,7 @@ public Task A_dropped_task_is_reported_in_every_form_and_a_kept_one_never(string var (source, expected) = DroppedTaskShapes.Use(call, form); var test = new AnalyzerTest(source); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Declarations.cs", DroppedTaskShapes.Declarations)); test.TestState.AdditionalReferences.Add(RealTesting.Reference); test.ExpectedDiagnostics.AddRange( diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs index c0f84da..550e59e 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/QueryDelegateConditionTests.cs @@ -133,6 +133,7 @@ private static Task Sweep(QueryReceiver receiver, string element, string paramet var (source, expected) = QueryDelegateShapes.Use(receiver, element, parameters, where); var test = new AnalyzerTest(TypeArgumentKinds.Candidates); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Declarations.cs", QueryDelegateShapes.Declarations)); test.TestState.Sources.Add(("Use.cs", source)); test.ExpectedDiagnostics.AddRange(expected.Select(call => new DiagnosticResult("CMTK0009", DiagnosticSeverity.Warning).WithLocation(call.Location))); diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs index b295217..7b28e0e 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/TypeArgumentConstraintKindTests.cs @@ -215,6 +215,7 @@ public void The_catalogue_names_are_unique_and_its_pairs_reach_every_verdict() private static Task Run(string subject, string use, bool breaking) { var test = new AnalyzerTest(TypeArgumentKinds.Candidates + subject); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add(("Use.cs", use)); if (breaking) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs index ca86c37..bf2c7bb 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/Variants/TypeArgumentSiteRun.cs @@ -40,6 +40,7 @@ public static Task Verify(TypeArgumentSubject subject, string siteNam var use = Fill(site.Use, subject, argument); var test = new AnalyzerTest(Fill(TypeArgumentSites.Subjects + subject.Declarations, subject, argument)); + test.TestBehaviors = VariantSweep.Behaviors; test.TestState.Sources.Add((site.File, reported ? use : Unmarked(use))); if (reported) diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json b/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json new file mode 100644 index 0000000..82eb3ce --- /dev/null +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelMode": "all" +} From 0df62c1109f3b8bfed29b42c76f242143c633658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 11:09:23 +0100 Subject: [PATCH 28/30] test: run the EF Core query sweeps' rows in parallel, and create each SQL Server database once After #250, CI's Linux leg ends every run, and the EF Core project is half of it: its two query sweeps are one class each of 12,275 rows, which xunit's default mode ran one after another (#251). A row waits on SQLite, SQL Server or PostgreSQL more than it computes: at four threads the project took 55 s instead of 123 s for the same CPU. The project now runs with parallelMode "all" (xunit.runner.json). A row reads only what was seeded for its model, and each SQLite class opens its own database per test. SqlServerRoundTripTests opts out ([TestClass(DisableParallelization = true)]): each of its tests creates the same tables in one database. Seeded by removing the opt-out: 5 of its 7 tests fail. The whole suite found a race in the SQL Server fixture: DatabaseAsync's GetOrAdd ran its factory more than once for callers that arrived together, and the first rows of a sweep dropped and created typekit_queries under each other. Each database is now created once behind a Lazy, as the sweeps' own seeded databases are. SqlServerFixtureTests asks for one database from 32 callers at once; with the plain GetOrAdd restored it fails 3 runs of 3. The profile also answered the issue's other half: a CMTK0003 sweep row costs its own file, and reusing the compilation or compiling the declarations to metadata saves nothing, so DroppedTaskVariantTests is unchanged. Co-Authored-By: Claude Opus 5.5 --- ...s.TypeKit.EntityFrameworkCore.Tests.csproj | 13 ++++++++ .../SqlServer.cs | 7 +++-- .../SqlServerFixtureTests.cs | 31 +++++++++++++++++++ .../SqlServerRoundTripTests.cs | 5 +++ .../xunit.runner.json | 4 +++ 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index ce8a11d..cd08757 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -24,4 +24,17 @@ + + + + + diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs index f53ac77..899c152 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs @@ -32,7 +32,10 @@ public sealed class SqlServer : IAsyncLifetime .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("SQL Server is now ready for client connections")) .Build(); - private readonly ConcurrentDictionary> _databases = new(); + // Lazy, because GetOrAdd may run its factory more than once for callers that arrive together, and two of them + // would drop and create the same database under each other: in the project's parallel mode the first rows of a + // sweep did, "Database 'typekit_queries' already exists" (#251). + private readonly ConcurrentDictionary>> _databases = new(); private string ConnectionString(string database) => new SqlConnectionStringBuilder { @@ -44,7 +47,7 @@ public sealed class SqlServer : IAsyncLifetime }.ConnectionString; /// A database of its own on the server, created empty the first time it is asked for, and its connection string. - public Task DatabaseAsync(string name) => _databases.GetOrAdd(name, CreateAsync); + public Task DatabaseAsync(string name) => _databases.GetOrAdd(name, key => new Lazy>(() => CreateAsync(key))).Value; private async Task CreateAsync(string name) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs new file mode 100644 index 0000000..55da554 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs @@ -0,0 +1,31 @@ +using Microsoft.Data.SqlClient; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A guard on the harness: a database many rows ask for at once is created once, as the project's parallel mode +/// asks for it (#251). Created more than once, one caller drops it under another, "Database 'typekit_queries' already +/// exists", or after another has returned, and every row reading it fails. +/// +[Collection(SqlServerCollection.Name)] +[Trait("Needs", "Docker")] +public sealed class SqlServerFixtureTests(SqlServer server) +{ + [Fact] + public async Task A_database_asked_for_by_many_callers_at_once_is_created_once() + { + var name = $"typekit_fixture_{Guid.NewGuid():N}"; + + var answers = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() => server.DatabaseAsync(name)))); + + answers.Distinct().ShouldHaveSingleItem(); + + // A creation that lost the race could still drop the database after the winner returned; give it the time. + await Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + await using var connection = new SqlConnection(answers[0]); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT DB_NAME()"; + (await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)).ShouldBe(name); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs index 52cd744..802cb65 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs @@ -12,7 +12,12 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; /// and what SQL Server does differently from memory (design/knowledge KB-048 to KB-054) a value object does as the /// type it wraps does. /// +/// +/// Its tests run one at a time: each creates the same tables in the one database typekit_round_trips, which +/// the project's parallel mode would race ("There is already an object named 'Customers'", #251). +/// [Collection(SqlServerCollection.Name)] +[TestClass(DisableParallelization = true)] [Trait("Needs", "Docker")] public sealed class SqlServerRoundTripTests(SqlServer server) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json new file mode 100644 index 0000000..82eb3ce --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", + "parallelMode": "all" +} From f25a8d1241b1d2c4ea6330eb2b24f17111a28fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 11:46:11 +0100 Subject: [PATCH 29/30] test: run the EF Core rows one collection at a time again, keeping the fixture's create-once On CI, parallel rows bought the EF Core project almost nothing (#251, #252's run): the database containers share the runner's four cores with the tests, so what looked like waiting on a local machine with spare cores became CPU. The Linux test step went from 9m 28s to 9m 17s, and the Windows one from 5m 17s to 7m 11s, as the projects beside EF Core slowed. xunit.runner.json and SqlServerRoundTripTests' opt-out go. SqlServer.DatabaseAsync keeps creating each database once behind a Lazy, with SqlServerFixtureTests as its guard: GetOrAdd may run its factory twice for callers that arrive together, which rows run in parallel did. Co-Authored-By: Claude Opus 5.5 --- ...doMetis.TypeKit.EntityFrameworkCore.Tests.csproj | 13 ------------- .../SqlServer.cs | 4 ++-- .../SqlServerFixtureTests.cs | 6 +++--- .../SqlServerRoundTripTests.cs | 5 ----- .../xunit.runner.json | 4 ---- 5 files changed, 5 insertions(+), 27 deletions(-) delete mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index cd08757..ce8a11d 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -24,17 +24,4 @@ - - - - - diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs index 899c152..b7bf9bc 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs @@ -33,8 +33,8 @@ public sealed class SqlServer : IAsyncLifetime .Build(); // Lazy, because GetOrAdd may run its factory more than once for callers that arrive together, and two of them - // would drop and create the same database under each other: in the project's parallel mode the first rows of a - // sweep did, "Database 'typekit_queries' already exists" (#251). + // would drop and create the same database under each other: with the project's rows run in parallel, the first + // rows of a sweep did, "Database 'typekit_queries' already exists" (#251). private readonly ConcurrentDictionary>> _databases = new(); private string ConnectionString(string database) => new SqlConnectionStringBuilder diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs index 55da554..63a5478 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerFixtureTests.cs @@ -3,9 +3,9 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; /// -/// A guard on the harness: a database many rows ask for at once is created once, as the project's parallel mode -/// asks for it (#251). Created more than once, one caller drops it under another, "Database 'typekit_queries' already -/// exists", or after another has returned, and every row reading it fails. +/// A guard on the harness: a database many callers ask for at once is created once, as the rows of a sweep ask for +/// it when they run in parallel (#251). Created more than once, one caller drops it under another, "Database +/// 'typekit_queries' already exists", or after another has returned, and every row reading it fails. /// [Collection(SqlServerCollection.Name)] [Trait("Needs", "Docker")] diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs index 802cb65..52cd744 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs @@ -12,12 +12,7 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; /// and what SQL Server does differently from memory (design/knowledge KB-048 to KB-054) a value object does as the /// type it wraps does. /// -/// -/// Its tests run one at a time: each creates the same tables in the one database typekit_round_trips, which -/// the project's parallel mode would race ("There is already an object named 'Customers'", #251). -/// [Collection(SqlServerCollection.Name)] -[TestClass(DisableParallelization = true)] [Trait("Needs", "Docker")] public sealed class SqlServerRoundTripTests(SqlServer server) { diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json deleted file mode 100644 index 82eb3ce..0000000 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", - "parallelMode": "all" -} From fdd3ac255d2bd5cba499e994b5bd1b58662a7b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 13:21:47 +0100 Subject: [PATCH 30/30] docs: close 2.3.1: the changelog dated, the analyzer release recorded The 2.3.1 section is dated 2026-10-31 and links blob/v2.3.1/. Shipped.md moves its empty release header from 2.3.0 to 2.3.1, since Roslyn's release tracking refuses a header after an empty release (RS2007); 2.3.1 adds no rule. The comparison and the README's Status name 2.3.1, the TypeKit cells checked against the release branch on 2026-10-31, none changed. Closes #254 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 34 +++++++++---------- README.md | 2 +- docs/comparison.md | 9 ++--- .../AnalyzerReleases.Shipped.md | 2 +- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98277d..b39b96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,30 +10,30 @@ A version's heading reads `## — Unreleased` until the release is pre the release date. The release workflow refuses a tag whose section still says `Unreleased`, and the section is the GitHub release's notes. -## 2.3.1 — Unreleased +## 2.3.1 — 2026-10-31 ### Changed - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. -- Analyzers: CMTK0005 and CMTK0006 now warn for a type parameter bound by a value object beside `new()`, as in `where T : IValue, new()`, which CMTK0001 already reported: `new T[n]` and a `T` field nothing sets. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). -- Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). -- Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). -- Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). -- Analyzers: CMTK0006 now warns when a constructor sets a member only on another object it creates, `new Order(...) { Id = id }` or `other with { Id = id }`, which it took as setting its own. In a build that treats warnings as errors, that can report code that built before ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). -- EF Core: a query filter that orders values your context holds without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`, builds again; EF works it out in memory by your `CompareTo`. With a lambda, EF orders those values in the database, and the filter still fails ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). -- Analyzers: CMTK0005 now also warns at `Array.CreateInstance(typeof(OrderId), n)`, `Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)` and `ArrayPool.Rent(n)`, which create default slots as `new OrderId[n]` does. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). -- Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- Generators: a generic `ToString()`, or a generic or by-`in` `CompareTo(TSelf)`, is no longer taken for the one you may write: a value object printed what a record prints (`Level { Value = 3 }`), a closed set its type name, and the ordering was marked hand-written, so EF Core refused to order by it. A generic or by-reference overload of `TryFrom`, `Revalidate`, `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` is kept beside the generated member, where the build failed with CMTK1011 or CMTK1008 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). -- Generators: a member of a base record or of a closed set's base class that the type can't see, a `private` one such as a `private const int MaxValue`, doesn't count as its own: it stands in for no generated member, so the checks above leave it alone and the type is generated as in 2.3.0 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Analyzers: CMTK0005 and CMTK0006 now warn for a type parameter bound by a value object beside `new()`, as in `where T : IValue, new()`, which CMTK0001 already reported: `new T[n]` and a `T` field nothing sets. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#equality)). +- Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). +- Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- EF Core: `Min`, `Max`, `Order`, `OrderDescending` or `OrderBy(x => x)` over a value object with its own `CompareTo`, cast to `object` first, as in `Select(o => (object)o.Priority).Max()`, or read back through `??`, a member of a projection, a grouping's key, a join or a subquery's `First()`, as in `Select(o => new { Key = (object)o.Priority }).OrderBy(a => a.Key)`, now throws as the same query without the cast does. EF dropped the cast and answered the column's order, the opposite of memory's for a descending `CompareTo` ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- Analyzers: CMTK0006 now warns when a constructor sets a member only on another object it creates, `new Order(...) { Id = id }` or `other with { Id = id }`, which it took as setting its own. In a build that treats warnings as errors, that can report code that built before ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). +- EF Core: a query filter that orders values your context holds without a lambda, as in `HasQueryFilter(o => o.Priority == Limits.Max())`, builds again; EF works it out in memory by your `CompareTo`. With a lambda, EF orders those values in the database, and the filter still fails ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- Analyzers: CMTK0005 now also warns at `Array.CreateInstance(typeof(OrderId), n)`, `Array.CreateInstanceFromArrayType(typeof(OrderId[]), n)` and `ArrayPool.Rent(n)`, which create default slots as `new OrderId[n]` does. In a build that treats warnings as errors, that can report code that built before ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Generators: an overload of a generated member that C# tells apart by its type parameters, or by a parameter that is `ref`, `in` or `out` where the generated one's isn't (or the other way round), is now kept beside the generated member, on a value object, a closed set or a base of either, where the build failed with CMTK1011: a `Parse(string, IFormatProvider?)`, a `From(in int)`, a `TryParse` whose last parameter isn't `out` ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a generic `ToString()`, or a generic or by-`in` `CompareTo(TSelf)`, is no longer taken for the one you may write: a value object printed what a record prints (`Level { Value = 3 }`), a closed set its type name, and the ordering was marked hand-written, so EF Core refused to order by it. A generic or by-reference overload of `TryFrom`, `Revalidate`, `CompareTo(object)`, `Equals`, `GetHashCode` or `PrintMembers` is kept beside the generated member, where the build failed with CMTK1011 or CMTK1008 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a member of a base record or of a closed set's base class that the type can't see, a `private` one such as a `private const int MaxValue`, doesn't count as its own: it stands in for no generated member, so the checks above leave it alone and the type is generated as in 2.3.0 ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ### Fixed -- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, or a zero written as `0L` or, in a `stackalloc`, `(byte)0`: neither holds an element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). -- Analyzers: CMTK0006 no longer reports an explicit interface implementation that every constructor assigns through a cast, `((IHasId)this).Id = id` ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). -- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). +- Analyzers: CMTK0005 no longer reports an array with a constant zero in any one dimension, such as `new OrderId[0, count]`, or a zero written as `0L` or, in a `stackalloc`, `(byte)0`: neither holds an element ([CMTK0005](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0005)). +- Analyzers: CMTK0006 no longer reports an explicit interface implementation that every constructor assigns through a cast, `((IHasId)this).Id = id` ([CMTK0006](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/src/CodoMetis.TypeKit.Analyzers/README.md#cmtk0006)). +- OpenAPI: each element of a list, an array or a dictionary of `Patch` is described as a patch property is, the argument's schema admitting null, where it admitted anything. A nullable `Patch?` is its argument's schema admitting null, where it was an empty required component; System.Text.Json reads a null there as a `null` patch, as it reads a property left out ([Use it in an endpoint and in the document](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/v2.3.1/docs/merge-patches.md#use-it-in-an-endpoint-and-in-the-document)). ## 2.3.0 — 2026-10-29 diff --git a/README.md b/README.md index 04d5db6..9974e69 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,7 @@ your projects. Thanks to the Metalama team. ## Status -2.3.0 is the current version. 1.0.0, released on 28 September 2026, was the first. +2.3.1 is the current version. 1.0.0, released on 28 September 2026, was the first. [Migrating from 2.0 to 2.1](docs/migrating-to-2-1.md) lists what builds or runs differently since 2.0, and [Migrating from 1.5 to 2.0](docs/migrating-to-2.md) what changed since 1.5. diff --git a/docs/comparison.md b/docs/comparison.md index a14ce08..3f55fd0 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -14,7 +14,8 @@ Every fact here was read from each library's source, at the release named in the - **13 October 2026** for closed sets. - **14 October 2026**, again, for OpenAPI and Native AOT. - **29 October 2026** for SuperStrong.Types. Again for Thinktecture.Runtime.Extensions, at 10.6.0, and - LanguageExt, at 5.0.0-beta-78. And again for TypeKit's own columns, at 2.3.0. + LanguageExt, at 5.0.0-beta-78. +- **31 October 2026** for TypeKit's own columns, at 2.3.1. What was read for each library, and when, is kept in the repository's [verified facts](../design/knowledge/README.md). Each table below lists the entries it rests on. @@ -43,7 +44,7 @@ Sources: FluentResults [KB-018](../design/knowledge/KB-018-fluentresults.md), On ## Value objects in detail -| | TypeKit 2.3.0 | Vogen 8.0.7 (the 9.0.0-beta.2 tag the same; not on NuGet) | Thinktecture.Runtime.Extensions 10.6.0 | StronglyTypedId 1.0.0-beta08 | SuperStrong.Types 1.0.0 | +| | TypeKit 2.3.1 | Vogen 8.0.7 (the 9.0.0-beta.2 tag the same; not on NuGet) | Thinktecture.Runtime.Extensions 10.6.0 | StronglyTypedId 1.0.0-beta08 | SuperStrong.Types 1.0.0 | |---|---|---|---|---|---| | Declared by | Implementing `IValue` or `IValidatedValue<...>` on a `partial record` | `[ValueObject]` on a partial type | `[ValueObject]`, `[ComplexValueObject]` or `[SmartEnum]` on a partial type | `[StronglyTypedId]` on a partial struct | `[StrongType]` on a partial class, not a record or a struct | | Validation | `Create` returns a `Result`, and every generated way of creating a value runs it | `Validate` returns a `Validation`. `From` throws | A `ValidateFactoryArguments` partial method | None | A `Define()` method built from validators such as `IsPositive()`. `From` throws, `TryFrom` returns `false`, and JSON, parsing, the type converter and EF Core reads run it | @@ -70,7 +71,7 @@ register means describing it in the document by hand, type by type. ## Option and Result in detail -| | TypeKit 2.3.0 | LanguageExt 4.4.9 (5.0.0-beta-78) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | +| | TypeKit 2.3.1 | LanguageExt 4.4.9 (5.0.0-beta-78) | CSharpFunctionalExtensions 3.7.0 | ErrorOr 2.1.1 | |---|---|---|---|---| | Reaching the value | `Match`, `TryGetValue` and the combinators. No `.Value` | `Match`, `IfNone` and the like. No public `.Value` on `Option` | `.Value`, which throws on a failure | `.Value`, which returns `default` on an error | | A `default` instance | `Option`: `None`. `Result`: throws when used, and fails the build when written in code (CMTK0001) | `Option`: `None` | `Maybe`: none. `Result`: a success holding `default(T)` | A success holding `default(T)` | @@ -82,7 +83,7 @@ Sources: LanguageExt [KB-015](../design/knowledge/KB-015-languageext-option.md), ## Closed sets in detail -| | TypeKit 2.3.0 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.6.0 | +| | TypeKit 2.3.1 | Ardalis.SmartEnum 8.2.0 | Intellenum 2.0.1 | Thinktecture.Runtime.Extensions 10.6.0 | |---|---|---|---|---| | Declared by | Implementing `IClosedSet` on a `sealed partial class`, one `public static readonly` field per member | Deriving from `SmartEnum`, each member `new("Name", value)` | `[Intellenum]` on a partial class. Members as fields, `[Member]` attributes or a static constructor | `[SmartEnum]` on a partial class, each member `new("KEY")` | | Data on a member | Properties, set through the private constructor | The subclass's own fields | One underlying value. Richer data through a custom underlying type | Declared properties, which become constructor parameters | diff --git a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md index 10d875a..9692127 100644 --- a/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Shipped.md @@ -62,4 +62,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------- CMTK0014 | Usage | Warning | UndefinedEnumValueAnalyzer, a FaultKind or ConstructorAccessibility that names no member -## Release 2.3.0 +## Release 2.3.1