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 @@ -15,6 +15,7 @@ section is the GitHub release's notes.
### Changed

- Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.3.1.
- Generators: a generated member you wrote where a value object reaches it now fails the build (CMTK1011, CMTK1008 for comparison), so your build can fail when you upgrade: an explicit implementation such as `IFormattable.ToString` in an interface a value object or closed set implements, one in a base record from another project, a base record's public `MinValue` or `MaxValue`, which stood in for the generated one, and a base record's `Create` beside your own. Remove it, don't implement that interface, or declare the marker on the value object itself. A base record's public `Value`, `Parse`, `ToString(string?, IFormatProvider?)` and the like, and `IConvertible` through an interface, now fail with a CMTK error rather than a Metalama crash ([Write your own members](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members)).

## 2.3.0 — 2026-10-29

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 0121. A generated member written by hand is refused wherever the value object reaches it

Date: 2026-10-30
Status: accepted

Context: [#191](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/191), under the 2.3.1 epic
[#198](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/198), found while
[#188](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/pull/188) closed the same gap for the arithmetic
operators, where an interface's explicit implementation is kept, from an outside review of that pull request;
widened the same day by [#195](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/195) and
[#197](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/197), from a second outside review; refines
[decision 28](0028-seams-declared-everything-else-refused.md), and is an exception to
[decision 34](0034-errors-fixed-within-major-version-warnings.md), taken in a patch release. Decision 28 refuses a member the
generators introduce written by hand, and an explicit implementation of an interface they implement, on the value
object and, since phase 11, on a base record (CMTK1011, CMTK1008 for the comparison interfaces).
`ValueObjectDeclaration` listed the value object's members for the first, and the value object's and its base
records' for the second, and `CreateDeclarations` the value object's own.

## What was measured

On `release/2.4.0` at `97c87c1`, 2026-10-30, with a consumer of the generators, the code these members are
generated by unchanged since 2.3.0 (`v2.3.0`, edeb90c), which 2.3.1 patches:

- **An interface's explicit implementation was replaced without a word.** An interface can implement a member
of a base interface explicitly, and every type that declares it reaches that default until it declares a
public member of its own. The generated member is one, for each of the eleven interfaces the generators
implement: `IFormattable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`,
`IUtf8SpanParsable`, `IMinMaxValue`, `IComparable`, `IComparable<T>`, `IComparisonOperators` (each answered
the generated member's result through a generic call, where the interface's threw or answered its own) and
`IConvertible` (below). The same held two interfaces down and through an interface of a base record.
- **Another assembly's were missed too, a base record's included.** Metalama lists no private member of a type
in another assembly, and an explicit implementation is private, so the listing saw none in a referenced
project. An interface there was replaced as above. A base record there was replaced or kept answering the
interface: an `IFormattable` call on a value object over a referenced record that implements it printed `1`,
where the base answered its own text, and `Max<T>()` over a referenced `MinMaxBase<TSelf>` reached the base's,
which threw. A closed set over a referenced base class that implements `IClosedSet<TSelf>` explicitly listed
two members in `All` and none through `T.All`, named `Red` "base" through the interface, and EF Core stored
"base" and read no member's name back. The rule has had this gap since phase 11.
`INamedType.TryFindImplementationForInterfaceMember` finds every one of them.
- **A base record's public member counted for nothing** (#197). Its public static `MinValue` and `MaxValue`, with
`IMinMaxValue<TSelf>` or without, were the value object's, and no `MinValue` or `MaxValue` was generated. Its
public `Value`, `From`, `Parse`, `TryParse`, `ToString(string?, IFormatProvider?)`, `TryFormat` or
`CompareTo(object)` failed the aspect (LAMA0500), naming nothing the user wrote. From another assembly, a
public static member that implements a static abstract one reads as an explicit implementation, since the
compiler records every such implementation as one, so it was refused as "the explicit implementation of ...".
- **A base record's `Create` gave two rule sets** (#195). Where the value object reaches the marker only through
a base record of another project that implements `Create` explicitly, `TryFrom("x")` ran the value object's
`Create`, which accepted, and generic `T.Create("x")` the base's, which refused. Declaring the marker on the value
object as well re-implements it, and both agree. In the same project such a base carries the marker and is
refused already (CMTK1022).
- **`IConvertible` declared through an interface** failed the aspect (LAMA0041), as declaring it directly did
before CMTK1011 named it. So did a closed set's `IParsable<TSelf>` declared through an interface (LAMA0512). A
closed set's interface that implements `IClosedSet<TSelf>.Name` or `TryParse` explicitly was replaced by the
generated members without a word.
- **An interface's `==`, `!=` and `IEquatable<T>.Equals` are replaced by the record's own**, in a plain record
as in a value object, so the generators change nothing there. For the same reason
`ValueObjectContractAspect.ExplicitlyImplementsEquatableBetween` has no gap: a base record's synthesized
`Equals(TBase)` implements `IEquatable<TBase>` before any interface's default (a `HashSet` of the base held
both value objects).
- **.NET implements three such members itself**: `INumberBase<TSelf>` implements `IUtf8SpanFormattable.TryFormat`
and `IUtf8SpanParsable<TSelf>.Parse` and `TryParse` explicitly, as bridges to the UTF-16 members. A scan of the
interfaces of `System.Private.CoreLib` for explicit implementations of the eleven found no other; the reference
assembly carries the same three.

## Decision

**CMTK1011, and CMTK1008 for the comparison interfaces, refuse a member the generators introduce wherever the
value object reaches it**: written on it, or in a base record, public or explicit, or as an explicit implementation
in an interface it implements, directly or through another, in this assembly or another. The error names the
member and where it is, "in its base type `X`" or "in its interface `X`", and a public member by its own name,
never as an explicit implementation. A closed set's `IClosedSet<TSelf>` is held the same way.

- **Found by the interface mapping, never by listing members.** For each member of a generated interface the
value object implements, `TryFindImplementationForInterfaceMember` gives the implementation C# maps it to; one
other than the value object's own public member is refused. The record's own `==` is what the mapping finds for
an interface's `==`, so that case needs no rule of its own. A base record's public member of a generated name
that implements no interface is found by the same names and signatures as on the value object.
- **A base record's `Create` counts.** One the value object reaches through the marker, explicit or beside its own,
is CMTK1011, which says to declare the marker on the value object itself. A base's public `Create` alone is one
rule set, which the generated code calls too, and builds.
- **`IConvertible` and a set's `IParsable<TSelf>` declared through an interface** are CMTK1011, naming the
interface, where the aspect failed.
- **Not refused: an interface's explicit implementation of a member of an interface of its own library.** That
is .NET's `INumberBase<TSelf>`: every .NET number replaces its bridges with its own UTF-8 members, as the
generated ones do, and refusing them would leave no value object able to implement `INumberBase<TSelf>`. The
test is by assembly symbol, never by name.
- **Not this decision:** `IValueObject<,>`, `IValueObjectMaterializer<,>` or `IPlainValueObject<,>` declared
through an interface of the user's still fails the aspect (LAMA0512,
[#192](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/192)). A seam inherited from a base record,
`CompareTo(TSelf)` and `TryFrom` among them, is
[#194](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/194)'s, and a hand-written `Equals(TBase)`
[#196](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/196)'s.

**The exception to decision 34.** A build that worked on 2.3.0 can fail on 2.3.1: a value object or a closed set
that reaches an interface's explicit implementation of a generated member, or a base record's in another
assembly; a value object over a base record whose public `MinValue` or `MaxValue` stood in for the generated one;
and one whose generic `T.Create` reached a base's `Create` beside its own. Each is code that never runs (an
interface's default the generated member replaced) or a second answer beside the generated one (a base's kept
member), in every build that contains it, so the error names a defect, never correct code. The defect shipped in
2.3.0, so its fix ships in the patch 2.3.1, not in a minor: waiting would keep a wrong answer in every 2.3 build,
and decision 119's reason, an error that names a defect in every build that contains it, holds after a tag as it
does before one. The changelog's Changed section says which
declarations fail and what to write instead. The forms that failed the aspect before fail the build with a CMTK
error now, which breaks no build.

`BuildOutcomeTests` holds it: one value object per interface, two interfaces down, through a base record's
interface, a record class over a reference type, and from another assembly (`IProbeFormats`, `IProbeBounded`,
`IProbeOrders`, `ProbeFormattableRecord` and `ProbeMinMaxRecord` in `CodoMetis.TypeKit.Generators.Probes`); a
base record's public member of each kind, in this project and in a library the generators do not run in, which
the fixture builds beside its consumers; a base record's `Create`; a closed set through an interface of either
assembly and over the library's base class; and the cases that build: a `ToString()` seam, a `Uri`'s missing
ordering, an interface's `==`, an `INumberBase<TSelf>` value object, and one `Create` over a base record.
1 change: 1 addition & 0 deletions design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,4 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) |
| [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted |
| [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted |
| [0121](0121-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 |
10 changes: 10 additions & 0 deletions design/aspects.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@
own: its `sealed` `ToString()` is the seam; an unsealed one, its `PrintMembers`, its explicit
implementations of the generated interfaces and an `IConvertible` on it are CMTK1011, or CMTK1008
for comparison. Members the compiler generated on a record from another assembly do not count.
- **Wherever the value object reaches it (decided 2026-10-30, [decision 121](adr/0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md)).**
An interface's explicit implementation is the default every type declaring the interface reaches until it
declares a public member, so the generated one replaced it without a word; a base record's public member of
a generated name was kept as the value object's (`MaxValue`) or failed the aspect (LAMA0500); and a base
record's `Create` from another project was a second rule set for generic `T.Create`. Found by the interface
mapping (`TryFindImplementationForInterfaceMember`), never by listing members: Metalama lists no private
member of a type in another assembly, so a base record's from a referenced project was missed as well.
`IConvertible` declared through an interface, and a closed set's `IParsable<TSelf>` through one, are CMTK1011
where they failed the aspect (LAMA0041, LAMA0512). Not refused: an interface's `==`, which the record's own
replaces anyway, and `INumberBase<TSelf>`'s UTF-8 bridges.
- **Equality over a base record is generated (decided 2026-10-14, [decision 75](adr/0075-equality-over-base-record-generated.md)).**
A derived record's synthesized equality calls the base's, which compares the base's fields and
whatever the base declares. A base comparing by reference made two value objects holding 42 unequal
Expand Down
16 changes: 10 additions & 6 deletions src/CodoMetis.TypeKit.Generators/ClosedSetTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,17 @@ private static string WithoutComments(string text)
/// overload of another signature is the set's own and is kept, as is <c>ToString()</c>, the seam. A
/// member of the set with a generated name is CMTK1021's, which says to rename it.
/// </summary>
/// <remarks>
/// An explicit implementation of <c>IClosedSet&lt;TSelf&gt;</c> in an interface the set implements is the
/// set's too: the generated <c>Name</c> and <c>TryParse</c> replaced an interface's without a word (#191,
/// measured 2026-10-30). <c>IParsable&lt;TSelf&gt;</c> declared through an interface failed the aspect as
/// declaring it does (LAMA0512).
/// </remarks>
public static IReadOnlyList<string> GeneratedMembersDeclaredByHand(INamedType set, IReadOnlyList<IField> members)
{
var text = TypeFactory.GetType(SpecialType.String);
var boolean = TypeFactory.GetType(SpecialType.Boolean);
var parsable = TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set);
var memberNames = new HashSet<string>(members.Select(member => member.ToDisplayString()));

return
Expand All @@ -201,9 +208,9 @@ .. ValueObjectDeclaration.MembersNamed(set, methods: false, [.. GeneratedNames(s
.. set.Methods.Where(method => IsOwn(method) && method.Name == "Parse" && (SameParameters(method, text) || SameParameters(method, text, boolean))).Select(ValueObjectDeclaration.Describe),
.. set.Methods.Where(method => IsOwn(method) && method.Name == "TryParse" && (SameParameters(method, text, set) || SameParameters(method, text, boolean, set))).Select(ValueObjectDeclaration.Describe),
.. set.Methods.Where(method => IsOwn(method) && IsGeneratedMatch(method, members.Count)).Select(ValueObjectDeclaration.Describe),
.. set.Methods.Where(method => method is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && method.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe),
.. set.Properties.Where(property => property is { IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: true } && property.ExplicitInterfaceImplementations.Any(OfClosedSet)).Select(ValueObjectDeclaration.Describe),
.. set.ImplementedInterfaces.Where(implemented => implemented.Equals(TypeFactory.GetNamedType(typeof(IParsable<>)).WithTypeArguments(set))).Select(implemented => $"the interface {implemented.ToDisplayString()}"),
.. ValueObjectDeclaration.ImplementationsOf(set, [TypeFactory.GetNamedType(typeof(IClosedSet<>))]),
.. set.ImplementedInterfaces.Where(implemented => implemented.Equals(parsable)).Select(implemented => $"the interface {implemented.ToDisplayString()}"),
.. set.ImplementedInterfaces.Where(implemented => implemented.AllImplementedInterfaces.Any(inherited => inherited.Equals(parsable))).Select(implemented => $"the interface {parsable.ToDisplayString()} in its interface {implemented.ToDisplayString()}"),
.. set.Attributes.Where(attribute => attribute.Type.Equals(TypeFactory.GetNamedType(typeof(JsonConverterAttribute)))
|| attribute.Type.Equals(TypeFactory.GetNamedType(typeof(TypeConverterAttribute)))
|| attribute.Type.Definition.Equals(TypeFactory.GetNamedType(typeof(GeneratedClosedSetAttribute<>))))
Expand All @@ -229,7 +236,4 @@ private static bool IsGeneratedMatch(IMethod method, int memberCount) =>
/// <summary>Whether a <c>ToString()</c> is written by hand: the seam, kept in place of the generated one.</summary>
public static bool DeclaresToString(INamedType set) =>
set.Methods.Any(method => method is { Name: "ToString", IsStatic: false, IsImplicitlyDeclared: false, IsExplicitInterfaceImplementation: false, Parameters.Count: 0 });

private static bool OfClosedSet(IMember implemented) =>
implemented.DeclaringType.Definition.Equals(TypeFactory.GetNamedType(typeof(IClosedSet<>)));
}
Loading
Loading