From 9f749a488578d272b3cf87e167d438f1bdb216fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 23:22:54 +0100 Subject: [PATCH] fix: refuse a closed set's base-class member the generators introduce, and keep its sealed ToString() ClosedSetTypes read the set's own members alone. A base class's Parse(string), Parse(string, bool), TryParse(string?, bool, out TSelf), Match, a static All, a protected _name, an IParsable it implements, and its sealed ToString() failed the aspect (LAMA0500, LAMA0502, LAMA0503, LAMA0512), naming nothing the user wrote, in this project and from another. A base class's ToString() that is not sealed was replaced by the generated one without a word, where a value object's base record's has been CMTK1011 since phase 11. The checks of names and signatures now read each base class, private members aside (a base's private _name and _all built and still do), and an IParsable a base implements is named with its base. A base's sealed ToString() is the seam, through ValueObjectDeclaration's own check; one not sealed is CMTK1011 with the value objects' remedy. Decision 121 records refusing it over taking it as the seam, which would change what such a set prints in a patch, and the exception to decision 34: such a set built on 2.3.0. CS0738 joins CS0535 as what a refused set may leave, where a base's All has another type. Input class (GTS): a generated closed-set member, by where it is declared. Catalogue in BuildOutcomeTests: each member above in a base class of this project, Parse, IParsable and both ToString() forms in a library base, QualifiedName (refused before, through the interface mapping), and four that build: a sealed ToString() of either assembly, private fields, a [JsonConverter] on the base. InheritedMembersTests holds what a set over a sealed ToString() prints. Siblings found by the catalogue: All, Match, Parse(string, bool), TryParse(string?, bool, out) and a protected _name, beyond the three the report named. Removing the base reads and the ToString() refusal fails the 11 new refusal rows and the two LAMA guards (13 of 310 BuildOutcomeTests); reverting the seam fails the test project's build on MaskedSet. Refs #205 Defect-Class: GTS Found-By: CA Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 29 +++++-- design/adr/README.md | 2 +- design/aspects.md | 4 +- design/closed-sets.md | 2 +- .../ClosedSetAspect.cs | 8 ++ .../ClosedSetTypes.cs | 43 ++++++++-- src/CodoMetis.TypeKit.Generators/README.md | 6 +- .../BuildOutcomeTests.cs | 84 ++++++++++++++++++- .../GeneratedSurface.verified.txt | 17 ++++ .../InheritedMembersTests.cs | 33 ++++++++ test/oracles/claims.json | 27 ++++++ 12 files changed, 235 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983987c9..59a60665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ section is the GitHub release's notes. - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). - Generators: an `Equals` a value object declares for one of its base records' types, such as `Equals(DeepBase?)`, now fails the build (CMTK1011), so your build can fail when you upgrade. A `HashSet` compared by it while `==` compared the underlying value; normalise values in `Create` instead ([Equality](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#equality)). - Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a closed set over a base class that declares a member the generators add now fails the build with CMTK1011 naming it, where it failed with a Metalama error that named nothing you wrote: its `Parse`, `TryParse`, `Match` or `All`, and an `IParsable` it implements. A base class's `ToString()` that isn't sealed now fails the build too (CMTK1011), so your build can fail when you upgrade: the generated `ToString()` replaced it without a word. Declare `ToString()` in the set, or seal the base's, which is then kept ([Closed sets](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#closed-sets)). - Generators: `IValueObject`, `IValueObjectMaterializer` or `IPlainValueObject` declared through an interface of yours, such as an `IEntityId : IValueObject`, or by a base record now fails with CMTK1011 naming that interface, where it failed with a Metalama error (LAMA0512) that named nothing you wrote. To constrain generic code, use `IValueObject` itself ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md index 1f918c8d..e9771b58 100644 --- a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -9,8 +9,9 @@ Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191 operators, where an interface's explicit implementation is kept, from an outside review of that pull request; widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), [#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and -[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, and by -[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192) before 2.3.1 was tagged; refines +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review, by +[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192), and by +[#205](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/205) from a sweep, before 2.3.1 was tagged; refines [decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to [decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the generators introduce written by hand, and an explicit implementation of an interface they implement, on the value @@ -65,6 +66,13 @@ generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: Guid>` written to constrain generic code (#192). So did a closed set's `IParsable` declared through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the generated members without a word. +- **A closed set's base class counted for nothing** (#205, measured on `release/2.3.1` at `dbdd44e`). + `ClosedSetTypes` read the set's own members. A base class's `Parse(string)`, `Parse(string, bool)`, + `TryParse(string?, bool, out TSelf)`, `Match`, a static `All`, a protected `_name`, an `IParsable` it + implements, and its `sealed` `ToString()` failed the aspect (LAMA0500, LAMA0502, LAMA0503, LAMA0512), in this + project and from another. Its `ToString()` that is not sealed was replaced by the generated one without a word, + where a value object's base record's is CMTK1011. A base's private `_name` and `_all`, and a `[JsonConverter]` on + it, built and still do. - **An interface's `==`, `!=` and `IEquatable.Equals` are replaced by the record's own**, in a plain record as in a value object, so the generators change nothing there. For the same reason `ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized @@ -97,6 +105,12 @@ never as an explicit implementation. A closed set's `IClosedSet` is held - **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one rule set, which the generated code calls too, and builds. +- **A closed set's base class counts as a value object's base record does** (#205). Its members of a generated + name or signature, other than private ones, and an `IParsable` it implements are CMTK1011, naming the + base. Its `sealed` `ToString()` is the seam; one that is not sealed is CMTK1011, which says to declare + `ToString()` in the set or seal the base's. Taking an unsealed one as the seam was the alternative: C# would keep + it in a derived class, but the set has printed its member's name over it since closed sets shipped, and taking it + would change what such a set prints in a patch, without a word. - **`IConvertible`, the three contracts and a set's `IParsable` declared through an interface or by a base record** are CMTK1011, naming the interface or the base, where the aspect failed. Accepting the contracts through an interface, which the generated `Value` would implement, was the alternative; it would add behaviour to a patch @@ -112,8 +126,9 @@ never as an explicit implementation. A closed set's `IClosedSet` is held **The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set that reaches an interface's explicit implementation of a generated member, or a base record's in another assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; -one whose generic `T.Create` reached a base's `Create` beside its own; and one over a base record that declares -`CompareTo(TSelf)`, `TryFrom` or `Revalidate`. Each is code that never runs (an +one whose generic `T.Create` reached a base's `Create` beside its own; one over a base record that declares +`CompareTo(TSelf)`, `TryFrom` or `Revalidate`; and a closed set over a base class whose `ToString()` is not sealed, +whose generated `ToString()` replaced the base's. Each is code that never runs (an interface's default the generated member replaced) or a second answer beside the generated one (a base's kept member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, @@ -127,5 +142,7 @@ interface, a record class over a reference type, and from another assembly (`IPr `IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a base record's public member of each kind, in this project and in a library the generators do not run in, which the fixture builds beside its consumers; a base record's `Create`; a base record's seams, and its `FromKnownGood`, which builds; a closed set through an interface of either -assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing -ordering, an interface's `==`, an `INumberBase` value object, and one `Create` over a base record. +assembly and over the library's base class; a closed set over a base class of either assembly that declares each +generated member (#205); and the cases that build: a `ToString()` seam, a `Uri`'s missing +ordering, an interface's `==`, an `INumberBase` value object, one `Create` over a base record, and a closed set over a base class's +`sealed` `ToString()`, private fields or `[JsonConverter]`. diff --git a/design/adr/README.md b/design/adr/README.md index fd60f6d0..9abd10ba 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 9ee0a453..dc7ef6a1 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -180,7 +180,9 @@ member of a type in another assembly, so a base record's from a referenced project was missed as well. `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>` and `IPlainValueObject<,>` declared through an interface or by a base record, and a closed set's `IParsable` through an interface, are CMTK1011 where - they failed the aspect (LAMA0041, LAMA0512, #192). Not refused: an interface's `==`, which the record's own + they failed the aspect (LAMA0041, LAMA0512, #192). A closed set's base class counts as a base record does: its + members of a generated name or signature and its `IParsable` are CMTK1011, its `sealed` `ToString()` is + the seam and one not sealed is CMTK1011 (#205). Not refused: an interface's `==`, which the record's own replaces anyway, and `INumberBase`'s UTF-8 bridges. - **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).** A derived record's synthesized equality calls the base's, which compares the base's fields and diff --git a/design/closed-sets.md b/design/closed-sets.md index 09f9766b..2ff3e871 100644 --- a/design/closed-sets.md +++ b/design/closed-sets.md @@ -12,7 +12,7 @@ The consumer's guide is [docs/closed-sets.md](../docs/closed-sets.md). - **Generated:** `Name`, `QualifiedName` (the nesting chain with dots, `Order.Status.Draft`), `All` in declaration order, `Match` with one required delegate per member, `TryParse`/`Parse` (ordinal, each with an `ignoreCase` overload), `IParsable` explicitly, `ToString()` unless written by - hand (the one seam), a nested JSON converter and type converter, `GeneratedClosedSetAttribute` + hand (the one seam; a base class's `sealed` one counts, one not sealed is refused), a nested JSON converter and type converter, `GeneratedClosedSetAttribute` for run-time discovery, and a private constructor where none is declared. - **Members are singletons.** Equality is reference equality, `Parse` answers the member itself, and nothing outside the type can make an instance (CMTK1016, CMTK1017). A record is refused (CMTK1020): diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs index bd675fb5..cef30513 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetAspect.cs @@ -79,6 +79,14 @@ public override void BuildAspect(IAspectBuilder builder) foreach (var (member, earlier, reason) in ClosedSetTypes.CaseCollisions(members)) problems.Add(ClosedSetMemberNameTaken.WithArguments((target, member.Name, reason.Replace("{0}", $"'{target.Name}.{earlier.Name}'")))); + // A base class's ToString() that is not sealed was replaced by the generated one without a word (#205), + // as a value object's base record's was. A sealed one is the seam (DeclaresToString). + if (ValueObjectDeclaration.UnsealedInheritedToString(target) is { } inheritedToString) + problems.Add(HandWrittenGeneratedMember.WithArguments((target, + $"{inheritedToString.ToDisplayString()} in its base type, which is not sealed", + $"its generated ToString() replaces it and prints the member's name; declare ToString() in {target.Name} (it may return base.ToString()), " + + "or seal the base's, which C# keeps in every derived class and the generators then keep too"))); + // A hand-written member the generators introduce failed the aspect with a Metalama error that // names no fix; a hand-written ToString() is the seam and is kept instead. var declaredByHand = ClosedSetTypes.GeneratedMembersDeclaredByHand(target, members); diff --git a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs index af0be486..6deb3545 100644 --- a/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs +++ b/src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs @@ -194,6 +194,12 @@ private static string WithoutComments(string text) /// set's too: the generated Name and TryParse replaced an interface's without a word (#191, /// measured 2026-10-30). IParsable<TSelf> declared through an interface failed the aspect as /// declaring it does (LAMA0512). + /// + /// A base class's count as the set's own (#205): its Parse, TryParse, Match or All, + /// and an IParsable<TSelf> it implements, failed the aspect (LAMA0500, LAMA0503, LAMA0512) naming + /// nothing the user wrote, in this project and from another (measured 2026-10-30). A private member of a base, + /// such as a _name field, is out of the set's sight and builds. + /// /// public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList members) { @@ -201,16 +207,33 @@ public static IReadOnlyList GeneratedMembersDeclaredByHand(INamedType se var boolean = TypeFactory.GetType(SpecialType.Boolean); var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set); var memberNames = new HashSet(members.Select(member => member.ToDisplayString())); + var generatedNames = GeneratedNames(set); + var bases = ValueObjectDeclaration.SelfAndBases(set).Skip(1).ToList(); - return + IEnumerable Declared(Func> find) => + ValueObjectDeclaration.SelfAndBases(set).SelectMany(type => find(type).Select(method => type.Equals(set) + ? ValueObjectDeclaration.Describe(method) + : $"{ValueObjectDeclaration.Describe(method)} in its base type {type.ToDisplayString()}")); + + List found = [ - .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(set)]).Where(declared => !memberNames.Contains(declared)), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe), - .. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe), + .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. generatedNames]).Where(declared => !memberNames.Contains(declared)), + .. bases.SelectMany(type => type.Fields.Concat(type.Properties).Concat(type.Events) + .Where(member => member is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false } && member.Accessibility != Accessibility.Private && generatedNames.Contains(member.Name)) + .Select(member => $"{member.ToDisplayString()} in its base type {type.ToDisplayString()}")), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set)))), + .. Declared(type => type.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count))), .. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]), .. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"), .. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"), + .. bases.Where(type => type.ImplementedInterfaces.SelectMany(implemented => implemented.AllImplementedInterfaces.Prepend(implemented)).Any(inherited => inherited.Equals(parsable))) + .Select(type => $"the interface {parsable.ToDisplayString()} in its base type {type.ToDisplayString()}"), + ]; + + return + [ + .. found.Distinct(), .. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute))) || attribute.Type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute))) || attribute.Type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>)))) @@ -233,7 +256,11 @@ private static bool IsGeneratedMatch(IMethod method, int memberCount) => && func.Definition.Equals(TypeFactory.GetNamedType(typeof(Func<>))) && func.TypeArguments[0].Equals(method.TypeParameters[0])); - /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. - public static bool DeclaresToString(INamedType set) => - set.Methods.Any(method => method is { Name: "ToString", IsStatic: false, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 }); + /// + /// Whether a ToString() is written by hand: the seam, kept in place of the generated one. A base class's + /// sealed one is the seam too, as for a value object: C# keeps it in every derived class, and generating + /// one failed the aspect (LAMA0502, #205). A base class's one that is not sealed is refused instead + /// (). + /// + public static bool DeclaresToString(INamedType set) => ValueObjectDeclaration.DeclaresToString(set); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 06cd6145..3a248e8e 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -463,7 +463,11 @@ public sealed partial class OrderStatus : IClosedSet, IParsable.TryParse(string?, out SetWithNameThroughInterface) in its interface ISetNamesItself")] [InlineData("SetOverLibraryBase", "the explicit implementation of IClosedSet.Name in its base type ExplicitSetBase")] [InlineData("SetWithNameThroughLibraryInterface", "the explicit implementation of IClosedSet.Name in its interface IProbeSetNames")] + [InlineData("SetOverParsingBase", "ParsingSetBase.Parse(string) in its base type ParsingSetBase")] + [InlineData("SetOverIgnoreCaseParsingBase", "IgnoreCaseParsingSetBase.Parse(string, bool) in its base type IgnoreCaseParsingSetBase")] + [InlineData("SetOverTryParsingBase", "TryParsingSetBase.TryParse(string?, bool, out SetOverTryParsingBase) in its base type TryParsingSetBase")] + [InlineData("SetOverMatchingBase", "MatchingSetBase.Match(Func) in its base type MatchingSetBase")] + [InlineData("SetOverParsableBase", "the interface IParsable in its base type ParsableSetBase")] + [InlineData("SetOverAllListingBase", "AllListingSetBase.All in its base type AllListingSetBase")] + [InlineData("SetOverProtectedFieldBase", "ProtectedFieldSetBase._name in its base type ProtectedFieldSetBase")] + [InlineData("SetOverQualifiedBase", "QualifiedSetBase.QualifiedName in its base type QualifiedSetBase")] + [InlineData("SetOverOpenToStringBase", "OpenToStringSetBase.ToString() in its base type, which is not sealed, so it is not generated: its generated ToString() replaces it")] + [InlineData("SetOverLibraryParsingBase", "LibraryParsingSetBase.Parse(string) in its base type LibraryParsingSetBase")] + [InlineData("SetOverLibraryParsableBase", "the interface IParsable in its base type LibraryParsableSetBase")] + [InlineData("SetOverLibraryOpenToString", "LibraryOpenToStringSetBase.ToString() in its base type, which is not sealed")] [InlineData("SetWithTypeConverterAttribute", "the attribute TypeConverter")] [InlineData("SetWithJsonConverterAttribute", "the attribute JsonConverter")] public void What_the_generators_implement_or_attach_is_refused_by_name_on_a_set(string type, string declared) => consumer.Errors.ShouldContain(error => error.Id == "CMTK1011" && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(declared), consumer.Output); + /// + /// What a base class declares that the set cannot reach, or that the generators keep, is no refusal: its + /// sealed ToString() is the seam, as for a value object, where it failed the aspect (LAMA0502, + /// #205); its private _name and _all, and a [JsonConverter] on it, which the set's own + /// replaces, built before and still do (measured 2026-10-30). + /// + [Theory] + [InlineData("SetOverSealedToStringBase")] + [InlineData("SetOverLibrarySealedToString")] + [InlineData("SetOverPrivateFieldsBase")] + [InlineData("SetOverAttributedBase")] + public void What_a_set_s_base_class_declares_out_of_the_generators_way_is_not_refused(string type) => + consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}"), consumer.Output); + /// /// A member whose name starts with the Kelvin sign (U+212A) and one that starts with K are two names /// to TryParse(name, ignoreCase: true), which ignores case ordinally, and one to Match, @@ -607,11 +633,12 @@ public void A_static_constructor_is_allowed() => [Fact] public void Every_error_is_one_of_the_intended_ones() => consumer.Errors.Select(error => error.Id).Distinct().Order() - .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK0013", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CMTK1023", "CS0535"], ignoreOrder: false, customMessage: consumer.Output); + .ShouldBe(["CMTK0001", "CMTK0003", "CMTK0004", "CMTK0005", "CMTK0006", "CMTK0007", "CMTK0008", "CMTK0009", "CMTK0012", "CMTK0013", "CMTK1000", "CMTK1001", "CMTK1002", "CMTK1003", "CMTK1004", "CMTK1005", "CMTK1006", "CMTK1007", "CMTK1008", "CMTK1009", "CMTK1010", "CMTK1011", "CMTK1012", "CMTK1013", "CMTK1014", "CMTK1015", "CMTK1016", "CMTK1017", "CMTK1018", "CMTK1019", "CMTK1020", "CMTK1021", "CMTK1022", "CMTK1023", "CS0535", "CS0738"], ignoreOrder: false, customMessage: consumer.Output); /// /// CS0535 is allowed above only for a refused closed set, whose interface members the generators - /// did not implement. A value object or a generated set that lost a member the same way is a + /// did not implement, and CS0738 where a base class's member of a generated name has another type + /// (a static int All, #205). A value object or a generated set that lost a member the same way is a /// regression the list of ids alone would let through. /// /// @@ -625,7 +652,7 @@ public void The_declarations_build_without_a_compiler_error() => [Fact] public void CS0535_names_only_a_refused_closed_set() => - consumer.Errors.Where(error => error.Id == "CS0535") + consumer.Errors.Where(error => error.Id is "CS0535" or "CS0738") .ShouldAllBe(error => RefusedClosedSet().IsMatch(error.Message), consumer.Output); [GeneratedRegex(@"^'(?:Consumer\.)?Set\w+(?:\.\w+)?(?:)?' does not implement interface member ")] @@ -2002,6 +2029,43 @@ public interface ISetNamesItself : IClosedSet where TSelf : class, // the interface, its Name was the base's, and EF Core stored that and read no member's name back (#191). public sealed partial class SetOverLibraryBase : Library.ExplicitSetBase { public static readonly SetOverLibraryBase A = new(); } + // A base class that declares a member the generators introduce into a set (#205): each failed the aspect + // (LAMA0500, LAMA0502, LAMA0512) naming nothing the user wrote, and an unsealed ToString() was replaced + // without a word. A sealed ToString() is the seam, as C# keeps it in every derived class. + public abstract class SealedToStringSetBase { public sealed override string ToString() => "***"; } + public sealed partial class SetOverSealedToStringBase : SealedToStringSetBase, IClosedSet { public static readonly SetOverSealedToStringBase A = new(); } + public abstract class OpenToStringSetBase { public override string ToString() => "***"; } + public sealed partial class SetOverOpenToStringBase : OpenToStringSetBase, IClosedSet { public static readonly SetOverOpenToStringBase A = new(); } + public abstract class ParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + public sealed partial class SetOverParsingBase : ParsingSetBase, IClosedSet { public static readonly SetOverParsingBase A = new(); } + public abstract class IgnoreCaseParsingSetBase { public static TSelf Parse(string name, bool ignoreCase) => throw new System.NotSupportedException(); } + public sealed partial class SetOverIgnoreCaseParsingBase : IgnoreCaseParsingSetBase, IClosedSet { public static readonly SetOverIgnoreCaseParsingBase A = new(); } + public abstract class TryParsingSetBase { public static bool TryParse(string? name, bool ignoreCase, out TSelf member) => throw new System.NotSupportedException(); } + public sealed partial class SetOverTryParsingBase : TryParsingSetBase, IClosedSet { public static readonly SetOverTryParsingBase A = new(); } + public abstract class MatchingSetBase { public TResult Match(System.Func only) => only(); } + public sealed partial class SetOverMatchingBase : MatchingSetBase, IClosedSet { public static readonly SetOverMatchingBase A = new(); } + public abstract class ParsableSetBase : System.IParsable where TSelf : ParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + public sealed partial class SetOverParsableBase : ParsableSetBase, IClosedSet { public static readonly SetOverParsableBase A = new(); } + public abstract class PrivateFieldsSetBase { private string _name = ""; private object? _all; public override int GetHashCode() => _name.Length + (_all is null ? 0 : 1); } + public sealed partial class SetOverPrivateFieldsBase : PrivateFieldsSetBase, IClosedSet { public static readonly SetOverPrivateFieldsBase A = new(); } + public abstract class QualifiedSetBase { public string QualifiedName => "***"; } + public abstract class AllListingSetBase { public static int All => 0; } + public abstract class ProtectedFieldSetBase { protected string _name = ""; } + public sealed partial class SetOverProtectedFieldBase : ProtectedFieldSetBase, IClosedSet { public static readonly SetOverProtectedFieldBase A = new(); } + public sealed partial class SetOverAllListingBase : AllListingSetBase, IClosedSet { public static readonly SetOverAllListingBase A = new(); } + public sealed partial class SetOverQualifiedBase : QualifiedSetBase, IClosedSet { public static readonly SetOverQualifiedBase A = new(); } + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public abstract class AttributedSetBase { } + public sealed partial class SetOverAttributedBase : AttributedSetBase, IClosedSet { public static readonly SetOverAttributedBase A = new(); } + public sealed partial class SetOverLibrarySealedToString : Library.LibrarySealedToStringSetBase, IClosedSet { public static readonly SetOverLibrarySealedToString A = new(); } + public sealed partial class SetOverLibraryOpenToString : Library.LibraryOpenToStringSetBase, IClosedSet { public static readonly SetOverLibraryOpenToString A = new(); } + public sealed partial class SetOverLibraryParsingBase : Library.LibraryParsingSetBase, IClosedSet { public static readonly SetOverLibraryParsingBase A = new(); } + public sealed partial class SetOverLibraryParsableBase : Library.LibraryParsableSetBase, IClosedSet { public static readonly SetOverLibraryParsableBase A = new(); } + public interface ISetParsesItself : System.IParsable where TSelf : ISetParsesItself; public sealed partial class SetWithParsableThroughInterface : IClosedSet, ISetParsesItself { public static readonly SetWithParsableThroughInterface A = new(); } @@ -2097,6 +2161,20 @@ public abstract record PublicMinMaxBase : System.Numerics.IMinMaxValue throw new System.NotSupportedException(); } + // Base classes of a closed set that declare a member the generators introduce into it (#205). + public abstract class LibrarySealedToStringSetBase { public sealed override string ToString() => "***"; } + + public abstract class LibraryOpenToStringSetBase { public override string ToString() => "***"; } + + public abstract class LibraryParsingSetBase { public static TSelf Parse(string name) => throw new System.NotSupportedException(); } + + public abstract class LibraryParsableSetBase : System.IParsable where TSelf : LibraryParsableSetBase + { + static TSelf System.IParsable.Parse(string s, System.IFormatProvider? provider) => throw new System.NotSupportedException(); + + static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out TSelf result) { result = null!; return false; } + } + // The set's All listed none, its Name through the interface was the base's, and EF Core stored that. public abstract class ExplicitSetBase : IClosedSet where TSelf : ExplicitSetBase { diff --git a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt index 53f2b5c0..67b03eef 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt +++ b/test/CodoMetis.TypeKit.Generators.Tests/GeneratedSurface.verified.txt @@ -1951,6 +1951,23 @@ MaskedOverALocalRecord static op_LessThanOrEqual(MaskedOverALocalRecord, MaskedOverALocalRecord) : Boolean extension static MaskedOverALocalRecordExtensions.GetValue(MaskedOverALocalRecord) : String extension static MaskedOverALocalRecordExtensions.ValueOrNull(MaskedOverALocalRecord) : String +MaskedSet + Match(Func, Func) : TResult + Name : String + QualifiedName : String + attribute CompilerGeneratedAttribute + attribute GeneratedClosedSetAttribute + attribute JsonConverterAttribute + attribute TypeConverterAttribute + implements IClosedSet + implements IParsable + nested MaskedSetJsonConverter : JsonConverter + nested MaskedSetTypeConverter : TypeConverter + static All : IReadOnlyList + static Parse(String) : MaskedSet + static Parse(String, Boolean) : MaskedSet + static TryParse(String, Boolean, out MaskedSet) : Boolean + static TryParse(String, out MaskedSet) : Boolean OverABetweenExplicitEqualityRecord $() : OverABetweenExplicitEqualityRecord CompareTo(Object) : Int32 diff --git a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs index 30d797e8..3ab32378 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/InheritedMembersTests.cs @@ -71,6 +71,23 @@ public sealed partial record MaskedOverALocalRecord : LocalMaskingRecord, IValue /// Over a sealed ToString() in another assembly. public sealed partial record MaskedOverALibraryRecord : ProbeMaskingRecord, IValue; +/// A base class in this assembly whose ToString() is sealed. +public abstract class LocalMaskingSetBase +{ + /// What every derived class prints. + public sealed override string ToString() => "***"; +} + +/// A closed set over a sealed ToString(), which failed the aspect (LAMA0502, #205). +public sealed partial class MaskedSet : LocalMaskingSetBase, CodoMetis.TypeKit.ClosedSets.IClosedSet +{ + /// The first member. + public static readonly MaskedSet Open = new(); + + /// The second member. + public static readonly MaskedSet Closed = new(); +} + /// /// What a value object inherits from a base record. Refusals are build errors and live in /// ; this is what is generated. @@ -185,4 +202,20 @@ public void A_sealed_ToString_in_a_base_record_is_the_seam(object value) JsonSerializer.Serialize(value, value.GetType()).ShouldBe("\"SECRET\""); TypeDescriptor.GetConverter(value.GetType()).ConvertToInvariantString(value).ShouldBe("SECRET"); } + + /// + /// A base class's sealed ToString() is a closed set's seam too (#205): it is what the set prints, while + /// its name, parsing and JSON are generated as usual. + /// + [Fact] + public void A_sealed_ToString_in_a_closed_set_s_base_class_is_the_seam() + { + MaskedSet.Closed.ToString().ShouldBe("***"); + $"{MaskedSet.Closed}".ShouldBe("***"); + + MaskedSet.Closed.Name.ShouldBe("Closed"); + MaskedSet.All.ShouldBe([MaskedSet.Open, MaskedSet.Closed]); + MaskedSet.Parse("Closed").ShouldBeSameAs(MaskedSet.Closed); + JsonSerializer.Serialize(MaskedSet.Closed).ShouldBe("\"Closed\""); + } } diff --git a/test/oracles/claims.json b/test/oracles/claims.json index f5c1df18..7e87aad6 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1238,6 +1238,33 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-set-s-base-class-members", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "Any other generated member written by hand fails the build (CMTK1011), in the set or in a class it derives from.", + "claim": "A generated member of a closed set written in a base class fails the build with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, + { + "id": "C22-a-set-s-base-class-tostring", + "source": "src/CodoMetis.TypeKit.Generators/README.md#closed-sets", + "quote": "A `sealed` one is kept, since C# keeps it in every derived class. One that isn't sealed fails the build (CMTK1011), because the generated `ToString()` would replace it.", + "claim": "A closed set over a base class's sealed ToString() prints it; over one not sealed, the build fails with CMTK1011.", + "oracle": "claim", + "surfaces": [ + "IClosedSet.*" + ], + "heldBy": [ + "BuildOutcomeTests", + "InheritedMembersTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors",