Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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: 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<DeepBase>` 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)).

## 2.3.0 — 2026-10-29

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TSelf>` declared through an interface (LAMA0512). A
closed set's interface that implements `IClosedSet<TSelf>.Name` or `TryParse` explicitly was replaced by the
Expand Down Expand Up @@ -75,6 +84,12 @@ never as an explicit implementation. A closed set's `IClosedSet<TSelf>` 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.
Expand All @@ -86,15 +101,14 @@ never as an explicit implementation. A closed set's `IClosedSet<TSelf>` 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,
Expand All @@ -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<TSelf>` value object, and one `Create` over a base record.
2 changes: 1 addition & 1 deletion design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
4 changes: 3 additions & 1 deletion design/aspects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSelf>` through one, are CMTK1011
Expand Down
4 changes: 4 additions & 0 deletions src/CodoMetis.TypeKit.Generators/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 11 additions & 0 deletions src/CodoMetis.TypeKit.Generators/ValueObjectAspect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,17 @@ private static bool TryResolve(IAspectBuilder<INamedType> 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
Expand Down
17 changes: 16 additions & 1 deletion src/CodoMetis.TypeKit.Generators/ValueObjectComparableAspect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public override void BuildAspect(IAspectBuilder<INamedType> 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)
{
Expand Down Expand Up @@ -153,6 +153,21 @@ private static IEnumerable<string> 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;
Expand Down
Loading
Loading