diff --git a/CHANGELOG.md b/CHANGELOG.md index 12eb00bc..eb623dcd 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.4.0. +- 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/0124-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0124-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md new file mode 100644 index 00000000..677240cd --- /dev/null +++ b/design/adr/0124-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -0,0 +1,110 @@ +# 0124. 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.4.0 epic +[#177](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/177), 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) under +[decision 119](0119-a-gap-found-before-the-tag-ships-in-that-release.md). 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: + +- **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.4.0: 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. Found before 2.4.0 is +tagged, it ships in 2.4.0, as decision 119 has such a gap do. 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/README.md b/design/adr/README.md index 245695ff..4df80fbd 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 | +| [0124](0124-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 that 2.4.0 ships under 119 (#191, #195, #197) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 3c1a23bd..2ecc17a3 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -169,6 +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. + - **Wherever the value object reaches it (decided 2026-10-30, [decision 124](adr/0124-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 3b2ac823..af0be486 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.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))) || 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 8d3818be..273ac263 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -220,15 +220,33 @@ 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. +- **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 +`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` 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. @@ -569,7 +587,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 +611,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/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index db8d4e9f..30f62e8e 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/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index 6bcef680..a9a02c20 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) { @@ -152,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 79a27700..76c3d09e 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,19 +268,24 @@ 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<>))); } // 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. - found.AddRange(ExplicitImplementationsOf(target, generatedInterfaces)); + // An interface's was replaced by the generated member instead, as silently. + 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 - // 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 +293,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,35 +325,101 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType ta } /// - /// The members of that explicitly implement a member of one of - /// , compared by definition, as names them. + /// 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. /// /// + /// /// 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). + /// + /// + /// 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) { - 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 member in implemented.Methods.Concat(implemented.Properties).Concat(implemented.Events)) + { + if (!target.TryFindImplementationForInterfaceMember(member, out var implementation) || !IsHandWritten(implementation)) continue; - foreach (var method in type.Methods) - if (method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(method) + where; + var declaring = implementation.DeclaringType; - foreach (var property in type.Properties) - if (property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(property) + where; + if (declaring.TypeKind == TypeKind.Interface && declaring.DeclaringAssembly.Equals(implemented.DeclaringAssembly)) continue; - foreach (var @event in type.Events) - if (@event is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && @event.ExplicitInterfaceImplementations.Any(OfOne)) yield return Describe(@event) + where; + 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). @@ -346,11 +427,22 @@ internal static IEnumerable ExplicitImplementationsOf(INamedType target, /// 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; @@ -366,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); } @@ -518,16 +621,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.Probes/Probes.cs b/test/CodoMetis.TypeKit.Generators.Probes/Probes.cs index 252c6a7e..e4c3c36c 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 858d93e9..53170f7b 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -169,10 +169,17 @@ 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("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")] public void What_the_generators_implement_or_attach_is_refused_by_name_on_a_set(string type, string declared) => @@ -311,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); @@ -365,6 +375,68 @@ 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); + + /// + /// 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 /// PrintMembers were replaced by the generated ToString(), printing what they hid; an @@ -383,9 +455,39 @@ 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")] + [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. @@ -1107,6 +1209,330 @@ 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; + + // 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; + + // 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 + // 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 +1841,28 @@ 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(); } + + // 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(); } + [System.ComponentModel.TypeConverter(typeof(System.ComponentModel.StringConverter))] public sealed partial class SetWithTypeConverterAttribute : IClosedSet { public static readonly SetWithTypeConverterAttribute A = new(); } @@ -1459,6 +1907,56 @@ 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; + + 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 + { + 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 = """ @@ -1485,8 +1983,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; @@ -1498,7 +1997,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; @@ -1509,8 +2008,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); @@ -1528,6 +2028,7 @@ await File.WriteAllTextAsync(Path.Combine(directory, "Consumer.csproj"), + {(library is null ? "" : $"")} """); @@ -1549,18 +2050,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"]) { @@ -1573,11 +2122,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/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 14f02dab..53f2b5c0 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 47a89c5b..a44cd2ed 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", @@ -1225,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", @@ -1255,6 +1282,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 +1312,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",