From 5f5ad55e61eef46bcbe15933a79a0b2a746e3027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:39:50 +0100 Subject: [PATCH] fix: refuse a seam a base record declares in place of the value object A base record's CompareTo(TSelf) was not seen as the comparison seam: DeclaresCompareTo read the value object's own methods, so no [HandWrittenOrdering] was put on it and an EF Core query ordered by the column ([1, 2]) where memory ordered [2, 1]. The generated CompareTo(TSelf) was not introduced, since OverrideStrategy.Ignore saw the inherited one, and the generated operators and CompareTo(object) called left.CompareTo(right), which C# binds to the generated CompareTo(object), declared on the more derived type: < overflowed the stack and ended the process. A base's TryFrom(T) and Revalidate() were the value object's in the same way, and the generated ones, which run Create, were not introduced. A base's CompareTo(object) failed the aspect (LAMA0500). HandWrittenComparisonMembers reports a base record's CompareTo(TSelf) and CompareTo(object) (CMTK1008), named once where the interface mapping finds the second too. ValueObjectDeclaration.InheritedSeams reports a base's TryFrom(T) and Revalidate() on a validated value object (CMTK1011), whose remedy says to declare it on the value object. A base's FromKnownGood(T) is another signature than the generated one, which takes the caller's expression too and hides it, so it stays allowed. Decision 121 records the choice over taking a base's as the seam. Input class (GTS): a seam, by where it is declared. Catalogue: each seam in a base record (CompareTo(TSelf), CompareTo(object), TryFrom, Revalidate, FromKnownGood), beside the value object's own seams the suite already holds. A base's sealed ToString() stays the seam. Reverting the source to 03e199b fails 6 of 285 BuildOutcomeTests; each guard's own seed fails only its rows, the named-once check included. Refs #194 Defect-Class: GTS Found-By: OR Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...ed-wherever-the-value-object-reaches-it.md | 28 ++++++-- design/adr/README.md | 2 +- design/aspects.md | 4 +- src/CodoMetis.TypeKit.Generators/README.md | 4 ++ .../ValueObjectAspect.cs | 11 +++ .../ValueObjectComparableAspect.cs | 17 ++++- .../ValueObjectDeclaration.cs | 21 +++++- .../BuildOutcomeTests.cs | 67 ++++++++++++++++++- test/oracles/claims.json | 14 ++++ 10 files changed, 156 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d03e162..2ceeee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ section is the GitHub release's notes. - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1. - Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). +- Generators: a `CompareTo(TSelf)`, `CompareTo(object)`, `TryFrom` or `Revalidate` a value object inherits from a base record now fails the build (CMTK1008, CMTK1011), so your build can fail when you upgrade. It stood in for the generated member: an inherited `CompareTo` made `<` overflow the stack and an EF Core query order by the column, and an inherited `TryFrom` need not run `Create`. Declare it on the value object ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md index 9866b1f..ccff4ec 100644 --- a/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md +++ b/design/adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md @@ -7,8 +7,9 @@ Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191 [#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while [#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic operators, where an interface's explicit implementation is kept, from an outside review of that pull request; -widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195) and -[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197), from a second outside review; refines +widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195), +[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197) and +[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194), from a second outside review; refines [decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to [decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the generators introduce written by hand, and an explicit implementation of an interface they implement, on the value @@ -48,6 +49,14 @@ generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches: `Create`, which accepted, and generic `T.Create("x")` the base's, which refused. Declaring the marker on the value object as well re-implements it, and both agree. In the same project such a base carries the marker and is refused already (CMTK1022). +- **A seam a base record declares stood in for the generated member** (#194). A base's `CompareTo(TSelf)` was not + seen as the seam: no `[HandWrittenOrdering]`, so an EF Core query ordered by the column (`[1, 2]`) where memory + ordered `[2, 1]`; the generated `CompareTo(TSelf)` was not introduced, since one existed, and the generated + operators and `CompareTo(object)` called `left.CompareTo(right)`, which C# binds to the generated + `CompareTo(object)`, declared on the more derived type, so `<` overflowed the stack and ended the process. A + base's `TryFrom(T)` and `Revalidate()` were the value object's, and the generated ones, which run `Create`, were + not introduced. A base's `CompareTo(object)` failed the aspect (LAMA0500). A base's `FromKnownGood(T)` is another + signature than the generated one, which takes the caller's expression too, and is hidden by it. - **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did before CMTK1011 named it. So did a closed set's `IParsable` declared through an interface (LAMA0512). A closed set's interface that implements `IClosedSet.Name` or `TryParse` explicitly was replaced by the @@ -75,6 +84,12 @@ never as an explicit implementation. A closed set's `IClosedSet` is held other than the value object's own public member is refused. The record's own `==` is what the mapping finds for an interface's `==`, so that case needs no rule of its own. A base record's public member of a generated name that implements no interface is found by the same names and signatures as on the value object. +- **A seam counts where the value object declares it.** A base record's `CompareTo(TSelf)` or `CompareTo(object)` + is CMTK1008, and its `TryFrom(T)` or `Revalidate()` CMTK1011, which says to declare it on the value object, calling + the base's if that is what is meant. Taking a base's as the seam was the alternative: it would need the generated + operators to call it past C#'s overload resolution and the ordering marker to follow it, for a seam the value + object can declare in one line. A base record's `sealed` `ToString()` stays the seam, since C# keeps it in every + derived record and the value object cannot declare its own. - **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own, is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one rule set, which the generated code calls too, and builds. @@ -86,15 +101,14 @@ never as an explicit implementation. A closed set's `IClosedSet` is held test is by assembly symbol, never by name. - **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared through an interface of the user's still fails the aspect (LAMA0512, - [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A seam inherited from a base record, - `CompareTo(TSelf)` and `TryFrom` among them, is - [#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194)'s, and a hand-written `Equals(TBase)` + [#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A hand-written `Equals(TBase)` is [#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s. **The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set that reaches an interface's explicit implementation of a generated member, or a base record's in another assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one; -and one whose generic `T.Create` reached a base's `Create` beside its own. Each is code that never runs (an +one whose generic `T.Create` reached a base's `Create` beside its own; and one over a base record that declares +`CompareTo(TSelf)`, `TryFrom` or `Revalidate`. Each is code that never runs (an interface's default the generated member replaced) or a second answer beside the generated one (a base's kept member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in 2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build, @@ -107,6 +121,6 @@ error now, which breaks no build. interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`, `IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a base record's public member of each kind, in this project and in a library the generators do not run in, which -the fixture builds beside its consumers; a base record's `Create`; a closed set through an interface of either +the fixture builds beside its consumers; a base record's `Create`; a base record's seams, and its `FromKnownGood`, which builds; a closed set through an interface of either assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing ordering, an interface's `==`, an `INumberBase` value object, and one `Create` over a base record. diff --git a/design/adr/README.md b/design/adr/README.md index b5bcfd1..855e4e8 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | -| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own too (#195); `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #195, #197) | 2026-10-30 | accepted | +| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible` and a set's `IParsable` declared through an interface are CMTK1011 where the aspect crashed; an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #194, #195, #197) | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index 408d788..4234827 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -173,7 +173,9 @@ An interface's explicit implementation is the default every type declaring the interface reaches until it declares a public member, so the generated one replaced it without a word; a base record's public member of a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base - record's `Create` from another project was a second rule set for generic `T.Create`. Found by the interface + record's `Create` from another project was a second rule set for generic `T.Create`. A seam counts where the + value object declares it: a base record's `CompareTo(TSelf)`, `TryFrom` or `Revalidate` stood in for the + generated member, and an inherited `CompareTo` made `<` overflow the stack (#194). Found by the interface mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private member of a type in another assembly, so a base record's from a referenced project was missed as well. `IConvertible` declared through an interface, and a closed set's `IParsable` through one, are CMTK1011 diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 273ac26..875e5ff 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -215,6 +215,10 @@ method that calls `From` or `Create`. A value object may declare its own `TryFrom`, `FromKnownGood`, `Revalidate`, `CompareTo(TSelf)` and `ToString()`. Each one is kept, and whatever depends on it is built from it. +Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails +the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member +without a word. A base record's `sealed` `ToString()` is the one exception, as described below. + Writing any other generated member or attribute by hand fails the build, naming it (CMTK1011). That covers: diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs index 30f62e8..9e32c32 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs @@ -277,6 +277,17 @@ private static bool TryResolve(IAspectBuilder builder, string? wrapp return false; } + // A seam is kept where the value object declares it. A base record's stood in for the generated member + // without a word, so it is refused, naming what to declare instead (#194). + var inheritedSeams = ValueObjectDeclaration.InheritedSeams(target, namedValueType, kind); + + if (inheritedSeams.Count > 0) + { + builder.Diagnostics.Report(HandWrittenGeneratedMember.WithArguments((target, string.Join(", ", inheritedSeams), + $"a seam is kept only where the value object declares it, and a base record's would stand in for the generated member without a word; declare it on {target.Name} itself, calling the base's if that is what you mean, or remove it from the base"))); + return false; + } + // The generated code calls {TSelf}.Create(value), which cannot reach an explicit implementation // (LAMA0611, CS1929). Refused rather than called through a constrained type parameter: the // smaller change, and a public Create is what IValidatedValue documents. Beside a public one, an diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs index a9a02c2..4427a63 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs @@ -44,7 +44,7 @@ public override void BuildAspect(IAspectBuilder builder) // factories. Any other hand-written comparison member would sit beside generated ones that // need not agree with it, so it is an error naming the seam. Before this, an operator failed // the aspect (LAMA0500, naming no fix) and an object overload was kept silently. - var besideTheSeam = HandWrittenComparisonMembers(builder.Target).ToList(); + var besideTheSeam = HandWrittenComparisonMembers(builder.Target).Distinct().ToList(); if (besideTheSeam.Count > 0) { @@ -153,6 +153,21 @@ private static IEnumerable HandWrittenComparisonMembers(INamedType targe yield return method.ToDisplayString(); } + // A base record's CompareTo(object) failed the aspect (LAMA0500), and its CompareTo(TSelf), the seam, was + // used without a word: no [HandWrittenOrdering], so EF Core ordered by the column, and the generated + // operators called the generated CompareTo(object), which C# prefers to the base's, until the stack + // overflowed (#194, measured 2026-10-30). The seam is kept where the value object declares it. + foreach (var type in ValueObjectDeclaration.SelfAndBases(target).Skip(1)) + { + foreach (var method in type.Methods) + { + if (method is { IsStatic: false, IsExplicitInterfaceImplementation: false, Name: nameof(IComparable.CompareTo), Parameters: [{ } other] } + && ValueObjectDeclaration.IsHandWritten(method) + && (other.Type.SpecialType == SpecialType.Object || other.Type.ToNonNullable().Equals(target))) + yield return $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"; + } + } + foreach (var member in ValueObjectDeclaration.ImplementationsOf(target, [typeof(IComparable<>).ToNamedType(), typeof(IComparable).ToNamedType(), typeof(IComparisonOperators<,,>).ToNamedType()])) yield return member; diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs index 76c3d09..0e238cf 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectDeclaration.cs @@ -60,7 +60,7 @@ private static bool IsHandWrittenToString(IMethod method) => /// A value object and the records it derives from, left out. A struct has /// none: its base is , whose ToString() is nobody's seam. /// - private static IEnumerable SelfAndBases(INamedType target) + internal static IEnumerable SelfAndBases(INamedType target) { yield return target; @@ -420,6 +420,23 @@ private static string PublicName(IMember implementation, IMember implemented) return at < 0 ? implementation.ToDisplayString() : implementation.DeclaringType.ToDisplayString() + shown.Substring(at); } + /// + /// The factory seams of a validated value object that a base record declares rather than the value object: + /// TryFrom(T) and Revalidate(). The generated one is introduced only where none exists, and a + /// base's counted, so it stood in for the value object's without a word: TryFrom answered whatever the + /// base did, which need not call Create, and Revalidate threw where the base's did (#194, + /// measured 2026-10-30). A base's FromKnownGood(T) is hidden by the generated one, whose caller + /// expression parameter makes it another signature, so it is not one. + /// + public static IReadOnlyList InheritedSeams(INamedType target, INamedType valueType, ValueObjectKind kind) => + kind != ValueObjectKind.Validated + ? [] + : [.. SelfAndBases(target).Skip(1).SelectMany(type => type.Methods + .Where(method => IsHandWritten(method) && !method.IsExplicitInterfaceImplementation + && (method is { Name: "TryFrom", IsStatic: true, Parameters: [{ } value] } && value.Type.ToNonNullable().Equals(valueType.ToNonNullable()) + || method is { Name: "Revalidate", IsStatic: false, Parameters.Count: 0 })) + .Select(method => $"{method.ToDisplayString()} in its base type {type.ToDisplayString()}"))]; + /// /// How a validated value object declares Create: as an explicit implementation of the /// marker's, and as a static method the generated code can call as {TSelf}.Create(value). @@ -615,7 +632,7 @@ internal static string Describe(IMember member) /// Written by the user, not synthesized by the compiler: a record in a referenced assembly reports its /// synthesized members as declared, and marks them [CompilerGenerated]. /// - private static bool IsHandWritten(IMember member) => + internal static bool IsHandWritten(IMember member) => !member.IsImplicitlyDeclared && !member.Attributes.Any(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(CompilerGeneratedAttribute)))); /// Non-implicit members of with one of , of any kind. diff --git a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs index 53170f7..1849657 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/BuildOutcomeTests.cs @@ -465,10 +465,24 @@ public void One_Create_over_a_base_record_is_not_refused(string type) => [InlineData("CMTK1011", "InheritsValueName", "ValueNameBase.Value in its base type ValueNameBase")] [InlineData("CMTK1011", "InheritsFromName", "FromNameBase.From(int) in its base type FromNameBase")] [InlineData("CMTK1011", "InheritsTryFormatName", "TryFormatNameBase.TryFormat(Span, out int, ReadOnlySpan, IFormatProvider?) in its base type TryFormatNameBase")] + [InlineData("CMTK1008", "InheritsCompareTo", "CompareToSeamBase.CompareTo(InheritsCompareTo?) in its base type CompareToSeamBase, but its comparison is generated from CompareTo(InheritsCompareTo)")] + [InlineData("CMTK1008", "InheritsObjectCompareTo", "ObjectCompareToBase.CompareTo(object?) in its base type ObjectCompareToBase")] + [InlineData("CMTK1011", "InheritsTryFrom", "TryFromSeamBase.TryFrom(string) in its base type TryFromSeamBase, so it is not generated: a seam is kept only where the value object declares it")] + [InlineData("CMTK1011", "InheritsRevalidate", "RevalidateSeamBase.Revalidate() in its base type RevalidateSeamBase, so it is not generated: a seam is kept only where the value object declares it")] [InlineData("CMTK1011", "InheritsLibraryPublicMinMax", "PublicMinMaxBase.MinValue in its base type PublicMinMaxBase")] public void What_a_base_record_declares_is_refused_by_name(string id, string type, string member) => consumer.Errors.ShouldContain(error => error.Id == id && error.Message.StartsWith($"'{type}' declares ") && error.Message.Contains(member), consumer.Output); + /// + /// A base record's public CompareTo(object) that implements IComparable is found twice, by the + /// interface mapping and as an inherited comparison member (#194), and named once. + /// + [Fact] + public void A_base_member_found_twice_is_named_once() => + consumer.Errors.Where(error => error.Message.StartsWith("'InheritsPublicObjectOrder' declares ", StringComparison.Ordinal)) + .ShouldHaveSingleItem(consumer.Output) + .Message.Split("PublicObjectOrderBase.CompareTo(object?)").Length.ShouldBe(2, consumer.Output); + /// /// A public static member of another project that implements a static abstract one reads as an explicit /// implementation there, since the compiler records every such implementation as one, and the error called it @@ -519,7 +533,8 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t /// The seams stay allowed: Create, TryFrom, FromKnownGood, Revalidate, /// CompareTo(TSelf) and ToString(). A MinValue of another type, or one the value /// object cannot reach, is not the wrapped type's bound: no MinValue is generated, where it - /// failed inside the generated code (LAMA0611, CS1503 and CS0122). + /// failed inside the generated code (LAMA0611, CS1503 and CS0122). A base record's + /// FromKnownGood(T) is hidden by the generated one, so it is no inherited seam (#194). /// [Theory] [InlineData("WithEverySeam", "'WithEverySeam")] @@ -532,6 +547,7 @@ public void Instance_state_besides_the_wrapped_value_is_refused_by_name(string t [InlineData("InheritsSealedToString", "'InheritsSealedToString")] [InlineData("InheritsFormattableBesideSealedToString", "'InheritsFormattableBesideSealedToString")] [InlineData("WithStatelessBase", "Described.")] + [InlineData("InheritsFromKnownGoodName", "FromKnownGoodNameBase")] public void A_seam_or_a_foreign_bound_is_not_refused(string type, string alsoNotNamed) => consumer.Errors.ShouldNotContain(error => error.Message.Contains($"'{type}'") || error.Message.Contains(alsoNotNamed), consumer.Output); @@ -1437,6 +1453,55 @@ public abstract record TryFormatNameBase public sealed partial record InheritsTryFormatName : TryFormatNameBase, IValue; + // A seam a base record declares rather than the value object (#194): its CompareTo(TSelf) left the + // ordering unmarked for EF Core and made < overflow the stack, its TryFrom and Revalidate stood in for + // the generated ones, and its CompareTo(object) failed the aspect (LAMA0500). + public abstract record CompareToSeamBase where TSelf : CompareToSeamBase + { + public int CompareTo(TSelf? other) => 0; + } + + public sealed partial record InheritsCompareTo : CompareToSeamBase, IValue; + + public abstract record ObjectCompareToBase + { + public int CompareTo(object? obj) => 0; + } + + public sealed partial record InheritsObjectCompareTo : ObjectCompareToBase, IValue; + + public abstract record TryFromSeamBase where TSelf : TryFromSeamBase + { + public static Option TryFrom(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsTryFrom : TryFromSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + public abstract record RevalidateSeamBase where TSelf : RevalidateSeamBase + { + public Result Revalidate() => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsRevalidate : RevalidateSeamBase, IValidatedValue + { + public static Result Create(string value) => Result.Error(Fault.Refused); + } + + // A base's FromKnownGood(T) is another signature than the generated one, which takes the caller's + // expression too and hides it, so it stands in for nothing. Generated as usual. + public abstract record FromKnownGoodNameBase where TSelf : FromKnownGoodNameBase + { + public static TSelf FromKnownGood(string value) => throw new System.NotSupportedException(); + } + + public sealed partial record InheritsFromKnownGoodName : FromKnownGoodNameBase, IValidatedValue + { + public static Result Create(string value) => new InheritsFromKnownGoodName(value); + } + // From a project the generators do not run in: read as an explicit implementation there, named as public. public sealed partial record InheritsLibraryPublicMinMax : Library.PublicMinMaxBase, IValue; diff --git a/test/oracles/claims.json b/test/oracles/claims.json index a44cd2e..18c6113 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1209,6 +1209,20 @@ "BuildOutcomeTests" ] }, + { + "id": "C22-a-seam-in-a-base-record", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "Declare them on the value object itself. A `TryFrom`, `Revalidate` or `CompareTo` in a base record fails the build (CMTK1011, or CMTK1008 for `CompareTo`), because it would stand in for the generated member without a word.", + "claim": "A TryFrom, Revalidate or CompareTo a base record declares fails the build, CMTK1008 for CompareTo.", + "oracle": "exception", + "surfaces": [ + "IPlainValueObject.*", + "IValidatedValue.*" + ], + "heldBy": [ + "BuildOutcomeTests" + ] + }, { "id": "C22-abstract", "source": "src/CodoMetis.TypeKit.Generators/README.md#build-errors",